Compare commits
75 Commits
92088e9c8a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9caa43d4a2 | |||
| dff4674c54 | |||
| 19e7a51b61 | |||
| 69bd91797d | |||
| 3b1f99951e | |||
| 2d87c267cf | |||
| d0e286e4bb | |||
| aa5895873b | |||
| c37e6ab4f2 | |||
| f160adbdb1 | |||
| 43828a87f0 | |||
| 2eb5a167b3 | |||
| 8ee86c7b0f | |||
| 9effdcd070 | |||
| 7c7b334f5c | |||
| 8716f6f684 | |||
| 1d33a8e14f | |||
| 7db1f9162b | |||
| d54510a864 | |||
| 28df633b00 | |||
| c83f23c319 | |||
| 90517f2289 | |||
| 308df54a15 | |||
| dcd1fcd709 | |||
| 18c02053da | |||
| 3c353cb701 | |||
| 0d5cd329ca | |||
| e950484af6 | |||
| cbc1af7348 | |||
| 88d42b37a6 | |||
| b5343bcd9d | |||
| 9b22b647f2 | |||
| 50cd1e29c9 | |||
| ba02eb10a7 | |||
| 7c656785c8 | |||
| f692961823 | |||
| 1be470f45b | |||
| 96c709f109 | |||
| fd77455f5b | |||
| 72d79ae214 | |||
| 53c1045406 | |||
| fd2255c83a | |||
| 986ecb2561 | |||
| 8d4a9a3704 | |||
| e6c41491b3 | |||
| cd70b50180 | |||
| 96390df254 | |||
| 371d4ce327 | |||
| cd1b541427 | |||
| 668a1cb473 | |||
| 517592e216 | |||
| 3e360c1807 | |||
| 8bb2e643d8 | |||
| 67a19d486b | |||
| 04fc326a8d | |||
| 1b80fd036d | |||
| 320d2654f5 | |||
| bbd33339a5 | |||
| 1fa17f7c9d | |||
| 7a39fc3bce | |||
| 2503d8be64 | |||
| de4c101b36 | |||
| 433db24688 | |||
| 40f99f0c49 | |||
| 8839ec244a | |||
| 073abafdfd | |||
| 84e455d9a6 | |||
| 6d5520dfa5 | |||
| 788757b785 | |||
| 48fd2f5463 | |||
| 88f265757c | |||
| 4273b3d43b | |||
| 4c18edf74f | |||
| d65b3e7909 | |||
| 49bf8a97d2 |
18
.gitignore
vendored
18
.gitignore
vendored
@@ -26,6 +26,13 @@ dist-ssr
|
|||||||
|
|
||||||
# Build output
|
# Build output
|
||||||
release/
|
release/
|
||||||
|
dist-api/
|
||||||
|
dist-electron/
|
||||||
|
|
||||||
|
# XCOpenCodeWeb (来自独立仓库 https://github.com/anomalyco/XCOpenCodeWeb)
|
||||||
|
remote/xcopencodeweb/XCOpenCodeWeb.exe
|
||||||
|
service/xcopencodeweb/XCOpenCodeWeb.exe
|
||||||
|
services/xcopencodeweb/XCOpenCodeWeb.exe
|
||||||
|
|
||||||
# Tools output
|
# Tools output
|
||||||
tools/tongyi/ppt_output/
|
tools/tongyi/ppt_output/
|
||||||
@@ -35,4 +42,13 @@ tools/mineru/__pycache__/
|
|||||||
tools/blog/__pycache__/
|
tools/blog/__pycache__/
|
||||||
|
|
||||||
# Notebook pydemos backup
|
# Notebook pydemos backup
|
||||||
notebook/
|
notebook/
|
||||||
|
|
||||||
|
# Gitea data (运行时数据,不需要版本控制)
|
||||||
|
remote/gitea/data/
|
||||||
|
|
||||||
|
# SDD service (来自独立仓库)
|
||||||
|
services/xcsdd/
|
||||||
|
|
||||||
|
# Terminal service
|
||||||
|
services/xcterminal/
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# 贡献指南
|
# 贡献指南
|
||||||
|
|
||||||
感谢你对 XCNote 项目的兴趣!我们欢迎任何形式的贡献,包括但不限于:
|
感谢你对 XCDesktop 项目的兴趣!我们欢迎任何形式的贡献,包括但不限于:
|
||||||
|
|
||||||
- 🐛 报告 Bug
|
- 🐛 报告 Bug
|
||||||
- 💡 提出新功能建议
|
- 💡 提出新功能建议
|
||||||
@@ -19,8 +19,8 @@
|
|||||||
### 克隆项目
|
### 克隆项目
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/your-repo/XCNote.git
|
git clone https://github.com/your-repo/XCDesktop.git
|
||||||
cd XCNote
|
cd XCDesktop
|
||||||
```
|
```
|
||||||
|
|
||||||
### 安装依赖
|
### 安装依赖
|
||||||
@@ -162,7 +162,7 @@ fix(editor): 修复保存内容丢失问题
|
|||||||
## 项目结构概览
|
## 项目结构概览
|
||||||
|
|
||||||
```
|
```
|
||||||
XCNote/
|
XCDesktop/
|
||||||
├── src/ # 前端源码
|
├── src/ # 前端源码
|
||||||
│ ├── components/ # UI 组件
|
│ ├── components/ # UI 组件
|
||||||
│ ├── contexts/ # React Context
|
│ ├── contexts/ # React Context
|
||||||
|
|||||||
50
README.md
50
README.md
@@ -8,12 +8,14 @@
|
|||||||
[](https://vitejs.dev/)
|
[](https://vitejs.dev/)
|
||||||
[](https://tailwindcss.com/)
|
[](https://tailwindcss.com/)
|
||||||
|
|
||||||
一站式 AI 工作台 - 集成笔记管理、时间追踪、任务管理、AI 辅助等多种功能
|
一站式 AI 工作台 - 集成笔记管理、时间追踪、远程桌面控制、AI 辅助等多种功能
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## ✨ 核心特性
|
## ✨ 核心特性
|
||||||
|
|
||||||
|
- **🖥️ 远程桌面控制** - 屏幕监控与远程控制,支持内网穿透,随时随地访问
|
||||||
|
- **🤖 AI 编程助手** - Web 版 OpenCode 界面,随时随地编程
|
||||||
- **🤖 AI 集成** - 深度集成通义万相、豆包等 AI 能力,支持语音转文字、视频解析、PPT 提取等
|
- **🤖 AI 集成** - 深度集成通义万相、豆包等 AI 能力,支持语音转文字、视频解析、PPT 提取等
|
||||||
- **📝 Markdown 笔记管理** - 基于 Milkdown 编辑器,支持数学公式(KaTeX)、代码高亮(Prism)、表格、任务列表等
|
- **📝 Markdown 笔记管理** - 基于 Milkdown 编辑器,支持数学公式(KaTeX)、代码高亮(Prism)、表格、任务列表等
|
||||||
- **⏱️ 时间追踪** - 记录学习和工作时间,生成生产力统计图表
|
- **⏱️ 时间追踪** - 记录学习和工作时间,生成生产力统计图表
|
||||||
@@ -86,6 +88,9 @@ XCDesktop/
|
|||||||
│ │ ├── pydemos/ # Python Demo
|
│ │ ├── pydemos/ # Python Demo
|
||||||
│ │ ├── weread/ # 微信读书
|
│ │ ├── weread/ # 微信读书
|
||||||
│ │ └── remote/ # 远程网页
|
│ │ └── remote/ # 远程网页
|
||||||
|
│ │ │ ├── opencode/ # AI 编程助手
|
||||||
|
│ │ │ ├── sdd/ # 规范驱动开发
|
||||||
|
│ │ │ └── terminal/ # 终端
|
||||||
│ ├── stores/ # Zustand 状态管理
|
│ ├── stores/ # Zustand 状态管理
|
||||||
│ └── types/ # 类型定义
|
│ └── types/ # 类型定义
|
||||||
├── api/ # 后端 API (Express)
|
├── api/ # 后端 API (Express)
|
||||||
@@ -96,9 +101,9 @@ XCDesktop/
|
|||||||
│ │ ├── ai/ # AI 集成
|
│ │ ├── ai/ # AI 集成
|
||||||
│ │ ├── document-parser/ # 文档解析
|
│ │ ├── document-parser/ # 文档解析
|
||||||
│ │ ├── pydemos/ # Python Demo
|
│ │ ├── pydemos/ # Python Demo
|
||||||
│ │ ├── recycle-bin/ # 回收站
|
│ │ ├── recycle-bin/ # 回收站
|
||||||
│ │ ├── remote/ # 远程网页
|
│ │ ├── remote/ # 远程网页
|
||||||
│ │ ├── time-tracking/ # 时间追踪
|
│ │ ├── time-tracking/ # 时间追踪
|
||||||
│ │ └── todo/ # 任务管理
|
│ │ └── todo/ # 任务管理
|
||||||
│ ├── middlewares/ # 中间件
|
│ ├── middlewares/ # 中间件
|
||||||
│ ├── schemas/ # 数据验证
|
│ ├── schemas/ # 数据验证
|
||||||
@@ -107,8 +112,17 @@ XCDesktop/
|
|||||||
│ └── events/ # 事件总线
|
│ └── events/ # 事件总线
|
||||||
├── electron/ # Electron 主进程
|
├── electron/ # Electron 主进程
|
||||||
├── shared/ # 共享类型和配置
|
├── shared/ # 共享类型和配置
|
||||||
|
├── remote/ # 远程桌面监控系统
|
||||||
|
│ ├── src/ # 后端服务
|
||||||
|
│ ├── public/ # 前端页面
|
||||||
|
│ ├── frp/ # 内网穿透 (FRP)
|
||||||
|
│ └── gitea/ # 自托管 Git 服务
|
||||||
|
├── service/ # Web 版 AI 编程助手
|
||||||
|
│ └── xcopencodeweb/ # OpenCode Web 界面
|
||||||
├── tools/ # 工具脚本
|
├── tools/ # 工具脚本
|
||||||
│ └── tongyi/ # 通义万相 AI 工具
|
│ └── tongyi/ # 通义万相 AI 工具
|
||||||
|
├── command/ # 项目任务文档
|
||||||
|
├── public/ # 静态资源
|
||||||
├── notebook/ # 笔记数据存储(运行时)
|
├── notebook/ # 笔记数据存储(运行时)
|
||||||
└── release/ # 构建输出
|
└── release/ # 构建输出
|
||||||
```
|
```
|
||||||
@@ -129,6 +143,8 @@ XCDesktop/
|
|||||||
|
|
||||||
| 模块 | 功能描述 |
|
| 模块 | 功能描述 |
|
||||||
|------|----------|
|
|------|----------|
|
||||||
|
| 远程桌面控制 | 屏幕监控与远程控制,支持内网穿透,随时随地访问 |
|
||||||
|
| AI 编程助手 | Web 版 OpenCode 界面,随时随地编程 |
|
||||||
| AI 集成 | 通义万相语音转文字、视频解析、PPT 提取等 |
|
| AI 集成 | 通义万相语音转文字、视频解析、PPT 提取等 |
|
||||||
| 文档解析 | 支持导入博客、PDF 等格式 |
|
| 文档解析 | 支持导入博客、PDF 等格式 |
|
||||||
| 时间追踪 | 记录工作/学习时间,统计生产力 |
|
| 时间追踪 | 记录工作/学习时间,统计生产力 |
|
||||||
@@ -137,6 +153,8 @@ XCDesktop/
|
|||||||
| Python Demo | Python 脚本管理 |
|
| Python Demo | Python 脚本管理 |
|
||||||
| 微信读书 | 微信读书网页版集成 |
|
| 微信读书 | 微信读书网页版集成 |
|
||||||
| 远程网页 | 内置浏览器,访问任意网页 |
|
| 远程网页 | 内置浏览器,访问任意网页 |
|
||||||
|
| SDD | 规范驱动开发,自动化流程 |
|
||||||
|
| 终端 | 集成终端界面,管理和执行命令 |
|
||||||
|
|
||||||
## 🛠️ 技术栈
|
## 🛠️ 技术栈
|
||||||
|
|
||||||
@@ -164,6 +182,13 @@ XCDesktop/
|
|||||||
- **框架**: Electron 40
|
- **框架**: Electron 40
|
||||||
- **日志**: electron-log
|
- **日志**: electron-log
|
||||||
|
|
||||||
|
### 远程控制
|
||||||
|
|
||||||
|
- **屏幕捕获**: FFmpeg, H.264 编码
|
||||||
|
- **流媒体**: WebSocket
|
||||||
|
- **内网穿透**: FRP
|
||||||
|
- **Git 服务**: Gitea
|
||||||
|
|
||||||
### AI 工具
|
### AI 工具
|
||||||
|
|
||||||
- **语音识别**: 通义听悟
|
- **语音识别**: 通义听悟
|
||||||
@@ -184,6 +209,25 @@ XCDesktop/
|
|||||||
└── downloads/ # 下载文件
|
└── downloads/ # 下载文件
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## ☁️ 远程服务
|
||||||
|
|
||||||
|
项目包含多个可选的远程服务组件,部署在远程被控电脑上:
|
||||||
|
|
||||||
|
### 远程桌面控制 (remote/)
|
||||||
|
|
||||||
|
**需部署在被控电脑上**的独立远程桌面监控系统:
|
||||||
|
- 屏幕捕获与 H.264 流媒体传输
|
||||||
|
- 远程鼠标、键盘控制
|
||||||
|
- FRP 内网穿透,支持外网访问
|
||||||
|
- 内置 Gitea 自托管 Git 服务
|
||||||
|
|
||||||
|
### AI 编程助手 (service/xcopencodeweb/)
|
||||||
|
|
||||||
|
Web 版 AI 编程助手(来自独立仓库 [XCOpenCodeWeb](https://github.com/anomalyco/XCOpenCodeWeb)),连接 OpenCode 服务器:
|
||||||
|
- 单文件 exe,直接运行
|
||||||
|
- 支持外部 OpenCode 服务器
|
||||||
|
- 随时随地编程
|
||||||
|
|
||||||
## 🤝 贡献指南
|
## 🤝 贡献指南
|
||||||
|
|
||||||
欢迎提交 Issue 和 Pull Request!请先阅读 [CONTRIBUTING.md](CONTRIBUTING.md)。
|
欢迎提交 Issue 和 Pull Request!请先阅读 [CONTRIBUTING.md](CONTRIBUTING.md)。
|
||||||
|
|||||||
3
api/.env
Normal file
3
api/.env
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
MINIMAX_API_KEY=sk-api-Ip6OsHSvDJlnbl7L9sRsK277vx1W6VfM8xXfbjjEcdsnT_91kZmciWDiNHzMkDMTEsxirTZdA-shZ-oYS0Qo70m3raWeO7_1Zr8rmM9D5QFWKgkLya60HrA
|
||||||
|
MINIMAX_GROUP_ID=1982508094420165122
|
||||||
|
OPENAI_API_KEY=
|
||||||
2
api/.env.example
Normal file
2
api/.env.example
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
MINIMAX_API_KEY=your_api_key_here
|
||||||
|
MINIMAX_GROUP_ID=your_group_id_here
|
||||||
@@ -23,8 +23,12 @@ import { apiModules } from './modules/index.js'
|
|||||||
import { validateModuleConsistency } from './infra/moduleValidator.js'
|
import { validateModuleConsistency } from './infra/moduleValidator.js'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
dotenv.config()
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
|
const __dirname = path.dirname(__filename)
|
||||||
|
|
||||||
|
dotenv.config({ path: path.resolve(__dirname, './.env') })
|
||||||
|
|
||||||
const app: express.Application = express()
|
const app: express.Application = express()
|
||||||
export const container = new ServiceContainer()
|
export const container = new ServiceContainer()
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export const config = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
get tempRoot(): string {
|
get tempRoot(): string {
|
||||||
return path.join(os.tmpdir(), 'xcnote_uploads')
|
return path.join(os.tmpdir(), 'xcdesktop_uploads')
|
||||||
},
|
},
|
||||||
|
|
||||||
get serverPort(): number {
|
get serverPort(): number {
|
||||||
@@ -38,6 +38,18 @@ export const config = {
|
|||||||
get isDev(): boolean {
|
get isDev(): boolean {
|
||||||
return !this.isElectron && !this.isVercel
|
return !this.isElectron && !this.isVercel
|
||||||
},
|
},
|
||||||
|
|
||||||
|
get minimaxApiKey(): string | undefined {
|
||||||
|
return process.env.MINIMAX_API_KEY
|
||||||
|
},
|
||||||
|
|
||||||
|
get minimaxGroupId(): string | undefined {
|
||||||
|
return process.env.MINIMAX_GROUP_ID
|
||||||
|
},
|
||||||
|
|
||||||
|
get openaiApiKey(): string | undefined {
|
||||||
|
return process.env.OPENAI_API_KEY
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PATHS = {
|
export const PATHS = {
|
||||||
|
|||||||
@@ -32,6 +32,110 @@ import { logger } from '../../utils/logger.js'
|
|||||||
|
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/drives',
|
||||||
|
asyncHandler(async (_req: Request, res: Response) => {
|
||||||
|
const drives: FileItemDTO[] = []
|
||||||
|
const letters = 'CDEFGHIJKLMNOPQRSTUVWXYZ'.split('')
|
||||||
|
|
||||||
|
for (const letter of letters) {
|
||||||
|
const drivePath = `${letter}:\\`
|
||||||
|
try {
|
||||||
|
await fs.access(drivePath)
|
||||||
|
drives.push({
|
||||||
|
name: `${letter}:`,
|
||||||
|
type: 'dir',
|
||||||
|
size: 0,
|
||||||
|
modified: new Date().toISOString(),
|
||||||
|
path: drivePath,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// 驱动器不存在,跳过
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(res, { items: drives })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/system',
|
||||||
|
validateQuery(listFilesQuerySchema),
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const systemPath = req.query.path as string
|
||||||
|
if (!systemPath) {
|
||||||
|
throw new BadRequestError('路径不能为空')
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullPath = path.resolve(systemPath)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.access(fullPath)
|
||||||
|
} catch {
|
||||||
|
throw new NotFoundError('路径不存在')
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = await fs.stat(fullPath)
|
||||||
|
if (!stats.isDirectory()) {
|
||||||
|
throw new NotADirectoryError()
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = await fs.readdir(fullPath)
|
||||||
|
const items = await Promise.all(
|
||||||
|
files.map(async (name): Promise<FileItemDTO | null> => {
|
||||||
|
const filePath = path.join(fullPath, name)
|
||||||
|
try {
|
||||||
|
const fileStats = await fs.stat(filePath)
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
type: fileStats.isDirectory() ? 'dir' : 'file',
|
||||||
|
size: fileStats.size,
|
||||||
|
modified: fileStats.mtime.toISOString(),
|
||||||
|
path: filePath,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const visibleItems = items.filter((i): i is FileItemDTO => i !== null && !i.name.startsWith('.') && !i.name.startsWith('$'))
|
||||||
|
visibleItems.sort((a, b) => {
|
||||||
|
if (a.type === b.type) return a.name.localeCompare(b.name)
|
||||||
|
return a.type === 'dir' ? -1 : 1
|
||||||
|
})
|
||||||
|
|
||||||
|
successResponse(res, { items: visibleItems })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/system/content',
|
||||||
|
validateQuery(contentQuerySchema),
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const systemPath = req.query.path as string
|
||||||
|
if (!systemPath) {
|
||||||
|
throw new BadRequestError('路径不能为空')
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullPath = path.resolve(systemPath)
|
||||||
|
const stats = await fs.stat(fullPath).catch(() => {
|
||||||
|
throw new NotFoundError('文件不存在')
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!stats.isFile()) throw new BadRequestError('不是文件')
|
||||||
|
|
||||||
|
const content = await fs.readFile(fullPath, 'utf-8')
|
||||||
|
successResponse(res, {
|
||||||
|
content,
|
||||||
|
metadata: {
|
||||||
|
size: stats.size,
|
||||||
|
modified: stats.mtime.toISOString(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/',
|
'/',
|
||||||
validateQuery(listFilesQuerySchema),
|
validateQuery(listFilesQuerySchema),
|
||||||
@@ -69,7 +173,7 @@ router.get(
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const visibleItems = items.filter((i): i is FileItemDTO => i !== null && !i.name.startsWith('.'))
|
const visibleItems = items.filter((i): i is FileItemDTO => i !== null && !i.name.startsWith('.') && !i.name.startsWith('$'))
|
||||||
visibleItems.sort((a, b) => {
|
visibleItems.sort((a, b) => {
|
||||||
if (a.type === b.type) return a.name.localeCompare(b.name)
|
if (a.type === b.type) return a.name.localeCompare(b.name)
|
||||||
return a.type === 'dir' ? -1 : 1
|
return a.type === 'dir' ? -1 : 1
|
||||||
|
|||||||
@@ -51,4 +51,13 @@ router.post(
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/config',
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
successResponse(res, {
|
||||||
|
notebookRoot: NOTEBOOK_ROOT,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
import express, { type Request, type Response } from 'express'
|
import express, { type Request, type Response } from 'express'
|
||||||
import { spawn } from 'child_process'
|
import { spawn } from 'child_process'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { fileURLToPath } from 'url'
|
|
||||||
import fs from 'fs/promises'
|
import fs from 'fs/promises'
|
||||||
import fsSync from 'fs'
|
import fsSync from 'fs'
|
||||||
import { asyncHandler } from '../../utils/asyncHandler.js'
|
import { asyncHandler } from '../../utils/asyncHandler.js'
|
||||||
import { successResponse } from '../../utils/response.js'
|
import { successResponse } from '../../utils/response.js'
|
||||||
import { resolveNotebookPath } from '../../utils/pathSafety.js'
|
import { resolveNotebookPath } from '../../utils/pathSafety.js'
|
||||||
import { ValidationError, NotFoundError, InternalError } from '../../../shared/errors/index.js'
|
import { ValidationError, NotFoundError, InternalError } from '../../../shared/errors/index.js'
|
||||||
|
import { PROJECT_ROOT } from '../../config/paths.js'
|
||||||
const __filename = fileURLToPath(import.meta.url)
|
|
||||||
const __dirname = path.dirname(__filename)
|
|
||||||
|
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
@@ -89,8 +86,7 @@ router.post(
|
|||||||
|
|
||||||
const content = await fs.readFile(fullPath, 'utf-8')
|
const content = await fs.readFile(fullPath, 'utf-8')
|
||||||
|
|
||||||
const projectRoot = path.resolve(__dirname, '..', '..', '..')
|
const scriptPath = path.join(PROJECT_ROOT, 'tools', 'doubao', 'main.py')
|
||||||
const scriptPath = path.join(projectRoot, 'tools', 'doubao', 'main.py')
|
|
||||||
|
|
||||||
if (!fsSync.existsSync(scriptPath)) {
|
if (!fsSync.existsSync(scriptPath)) {
|
||||||
throw new InternalError(`Python script not found: ${scriptPath}`)
|
throw new InternalError(`Python script not found: ${scriptPath}`)
|
||||||
|
|||||||
@@ -1,51 +1,73 @@
|
|||||||
import { readdirSync, statSync } from 'fs'
|
|
||||||
import { join, dirname } from 'path'
|
|
||||||
import { fileURLToPath } from 'url'
|
|
||||||
import type { ApiModule } from '../infra/types.js'
|
import type { ApiModule } from '../infra/types.js'
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url)
|
|
||||||
const __dirname = dirname(__filename)
|
|
||||||
|
|
||||||
const moduleFactoryPattern = /^create\w+Module$/
|
|
||||||
|
|
||||||
async function discoverModules(): Promise<ApiModule[]> {
|
async function discoverModules(): Promise<ApiModule[]> {
|
||||||
|
return await getStaticModules()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStaticModules(): Promise<ApiModule[]> {
|
||||||
const modules: ApiModule[] = []
|
const modules: ApiModule[] = []
|
||||||
const entries = readdirSync(__dirname)
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
try {
|
||||||
const entryPath = join(__dirname, entry)
|
const { createTodoModule } = await import('./todo/index.js')
|
||||||
|
modules.push(createTodoModule())
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[ModuleLoader] Failed to load todo module:', e)
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stats = statSync(entryPath)
|
const { createTimeTrackingModule } = await import('./time-tracking/index.js')
|
||||||
if (!stats.isDirectory()) {
|
modules.push(createTimeTrackingModule())
|
||||||
continue
|
} catch (e) {
|
||||||
}
|
console.warn('[ModuleLoader] Failed to load time-tracking module:', e)
|
||||||
|
}
|
||||||
|
|
||||||
const moduleIndexPath = join(entryPath, 'index.ts')
|
try {
|
||||||
let moduleIndexStats: ReturnType<typeof statSync>
|
const { createRecycleBinModule } = await import('./recycle-bin/index.js')
|
||||||
try {
|
modules.push(createRecycleBinModule())
|
||||||
moduleIndexStats = statSync(moduleIndexPath)
|
} catch (e) {
|
||||||
} catch {
|
console.warn('[ModuleLoader] Failed to load recycle-bin module:', e)
|
||||||
continue
|
}
|
||||||
}
|
|
||||||
if (!moduleIndexStats.isFile()) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const moduleExports = await import(`./${entry}/index.js`)
|
try {
|
||||||
|
const { createPyDemosModule } = await import('./pydemos/index.js')
|
||||||
|
modules.push(createPyDemosModule())
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[ModuleLoader] Failed to load pydemos module:', e)
|
||||||
|
}
|
||||||
|
|
||||||
for (const exportName of Object.keys(moduleExports)) {
|
try {
|
||||||
if (moduleFactoryPattern.test(exportName)) {
|
const { createDocumentParserModule } = await import('./document-parser/index.js')
|
||||||
const factory = moduleExports[exportName]
|
modules.push(createDocumentParserModule())
|
||||||
if (typeof factory === 'function') {
|
} catch (e) {
|
||||||
const module = factory() as ApiModule
|
console.warn('[ModuleLoader] Failed to load document-parser module:', e)
|
||||||
modules.push(module)
|
}
|
||||||
}
|
|
||||||
}
|
try {
|
||||||
}
|
const { createAiModule } = await import('./ai/index.js')
|
||||||
} catch (error) {
|
modules.push(createAiModule())
|
||||||
console.warn(`[ModuleLoader] Failed to load module '${entry}':`, error)
|
} catch (e) {
|
||||||
}
|
console.warn('[ModuleLoader] Failed to load ai module:', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { createRemoteModule } = await import('./remote/index.js')
|
||||||
|
modules.push(createRemoteModule())
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[ModuleLoader] Failed to load remote module:', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { createOpencodeModule } = await import('./opencode/index.js')
|
||||||
|
modules.push(createOpencodeModule())
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[ModuleLoader] Failed to load opencode module:', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { createVoiceModule } = await import('./voice/index.js')
|
||||||
|
modules.push(createVoiceModule())
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[ModuleLoader] Failed to load voice module:', e)
|
||||||
}
|
}
|
||||||
|
|
||||||
modules.sort((a, b) => {
|
modules.sort((a, b) => {
|
||||||
@@ -66,3 +88,5 @@ export * from './pydemos/index.js'
|
|||||||
export * from './document-parser/index.js'
|
export * from './document-parser/index.js'
|
||||||
export * from './ai/index.js'
|
export * from './ai/index.js'
|
||||||
export * from './remote/index.js'
|
export * from './remote/index.js'
|
||||||
|
export * from './opencode/index.js'
|
||||||
|
export * from './voice/index.js'
|
||||||
|
|||||||
20
api/modules/opencode/index.ts
Normal file
20
api/modules/opencode/index.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import type { Router } from 'express'
|
||||||
|
import type { ServiceContainer } from '../../infra/container.js'
|
||||||
|
import { createApiModule } from '../../infra/createModule.js'
|
||||||
|
import { OPENCODE_MODULE } from '../../../shared/modules/opencode/index.js'
|
||||||
|
import { createOpencodeRoutes } from './routes.js'
|
||||||
|
|
||||||
|
export * from './routes.js'
|
||||||
|
|
||||||
|
export const createOpencodeModule = () => {
|
||||||
|
return createApiModule({
|
||||||
|
...OPENCODE_MODULE,
|
||||||
|
version: OPENCODE_MODULE.version || '1.0.0',
|
||||||
|
}, {
|
||||||
|
routes: (_container: ServiceContainer): Router => {
|
||||||
|
return createOpencodeRoutes()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createOpencodeModule
|
||||||
223
api/modules/opencode/routes.ts
Normal file
223
api/modules/opencode/routes.ts
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import { createOpencodeClient } from '@opencode-ai/sdk'
|
||||||
|
|
||||||
|
const OPENCODE_URL = 'http://localhost:4096'
|
||||||
|
|
||||||
|
async function getClient() {
|
||||||
|
console.log('[OpenCode] Creating client for URL:', OPENCODE_URL)
|
||||||
|
return createOpencodeClient({
|
||||||
|
baseUrl: OPENCODE_URL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createOpencodeRoutes(): Router {
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
const action = req.query.action as string | undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = await getClient()
|
||||||
|
|
||||||
|
if (action === 'list-sessions') {
|
||||||
|
const response = await client.session.list()
|
||||||
|
const sessions = (response.data || []).map((session: { id: string; title?: string; createdAt?: string }) => ({
|
||||||
|
id: session.id,
|
||||||
|
title: session.title || 'New Chat',
|
||||||
|
createdAt: session.createdAt ? new Date(session.createdAt).getTime() : Date.now(),
|
||||||
|
}))
|
||||||
|
res.json({ sessions })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(400).json({ error: 'Unknown action' })
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error('API error:', error)
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
res.status(500).json({ error: errorMessage })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const body = req.body
|
||||||
|
const { action, sessionId, title, text, attachments } = body
|
||||||
|
console.log('[OpenCode] Received action:', action, 'sessionId:', sessionId)
|
||||||
|
|
||||||
|
const client = await getClient()
|
||||||
|
console.log('[OpenCode] Client created successfully')
|
||||||
|
|
||||||
|
if (action === 'create-session') {
|
||||||
|
try {
|
||||||
|
const response = await client.session.create({
|
||||||
|
body: { title: title || 'New Chat' },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.error) {
|
||||||
|
res.status(500).json({ error: 'OpenCode error', details: response.error })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionData = response.data as { id: string; title?: string; createdAt?: string } | undefined
|
||||||
|
if (sessionData) {
|
||||||
|
res.json({
|
||||||
|
session: {
|
||||||
|
id: sessionData.id,
|
||||||
|
title: sessionData.title,
|
||||||
|
createdAt: sessionData.createdAt ? new Date(sessionData.createdAt).getTime() : Date.now(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res.status(500).json({ error: 'Failed to create session - no data' })
|
||||||
|
return
|
||||||
|
} catch (e: unknown) {
|
||||||
|
console.error('Create session exception:', e)
|
||||||
|
const errorMessage = e instanceof Error ? e.message : 'Unknown error'
|
||||||
|
res.status(500).json({ error: errorMessage, exception: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'delete-session') {
|
||||||
|
const response = await client.session.delete({
|
||||||
|
path: { id: sessionId },
|
||||||
|
})
|
||||||
|
res.json({ success: response.data })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'get-session') {
|
||||||
|
const response = await client.session.get({
|
||||||
|
path: { id: sessionId },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.data) {
|
||||||
|
res.json({
|
||||||
|
title: response.data.title || 'New Chat',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res.status(404).json({ error: 'Session not found' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'get-messages') {
|
||||||
|
const limit = body.limit || 20
|
||||||
|
|
||||||
|
const response = await client.session.messages({
|
||||||
|
path: { id: sessionId },
|
||||||
|
query: { limit }
|
||||||
|
})
|
||||||
|
|
||||||
|
const messages = (response.data || []).map((item: { info?: { id?: string; role?: string; content?: string; createdAt?: string }; content?: string; parts?: unknown[]; id?: string; role?: string }) => {
|
||||||
|
let content = item.info?.content || item.content || ''
|
||||||
|
|
||||||
|
const parts = item.parts as Array<{ type: string; text?: string }> | undefined
|
||||||
|
if (parts && parts.length > 0) {
|
||||||
|
const textParts = parts
|
||||||
|
.filter((p) => p.type === 'text')
|
||||||
|
.map((p) => p.text)
|
||||||
|
.join('')
|
||||||
|
if (textParts) {
|
||||||
|
content = textParts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: item.info?.id || item.id,
|
||||||
|
role: item.info?.role || item.role,
|
||||||
|
content: content,
|
||||||
|
parts: item.parts || [],
|
||||||
|
createdAt: item.info?.createdAt,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasMore = messages.length >= limit
|
||||||
|
|
||||||
|
res.json({ messages, hasMore })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'prompt') {
|
||||||
|
const parts: Array<{ type: 'text'; text: string } | { type: 'file'; url: string; name: string; mime: string }> = []
|
||||||
|
|
||||||
|
if (attachments && attachments.length > 0) {
|
||||||
|
for (const att of attachments as Array<{ url: string; name: string; mediaType: string }>) {
|
||||||
|
parts.push({
|
||||||
|
type: 'file',
|
||||||
|
url: att.url,
|
||||||
|
name: att.name,
|
||||||
|
mime: att.mediaType,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push({ type: 'text', text })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await client.session.prompt({
|
||||||
|
path: { id: sessionId },
|
||||||
|
body: { parts },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.data) {
|
||||||
|
res.json({
|
||||||
|
message: {
|
||||||
|
id: (response.data.info as { id?: string })?.id || crypto.randomUUID(),
|
||||||
|
role: (response.data.info as { role?: string })?.role || 'assistant',
|
||||||
|
content: '',
|
||||||
|
parts: response.data.parts || [],
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(500).json({ error: 'Failed to get response', details: response.error })
|
||||||
|
return
|
||||||
|
} catch (e: unknown) {
|
||||||
|
console.error('Prompt exception:', e)
|
||||||
|
const errorMessage = e instanceof Error ? e.message : 'Unknown error'
|
||||||
|
res.status(500).json({ error: errorMessage })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'summarize-session') {
|
||||||
|
try {
|
||||||
|
await client.session.summarize({
|
||||||
|
path: { id: sessionId },
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await client.session.get({
|
||||||
|
path: { id: sessionId },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.data) {
|
||||||
|
res.json({
|
||||||
|
title: response.data.title || 'New Chat',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(500).json({ error: 'Failed to get session' })
|
||||||
|
return
|
||||||
|
} catch (e: unknown) {
|
||||||
|
console.error('Summarize error:', e)
|
||||||
|
const errorMessage = e instanceof Error ? e.message : 'Unknown error'
|
||||||
|
res.status(500).json({ error: errorMessage })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(400).json({ error: 'Unknown action' })
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error('API error:', error)
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
res.status(500).json({ error: errorMessage })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return router
|
||||||
|
}
|
||||||
@@ -73,6 +73,9 @@ export class RemoteService {
|
|||||||
serverHost: deviceConfig.serverHost || '',
|
serverHost: deviceConfig.serverHost || '',
|
||||||
desktopPort: deviceConfig.desktopPort || 3000,
|
desktopPort: deviceConfig.desktopPort || 3000,
|
||||||
gitPort: deviceConfig.gitPort || 3001,
|
gitPort: deviceConfig.gitPort || 3001,
|
||||||
|
openCodePort: deviceConfig.openCodePort || 3002,
|
||||||
|
fileTransferPort: deviceConfig.fileTransferPort || 3003,
|
||||||
|
password: deviceConfig.password || '',
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return {
|
return {
|
||||||
@@ -81,6 +84,9 @@ export class RemoteService {
|
|||||||
serverHost: '',
|
serverHost: '',
|
||||||
desktopPort: 3000,
|
desktopPort: 3000,
|
||||||
gitPort: 3001,
|
gitPort: 3001,
|
||||||
|
openCodePort: 3002,
|
||||||
|
fileTransferPort: 3003,
|
||||||
|
password: '',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -114,6 +120,9 @@ export class RemoteService {
|
|||||||
serverHost: device.serverHost,
|
serverHost: device.serverHost,
|
||||||
desktopPort: device.desktopPort,
|
desktopPort: device.desktopPort,
|
||||||
gitPort: device.gitPort,
|
gitPort: device.gitPort,
|
||||||
|
openCodePort: device.openCodePort,
|
||||||
|
fileTransferPort: device.fileTransferPort,
|
||||||
|
password: device.password || '',
|
||||||
}
|
}
|
||||||
await fs.writeFile(deviceConfigPath, JSON.stringify(deviceConfig, null, 2), 'utf-8')
|
await fs.writeFile(deviceConfigPath, JSON.stringify(deviceConfig, null, 2), 'utf-8')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,7 +182,9 @@ class SessionPersistenceService implements SessionPersistence {
|
|||||||
const filePath = getMonthFilePath(year, month)
|
const filePath = getMonthFilePath(year, month)
|
||||||
try {
|
try {
|
||||||
const content = await fs.readFile(filePath, 'utf-8')
|
const content = await fs.readFile(filePath, 'utf-8')
|
||||||
return JSON.parse(content)
|
const data = JSON.parse(content)
|
||||||
|
data.activeDays = Object.values(data.days).filter((d: any) => d.totalDuration > 0).length
|
||||||
|
return data
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return createEmptyMonthData(year, month)
|
return createEmptyMonthData(year, month)
|
||||||
}
|
}
|
||||||
@@ -192,7 +194,9 @@ class SessionPersistenceService implements SessionPersistence {
|
|||||||
const filePath = getYearFilePath(year)
|
const filePath = getYearFilePath(year)
|
||||||
try {
|
try {
|
||||||
const content = await fs.readFile(filePath, 'utf-8')
|
const content = await fs.readFile(filePath, 'utf-8')
|
||||||
return JSON.parse(content)
|
const data = JSON.parse(content)
|
||||||
|
data.totalActiveDays = Object.values(data.months).filter((m: any) => m.totalDuration > 0).length
|
||||||
|
return data
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return createEmptyYearData(year)
|
return createEmptyYearData(year)
|
||||||
}
|
}
|
||||||
@@ -226,7 +230,7 @@ class SessionPersistenceService implements SessionPersistence {
|
|||||||
if (existingSessionIndex >= 0) {
|
if (existingSessionIndex >= 0) {
|
||||||
const oldDuration = dayData.sessions[existingSessionIndex].duration
|
const oldDuration = dayData.sessions[existingSessionIndex].duration
|
||||||
dayData.sessions[existingSessionIndex] = realtimeSession
|
dayData.sessions[existingSessionIndex] = realtimeSession
|
||||||
dayData.totalDuration += currentSessionDuration - oldDuration
|
dayData.totalDuration = dayData.totalDuration - oldDuration + currentSessionDuration
|
||||||
} else {
|
} else {
|
||||||
dayData.sessions.push(realtimeSession)
|
dayData.sessions.push(realtimeSession)
|
||||||
dayData.totalDuration += currentSessionDuration
|
dayData.totalDuration += currentSessionDuration
|
||||||
@@ -269,8 +273,10 @@ class SessionPersistenceService implements SessionPersistence {
|
|||||||
monthData.days[dayStr].totalDuration += duration
|
monthData.days[dayStr].totalDuration += duration
|
||||||
monthData.days[dayStr].sessions += 1
|
monthData.days[dayStr].sessions += 1
|
||||||
monthData.monthlyTotal += duration
|
monthData.monthlyTotal += duration
|
||||||
monthData.activeDays = Object.keys(monthData.days).length
|
monthData.activeDays = Object.values(monthData.days).filter(d => d.totalDuration > 0).length
|
||||||
monthData.averageDaily = Math.floor(monthData.monthlyTotal / monthData.activeDays)
|
monthData.averageDaily = monthData.activeDays > 0
|
||||||
|
? Math.floor(monthData.monthlyTotal / monthData.activeDays)
|
||||||
|
: 0
|
||||||
monthData.lastUpdated = new Date().toISOString()
|
monthData.lastUpdated = new Date().toISOString()
|
||||||
|
|
||||||
await fs.writeFile(filePath, JSON.stringify(monthData, null, 2), 'utf-8')
|
await fs.writeFile(filePath, JSON.stringify(monthData, null, 2), 'utf-8')
|
||||||
@@ -289,10 +295,15 @@ class SessionPersistenceService implements SessionPersistence {
|
|||||||
|
|
||||||
yearData.months[monthStr].totalDuration += duration
|
yearData.months[monthStr].totalDuration += duration
|
||||||
yearData.yearlyTotal += duration
|
yearData.yearlyTotal += duration
|
||||||
yearData.totalActiveDays = Object.values(yearData.months).reduce((sum, m) => sum + m.activeDays, 0)
|
yearData.totalActiveDays = Object.values(yearData.months).reduce((sum, m) => {
|
||||||
|
const hasActiveDays = m.totalDuration > 0 ? 1 : 0
|
||||||
|
return sum + hasActiveDays
|
||||||
|
}, 0)
|
||||||
|
|
||||||
const monthCount = Object.keys(yearData.months).length
|
const activeMonthCount = Object.values(yearData.months).filter(m => m.totalDuration > 0).length
|
||||||
yearData.averageMonthly = Math.floor(yearData.yearlyTotal / monthCount)
|
yearData.averageMonthly = activeMonthCount > 0
|
||||||
|
? Math.floor(yearData.yearlyTotal / activeMonthCount)
|
||||||
|
: 0
|
||||||
yearData.averageDaily = yearData.totalActiveDays > 0
|
yearData.averageDaily = yearData.totalActiveDays > 0
|
||||||
? Math.floor(yearData.yearlyTotal / yearData.totalActiveDays)
|
? Math.floor(yearData.yearlyTotal / yearData.totalActiveDays)
|
||||||
: 0
|
: 0
|
||||||
@@ -315,7 +326,7 @@ class SessionPersistenceService implements SessionPersistence {
|
|||||||
const oldDayDuration = monthData.days[dayStr].totalDuration
|
const oldDayDuration = monthData.days[dayStr].totalDuration
|
||||||
monthData.days[dayStr].totalDuration = todayDuration
|
monthData.days[dayStr].totalDuration = todayDuration
|
||||||
monthData.monthlyTotal = monthData.monthlyTotal - oldDayDuration + todayDuration
|
monthData.monthlyTotal = monthData.monthlyTotal - oldDayDuration + todayDuration
|
||||||
monthData.activeDays = Object.keys(monthData.days).length
|
monthData.activeDays = Object.values(monthData.days).filter(d => d.totalDuration > 0).length
|
||||||
monthData.averageDaily = monthData.activeDays > 0
|
monthData.averageDaily = monthData.activeDays > 0
|
||||||
? Math.floor(monthData.monthlyTotal / monthData.activeDays)
|
? Math.floor(monthData.monthlyTotal / monthData.activeDays)
|
||||||
: 0
|
: 0
|
||||||
@@ -345,10 +356,15 @@ class SessionPersistenceService implements SessionPersistence {
|
|||||||
yearData.months[monthStr].totalDuration = monthData.monthlyTotal
|
yearData.months[monthStr].totalDuration = monthData.monthlyTotal
|
||||||
yearData.months[monthStr].activeDays = monthData.activeDays
|
yearData.months[monthStr].activeDays = monthData.activeDays
|
||||||
yearData.yearlyTotal = yearData.yearlyTotal - oldMonthTotal + monthData.monthlyTotal
|
yearData.yearlyTotal = yearData.yearlyTotal - oldMonthTotal + monthData.monthlyTotal
|
||||||
yearData.totalActiveDays = Object.values(yearData.months).reduce((sum, m) => sum + m.activeDays, 0)
|
yearData.totalActiveDays = Object.values(yearData.months).reduce((sum, m) => {
|
||||||
|
const hasActiveDays = m.totalDuration > 0 ? 1 : 0
|
||||||
|
return sum + hasActiveDays
|
||||||
|
}, 0)
|
||||||
|
|
||||||
const monthCount = Object.keys(yearData.months).length
|
const activeMonthCount = Object.values(yearData.months).filter(m => m.totalDuration > 0).length
|
||||||
yearData.averageMonthly = monthCount > 0 ? Math.floor(yearData.yearlyTotal / monthCount) : 0
|
yearData.averageMonthly = activeMonthCount > 0
|
||||||
|
? Math.floor(yearData.yearlyTotal / activeMonthCount)
|
||||||
|
: 0
|
||||||
yearData.averageDaily = yearData.totalActiveDays > 0
|
yearData.averageDaily = yearData.totalActiveDays > 0
|
||||||
? Math.floor(yearData.yearlyTotal / yearData.totalActiveDays)
|
? Math.floor(yearData.yearlyTotal / yearData.totalActiveDays)
|
||||||
: 0
|
: 0
|
||||||
|
|||||||
@@ -366,23 +366,47 @@ class TimeTrackerService {
|
|||||||
if (targetMonth) {
|
if (targetMonth) {
|
||||||
const monthData = await this.persistence.getMonthData(targetYear, targetMonth)
|
const monthData = await this.persistence.getMonthData(targetYear, targetMonth)
|
||||||
totalDuration = monthData.monthlyTotal
|
totalDuration = monthData.monthlyTotal
|
||||||
activeDays = monthData.activeDays
|
activeDays = Object.values(monthData.days).filter(d => d.totalDuration > 0).length
|
||||||
|
|
||||||
for (const [day, summary] of Object.entries(monthData.days)) {
|
for (const [day, summary] of Object.entries(monthData.days)) {
|
||||||
if (!longestDay || summary.totalDuration > longestDay.duration) {
|
if (!longestDay || summary.totalDuration > longestDay.duration) {
|
||||||
longestDay = { date: `${targetYear}-${targetMonth.toString().padStart(2, '0')}-${day}`, duration: summary.totalDuration }
|
longestDay = { date: `${targetYear}-${targetMonth.toString().padStart(2, '0')}-${day}`, duration: summary.totalDuration }
|
||||||
}
|
}
|
||||||
|
for (const tab of summary.topTabs || []) {
|
||||||
|
tabDurations[tab.fileName] = (tabDurations[tab.fileName] || 0) + tab.duration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dayData = await this.persistence.getDayData(targetYear, targetMonth, 1)
|
||||||
|
for (const session of dayData.sessions) {
|
||||||
|
for (const record of session.tabRecords) {
|
||||||
|
const key = record.filePath || record.fileName
|
||||||
|
tabDurations[key] = (tabDurations[key] || 0) + record.duration
|
||||||
|
tabTypeDurations[record.tabType] = (tabTypeDurations[record.tabType] || 0) + record.duration
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const yearData = await this.persistence.getYearData(targetYear)
|
const yearData = await this.persistence.getYearData(targetYear)
|
||||||
totalDuration = yearData.yearlyTotal
|
totalDuration = yearData.yearlyTotal
|
||||||
activeDays = yearData.totalActiveDays
|
activeDays = Object.values(yearData.months).reduce((sum, m) => {
|
||||||
|
return sum + Object.entries(m).filter(([_, d]) => (d as any).totalDuration > 0).length
|
||||||
|
}, 0)
|
||||||
|
|
||||||
for (const [month, summary] of Object.entries(yearData.months)) {
|
for (const [month, summary] of Object.entries(yearData.months)) {
|
||||||
if (!longestDay || summary.totalDuration > longestDay.duration) {
|
if (!longestDay || summary.totalDuration > longestDay.duration) {
|
||||||
longestDay = { date: `${targetYear}-${month}`, duration: summary.totalDuration }
|
longestDay = { date: `${targetYear}-${month}`, duration: summary.totalDuration }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (let m = 1; m <= 12; m++) {
|
||||||
|
const monthStr = m.toString().padStart(2, '0')
|
||||||
|
const monthData = await this.persistence.getMonthData(targetYear, m)
|
||||||
|
for (const dayData of Object.values(monthData.days)) {
|
||||||
|
for (const tab of dayData.topTabs || []) {
|
||||||
|
tabDurations[tab.fileName] = (tabDurations[tab.fileName] || 0) + tab.duration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
25
api/modules/voice/index.ts
Normal file
25
api/modules/voice/index.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import type { Router } from 'express'
|
||||||
|
import type { ServiceContainer } from '../../infra/container.js'
|
||||||
|
import { createApiModule } from '../../infra/createModule.js'
|
||||||
|
import { voiceModule, VOICE_MODULE } from '../../../shared/modules/voice/index.js'
|
||||||
|
import { createVoiceRoutes } from './routes.js'
|
||||||
|
|
||||||
|
export * from './routes.js'
|
||||||
|
|
||||||
|
export const createVoiceModule = () => {
|
||||||
|
return createApiModule(
|
||||||
|
{
|
||||||
|
...voiceModule,
|
||||||
|
basePath: '/voice',
|
||||||
|
version: '1.0.0',
|
||||||
|
order: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
routes: (_container: ServiceContainer): Router => {
|
||||||
|
return createVoiceRoutes()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createVoiceModule
|
||||||
117
api/modules/voice/routes.ts
Normal file
117
api/modules/voice/routes.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import { config } from '../../config/index.js'
|
||||||
|
|
||||||
|
interface AIMLAPISTTCreateResponse {
|
||||||
|
generation_id?: string
|
||||||
|
status?: string
|
||||||
|
error?: {
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AIMLAPISTTQueryResponse {
|
||||||
|
status?: string
|
||||||
|
result?: {
|
||||||
|
results?: {
|
||||||
|
channels?: Array<{
|
||||||
|
alternatives?: Array<{
|
||||||
|
transcript?: string
|
||||||
|
}>
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
error?: {
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createVoiceRoutes(): Router {
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
router.post('/stt', async (req, res) => {
|
||||||
|
console.log('[Voice] Request received, body keys:', Object.keys(req.body || {}))
|
||||||
|
try {
|
||||||
|
const apiKey = config.minimaxApiKey
|
||||||
|
|
||||||
|
console.log('[Voice] API Key exists:', !!apiKey)
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
res.status(500).json({ error: 'MiniMax API key not configured' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.body.audio) {
|
||||||
|
res.status(400).json({ error: 'No audio data provided' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const audioBuffer = Buffer.from(req.body.audio, 'base64')
|
||||||
|
console.log('[Voice] Audio buffer size:', audioBuffer.length)
|
||||||
|
|
||||||
|
const formData = new FormData()
|
||||||
|
const blob = new Blob([audioBuffer], { type: 'audio/webm' })
|
||||||
|
formData.append('file', blob, 'audio.webm')
|
||||||
|
formData.append('model', '#g1_whisper-large')
|
||||||
|
|
||||||
|
console.log('[Voice] Creating STT job via MiniMax...')
|
||||||
|
|
||||||
|
const createResponse = await fetch('https://api.minimax.chat/v1/stt/create', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${apiKey}`,
|
||||||
|
},
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('[Voice] Create response status:', createResponse.status)
|
||||||
|
const createData: AIMLAPISTTCreateResponse = await createResponse.json()
|
||||||
|
console.log('[Voice] Create response:', createData)
|
||||||
|
|
||||||
|
if (!createResponse.ok || !createData.generation_id) {
|
||||||
|
console.error('[Voice] Failed to create STT job:', createData.error?.message)
|
||||||
|
res.status(500).json({ error: createData.error?.message || 'Failed to create STT job' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobId = createData.generation_id
|
||||||
|
console.log('[Voice] Job ID:', jobId)
|
||||||
|
|
||||||
|
console.log('[Voice] Polling for result...')
|
||||||
|
let resultText = ''
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||||
|
|
||||||
|
const queryResponse = await fetch(`https://api.minimax.chat/v1/stt/${jobId}`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${apiKey}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const queryData: AIMLAPISTTQueryResponse = await queryResponse.json()
|
||||||
|
console.log('[Voice] Query response:', queryData)
|
||||||
|
|
||||||
|
if (queryData.status === 'succeeded') {
|
||||||
|
resultText = queryData.result?.results?.channels?.[0]?.alternatives?.[0]?.transcript || ''
|
||||||
|
break
|
||||||
|
} else if (queryData.status === 'failed') {
|
||||||
|
console.error('[Voice] STT job failed:', queryData.error?.message)
|
||||||
|
res.status(500).json({ error: queryData.error?.message || 'STT processing failed' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resultText) {
|
||||||
|
res.status(500).json({ error: 'STT processing timeout' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Voice] Final result:', resultText)
|
||||||
|
res.json({ text: resultText })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Voice] STT error:', error)
|
||||||
|
res.status(500).json({ error: 'Failed to process audio' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return router
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import chokidar, { FSWatcher } from 'chokidar';
|
import chokidar, { FSWatcher } from 'chokidar';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { NOTEBOOK_ROOT } from '../config/paths.js';
|
import { config } from '../config/index.js';
|
||||||
import { eventBus } from '../events/eventBus.js';
|
import { eventBus } from '../events/eventBus.js';
|
||||||
import { logger } from '../utils/logger.js';
|
import { logger } from '../utils/logger.js';
|
||||||
import { toPosixPath } from '../../shared/utils/path.js';
|
import { toPosixPath } from '../../shared/utils/path.js';
|
||||||
@@ -10,16 +10,17 @@ let watcher: FSWatcher | null = null;
|
|||||||
export const startWatcher = (): void => {
|
export const startWatcher = (): void => {
|
||||||
if (watcher) return;
|
if (watcher) return;
|
||||||
|
|
||||||
logger.info(`Starting file watcher for: ${NOTEBOOK_ROOT}`);
|
const notebookRoot = config.notebookRoot;
|
||||||
|
logger.info(`Starting file watcher for: ${notebookRoot}`);
|
||||||
|
|
||||||
watcher = chokidar.watch(NOTEBOOK_ROOT, {
|
watcher = chokidar.watch(notebookRoot, {
|
||||||
ignored: /(^|[\/\\])\../,
|
ignored: /(^|[\/\\])\../,
|
||||||
persistent: true,
|
persistent: true,
|
||||||
ignoreInitial: true,
|
ignoreInitial: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const broadcast = (event: string, changedPath: string) => {
|
const broadcast = (event: string, changedPath: string) => {
|
||||||
const rel = path.relative(NOTEBOOK_ROOT, changedPath);
|
const rel = path.relative(notebookRoot, changedPath);
|
||||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return;
|
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return;
|
||||||
logger.info(`File event: ${event} - ${rel}`);
|
logger.info(`File event: ${event} - ${rel}`);
|
||||||
eventBus.broadcast({ event, path: toPosixPath(rel) });
|
eventBus.broadcast({ event, path: toPosixPath(rel) });
|
||||||
|
|||||||
6
console.txt
Normal file
6
console.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
Total messages: 5 (Errors: 3, Warnings: 0)
|
||||||
|
Returning 3 messages for level "error"
|
||||||
|
|
||||||
|
[ERROR] Manifest: Line: 1, column: 1, Syntax error. @ http://localhost:3000/site.webmanifest:0
|
||||||
|
[ERROR] Manifest: Line: 1, column: 1, Syntax error. @ http://localhost:3000/site.webmanifest:0
|
||||||
|
The requested module '/node_modules/lru_map/dist/lru.js?v=e2db4b71' does not provide an export named 'default'
|
||||||
156
counter.py
Normal file
156
counter.py
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
if sys.platform == "win32":
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
sys.stderr.reconfigure(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def count_lines(file_path: Path) -> int:
|
||||||
|
try:
|
||||||
|
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
|
||||||
|
return sum(1 for _ in f)
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def should_include(file_path: Path, extensions: list[str]) -> bool:
|
||||||
|
if not extensions:
|
||||||
|
return True
|
||||||
|
return file_path.suffix in extensions
|
||||||
|
|
||||||
|
|
||||||
|
def walk_dir(root_path: Path, extensions: list[str], exclude_dirs: set[str]):
|
||||||
|
results = []
|
||||||
|
total_lines = 0
|
||||||
|
|
||||||
|
for root, dirs, files in os.walk(root_path):
|
||||||
|
dirs[:] = [d for d in dirs if d not in exclude_dirs]
|
||||||
|
|
||||||
|
rel_root = Path(root).relative_to(root_path)
|
||||||
|
indent = len(rel_root.parts) if str(rel_root) != "." else 0
|
||||||
|
prefix = " " * indent
|
||||||
|
|
||||||
|
if indent > 0:
|
||||||
|
print(f"{prefix}└── {rel_root.name}/")
|
||||||
|
|
||||||
|
for i, file in enumerate(files):
|
||||||
|
file_path = Path(root) / file
|
||||||
|
|
||||||
|
if not should_include(file_path, extensions):
|
||||||
|
continue
|
||||||
|
|
||||||
|
lines = count_lines(file_path)
|
||||||
|
total_lines += lines
|
||||||
|
results.append((file_path, lines, indent + 1))
|
||||||
|
|
||||||
|
is_last = (i == len(files) - 1) and not any(
|
||||||
|
should_include(Path(root) / f, extensions) for f in dirs
|
||||||
|
)
|
||||||
|
connector = "└──" if is_last else "├──"
|
||||||
|
|
||||||
|
print(f"{' ' * (indent + 1)}{connector} {file} ({lines} lines)")
|
||||||
|
|
||||||
|
return total_lines
|
||||||
|
|
||||||
|
|
||||||
|
def main_all():
|
||||||
|
directories = {
|
||||||
|
"src": [".ts", ".tsx", ".vue", ".js"],
|
||||||
|
"api": [".ts", ".py"],
|
||||||
|
"electron": [".ts"],
|
||||||
|
"remote": [".js"],
|
||||||
|
"shared": [".ts"],
|
||||||
|
"tools": [".py"],
|
||||||
|
}
|
||||||
|
|
||||||
|
total_all = 0
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for directory, extensions in directories.items():
|
||||||
|
root_path = Path(directory)
|
||||||
|
if not root_path.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
exclude_dirs = {
|
||||||
|
".git",
|
||||||
|
"node_modules",
|
||||||
|
"dist",
|
||||||
|
"dist-api",
|
||||||
|
"dist-electron",
|
||||||
|
"__pycache__",
|
||||||
|
".ruff_cache",
|
||||||
|
}
|
||||||
|
|
||||||
|
print(f"\n{'=' * 60}")
|
||||||
|
print(f"项目文件统计: {root_path}")
|
||||||
|
print(f"后缀过滤: {extensions}")
|
||||||
|
print(f"{'=' * 60}\n")
|
||||||
|
|
||||||
|
total = walk_dir(root_path, extensions, exclude_dirs)
|
||||||
|
results.append((directory, total))
|
||||||
|
total_all += total
|
||||||
|
|
||||||
|
print(f"\n{'=' * 60}")
|
||||||
|
print("汇总统计")
|
||||||
|
print(f"{'=' * 60}")
|
||||||
|
for name, lines in results:
|
||||||
|
print(f"{name:15} {lines:>10,} 行")
|
||||||
|
print(f"{'=' * 60}")
|
||||||
|
print(f"{'总计':15} {total_all:>10,} 行")
|
||||||
|
print(f"{'=' * 60}\n")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description="统计项目文件行数")
|
||||||
|
parser.add_argument(
|
||||||
|
"-e", "--extension", action="append", help="指定后缀名,如: .py .ts .js"
|
||||||
|
)
|
||||||
|
parser.add_argument("-d", "--directory", default=".", help="指定子文件夹路径")
|
||||||
|
parser.add_argument(
|
||||||
|
"-x", "--exclude", action="append", default=[], help="排除的文件夹"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--all",
|
||||||
|
action="store_true",
|
||||||
|
help="统计所有源码目录 (src/api/electron/remote/shared/tools)",
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.all:
|
||||||
|
main_all()
|
||||||
|
else:
|
||||||
|
root_path = Path(args.directory).resolve()
|
||||||
|
extensions = args.extension
|
||||||
|
exclude_dirs = {
|
||||||
|
".git",
|
||||||
|
"node_modules",
|
||||||
|
"dist",
|
||||||
|
"dist-api",
|
||||||
|
"dist-electron",
|
||||||
|
"__pycache__",
|
||||||
|
".ruff_cache",
|
||||||
|
}
|
||||||
|
exclude_dirs.update(args.exclude)
|
||||||
|
|
||||||
|
if not root_path.exists():
|
||||||
|
print(f"错误: 目录 {root_path} 不存在")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"\n{'=' * 60}")
|
||||||
|
print(f"项目文件统计: {root_path}")
|
||||||
|
if extensions:
|
||||||
|
print(f"后缀过滤: {extensions}")
|
||||||
|
print(f"{'=' * 60}\n")
|
||||||
|
|
||||||
|
total = walk_dir(root_path, extensions, exclude_dirs)
|
||||||
|
|
||||||
|
print(f"\n{'=' * 60}")
|
||||||
|
print(f"总行数: {total}")
|
||||||
|
print(f"{'=' * 60}\n")
|
||||||
3026
dist-api/server.js
3026
dist-api/server.js
File diff suppressed because it is too large
Load Diff
@@ -1,316 +0,0 @@
|
|||||||
var __defProp = Object.defineProperty;
|
|
||||||
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
|
|
||||||
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
|
|
||||||
|
|
||||||
// electron/main.ts
|
|
||||||
import { app, BrowserWindow as BrowserWindow3, shell, ipcMain, dialog as dialog2, nativeTheme, globalShortcut, clipboard } from "electron";
|
|
||||||
import path2 from "path";
|
|
||||||
import { fileURLToPath, pathToFileURL } from "url";
|
|
||||||
import fs2 from "fs";
|
|
||||||
import log2 from "electron-log";
|
|
||||||
|
|
||||||
// electron/services/pdfGenerator.ts
|
|
||||||
import { BrowserWindow } from "electron";
|
|
||||||
async function generatePdf(htmlContent) {
|
|
||||||
const printWin = new BrowserWindow({
|
|
||||||
show: false,
|
|
||||||
webPreferences: {
|
|
||||||
nodeIntegration: false,
|
|
||||||
contextIsolation: true,
|
|
||||||
sandbox: false
|
|
||||||
// 与 main.ts 保持一致,确保脚本执行权限
|
|
||||||
}
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
await printWin.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(htmlContent)}`);
|
|
||||||
await printWin.webContents.executeJavaScript(`
|
|
||||||
new Promise(resolve => {
|
|
||||||
const check = () => {
|
|
||||||
if (window.__PRINT_READY__) {
|
|
||||||
resolve();
|
|
||||||
} else {
|
|
||||||
setTimeout(check, 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
check();
|
|
||||||
})
|
|
||||||
`);
|
|
||||||
const pdfData = await printWin.webContents.printToPDF({
|
|
||||||
printBackground: true,
|
|
||||||
pageSize: "A4",
|
|
||||||
margins: { top: 0, bottom: 0, left: 0, right: 0 }
|
|
||||||
});
|
|
||||||
return pdfData;
|
|
||||||
} finally {
|
|
||||||
printWin.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// electron/services/htmlImport.ts
|
|
||||||
import { dialog } from "electron";
|
|
||||||
import path from "path";
|
|
||||||
import fs from "fs";
|
|
||||||
import log from "electron-log";
|
|
||||||
var selectHtmlFile = async (win) => {
|
|
||||||
if (!win) return { success: false, error: "No window found" };
|
|
||||||
try {
|
|
||||||
const { filePaths, canceled } = await dialog.showOpenDialog(win, {
|
|
||||||
title: "\u9009\u62E9 HTML \u6587\u4EF6",
|
|
||||||
filters: [
|
|
||||||
{ name: "HTML Files", extensions: ["html", "htm"] }
|
|
||||||
],
|
|
||||||
properties: ["openFile"]
|
|
||||||
});
|
|
||||||
if (canceled || filePaths.length === 0) {
|
|
||||||
return { success: false, canceled: true };
|
|
||||||
}
|
|
||||||
const htmlPath = filePaths[0];
|
|
||||||
const htmlDir = path.dirname(htmlPath);
|
|
||||||
const htmlFileName = path.basename(htmlPath, path.extname(htmlPath));
|
|
||||||
const assetsDirName = `${htmlFileName}_files`;
|
|
||||||
const assetsDirPath = path.join(htmlDir, assetsDirName);
|
|
||||||
const assetsFiles = [];
|
|
||||||
if (fs.existsSync(assetsDirPath)) {
|
|
||||||
const collectFiles = (dir, baseDir) => {
|
|
||||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
||||||
for (const entry of entries) {
|
|
||||||
const fullPath = path.join(dir, entry.name);
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
collectFiles(fullPath, baseDir);
|
|
||||||
} else {
|
|
||||||
const relPath = path.relative(baseDir, fullPath);
|
|
||||||
assetsFiles.push(relPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
collectFiles(assetsDirPath, assetsDirPath);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
htmlPath,
|
|
||||||
htmlDir,
|
|
||||||
htmlFileName,
|
|
||||||
assetsDirName,
|
|
||||||
assetsFiles
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
log.error("Select HTML file failed:", error);
|
|
||||||
return { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// electron/state.ts
|
|
||||||
var ElectronState = class {
|
|
||||||
constructor() {
|
|
||||||
__publicField(this, "state", {
|
|
||||||
mainWindow: null,
|
|
||||||
serverPort: 3001,
|
|
||||||
isDev: false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
getMainWindow() {
|
|
||||||
return this.state.mainWindow;
|
|
||||||
}
|
|
||||||
setMainWindow(window) {
|
|
||||||
this.state.mainWindow = window;
|
|
||||||
}
|
|
||||||
getServerPort() {
|
|
||||||
return this.state.serverPort;
|
|
||||||
}
|
|
||||||
setServerPort(port) {
|
|
||||||
this.state.serverPort = port;
|
|
||||||
}
|
|
||||||
isDevelopment() {
|
|
||||||
return this.state.isDev;
|
|
||||||
}
|
|
||||||
setDevelopment(isDev) {
|
|
||||||
this.state.isDev = isDev;
|
|
||||||
}
|
|
||||||
reset() {
|
|
||||||
this.state = {
|
|
||||||
mainWindow: null,
|
|
||||||
serverPort: 3001,
|
|
||||||
isDev: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
var electronState = new ElectronState();
|
|
||||||
|
|
||||||
// electron/main.ts
|
|
||||||
log2.initialize();
|
|
||||||
var __filename = fileURLToPath(import.meta.url);
|
|
||||||
var __dirname = path2.dirname(__filename);
|
|
||||||
process.env.NOTEBOOK_ROOT = path2.join(app.getPath("documents"), "XCNote");
|
|
||||||
if (!fs2.existsSync(process.env.NOTEBOOK_ROOT)) {
|
|
||||||
try {
|
|
||||||
fs2.mkdirSync(process.env.NOTEBOOK_ROOT, { recursive: true });
|
|
||||||
} catch (err) {
|
|
||||||
log2.error("Failed to create notebook directory:", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
electronState.setDevelopment(!app.isPackaged);
|
|
||||||
var lastClipboardText = "";
|
|
||||||
function startClipboardWatcher() {
|
|
||||||
lastClipboardText = clipboard.readText();
|
|
||||||
setInterval(() => {
|
|
||||||
try {
|
|
||||||
const currentText = clipboard.readText();
|
|
||||||
if (currentText && currentText !== lastClipboardText) {
|
|
||||||
lastClipboardText = currentText;
|
|
||||||
log2.info("Clipboard changed, syncing to remote");
|
|
||||||
const win = electronState.getMainWindow();
|
|
||||||
if (win) {
|
|
||||||
win.webContents.send("remote-clipboard-auto-sync", currentText);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
}
|
|
||||||
}, 1e3);
|
|
||||||
}
|
|
||||||
async function createWindow() {
|
|
||||||
const initialSymbolColor = nativeTheme.shouldUseDarkColors ? "#ffffff" : "#000000";
|
|
||||||
const mainWindow = new BrowserWindow3({
|
|
||||||
width: 1280,
|
|
||||||
height: 800,
|
|
||||||
minWidth: 1600,
|
|
||||||
minHeight: 900,
|
|
||||||
autoHideMenuBar: true,
|
|
||||||
titleBarStyle: "hidden",
|
|
||||||
titleBarOverlay: {
|
|
||||||
color: "#00000000",
|
|
||||||
symbolColor: initialSymbolColor,
|
|
||||||
height: 32
|
|
||||||
},
|
|
||||||
webPreferences: {
|
|
||||||
nodeIntegration: false,
|
|
||||||
contextIsolation: true,
|
|
||||||
sandbox: false,
|
|
||||||
webviewTag: true,
|
|
||||||
preload: path2.join(__dirname, "preload.cjs")
|
|
||||||
}
|
|
||||||
});
|
|
||||||
electronState.setMainWindow(mainWindow);
|
|
||||||
mainWindow.setMenu(null);
|
|
||||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
|
||||||
if (url.startsWith("http:") || url.startsWith("https:")) {
|
|
||||||
shell.openExternal(url);
|
|
||||||
return { action: "deny" };
|
|
||||||
}
|
|
||||||
return { action: "allow" };
|
|
||||||
});
|
|
||||||
if (electronState.isDevelopment()) {
|
|
||||||
log2.info("Loading development URL...");
|
|
||||||
try {
|
|
||||||
await mainWindow.loadURL("http://localhost:5173");
|
|
||||||
} catch (e) {
|
|
||||||
log2.error("Failed to load dev URL. Make sure npm run electron:dev is used.", e);
|
|
||||||
}
|
|
||||||
mainWindow.webContents.openDevTools();
|
|
||||||
} else {
|
|
||||||
log2.info(`Loading production URL with port ${electronState.getServerPort()}...`);
|
|
||||||
await mainWindow.loadURL(`http://localhost:${electronState.getServerPort()}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ipcMain.handle("export-pdf", async (event, title, htmlContent) => {
|
|
||||||
const win = BrowserWindow3.fromWebContents(event.sender);
|
|
||||||
if (!win) return { success: false, error: "No window found" };
|
|
||||||
try {
|
|
||||||
const { filePath } = await dialog2.showSaveDialog(win, {
|
|
||||||
title: "\u5BFC\u51FA PDF",
|
|
||||||
defaultPath: `${title}.pdf`,
|
|
||||||
filters: [{ name: "PDF Files", extensions: ["pdf"] }]
|
|
||||||
});
|
|
||||||
if (!filePath) return { success: false, canceled: true };
|
|
||||||
if (!htmlContent) {
|
|
||||||
throw new Error("No HTML content provided for PDF export");
|
|
||||||
}
|
|
||||||
const pdfData = await generatePdf(htmlContent);
|
|
||||||
fs2.writeFileSync(filePath, pdfData);
|
|
||||||
return { success: true, filePath };
|
|
||||||
} catch (error) {
|
|
||||||
log2.error("Export PDF failed:", error);
|
|
||||||
return { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
ipcMain.handle("select-html-file", async (event) => {
|
|
||||||
const win = BrowserWindow3.fromWebContents(event.sender);
|
|
||||||
return selectHtmlFile(win);
|
|
||||||
});
|
|
||||||
ipcMain.handle("update-titlebar-buttons", async (event, symbolColor) => {
|
|
||||||
const win = BrowserWindow3.fromWebContents(event.sender);
|
|
||||||
if (win) {
|
|
||||||
win.setTitleBarOverlay({ symbolColor });
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
return { success: false };
|
|
||||||
});
|
|
||||||
ipcMain.handle("clipboard-read-text", async () => {
|
|
||||||
try {
|
|
||||||
const text = clipboard.readText();
|
|
||||||
return { success: true, text };
|
|
||||||
} catch (error) {
|
|
||||||
log2.error("Clipboard read failed:", error);
|
|
||||||
return { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
ipcMain.handle("clipboard-write-text", async (event, text) => {
|
|
||||||
try {
|
|
||||||
clipboard.writeText(text);
|
|
||||||
return { success: true };
|
|
||||||
} catch (error) {
|
|
||||||
log2.error("Clipboard write failed:", error);
|
|
||||||
return { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
async function startServer() {
|
|
||||||
if (electronState.isDevelopment()) {
|
|
||||||
log2.info("In dev mode, assuming external servers are running.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const serverPath = path2.join(__dirname, "../dist-api/server.js");
|
|
||||||
const serverUrl = pathToFileURL(serverPath).href;
|
|
||||||
log2.info(`Starting internal server from: ${serverPath}`);
|
|
||||||
try {
|
|
||||||
const serverModule = await import(serverUrl);
|
|
||||||
if (serverModule.startServer) {
|
|
||||||
const port = await serverModule.startServer();
|
|
||||||
electronState.setServerPort(port);
|
|
||||||
log2.info(`Internal server started successfully on port ${port}`);
|
|
||||||
} else {
|
|
||||||
log2.warn("startServer function not found in server module, using default port 3001");
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
log2.error("Failed to start internal server:", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
app.whenReady().then(async () => {
|
|
||||||
await startServer();
|
|
||||||
await createWindow();
|
|
||||||
startClipboardWatcher();
|
|
||||||
globalShortcut.register("CommandOrControl+Shift+C", () => {
|
|
||||||
log2.info("Global shortcut: sync clipboard to remote");
|
|
||||||
const win = electronState.getMainWindow();
|
|
||||||
if (win) {
|
|
||||||
win.webContents.send("remote-clipboard-sync-to-remote");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
globalShortcut.register("CommandOrControl+Shift+V", () => {
|
|
||||||
log2.info("Global shortcut: sync clipboard from remote");
|
|
||||||
const win = electronState.getMainWindow();
|
|
||||||
if (win) {
|
|
||||||
win.webContents.send("remote-clipboard-sync-from-remote");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
app.on("activate", () => {
|
|
||||||
if (BrowserWindow3.getAllWindows().length === 0) {
|
|
||||||
createWindow();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
app.on("window-all-closed", () => {
|
|
||||||
globalShortcut.unregisterAll();
|
|
||||||
if (process.platform !== "darwin") {
|
|
||||||
app.quit();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
//# sourceMappingURL=main.js.map
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,24 +0,0 @@
|
|||||||
// electron/preload.ts
|
|
||||||
var import_electron = require("electron");
|
|
||||||
console.log("--- PRELOAD SCRIPT LOADED SUCCESSFULLY ---");
|
|
||||||
import_electron.contextBridge.exposeInMainWorld("electronAPI", {
|
|
||||||
exportPDF: (title, htmlContent) => import_electron.ipcRenderer.invoke("export-pdf", title, htmlContent),
|
|
||||||
selectHtmlFile: () => import_electron.ipcRenderer.invoke("select-html-file"),
|
|
||||||
updateTitlebarButtons: (symbolColor) => import_electron.ipcRenderer.invoke("update-titlebar-buttons", symbolColor),
|
|
||||||
onRemoteClipboardSyncToRemote: (callback) => {
|
|
||||||
import_electron.ipcRenderer.on("remote-clipboard-sync-to-remote", callback);
|
|
||||||
return () => import_electron.ipcRenderer.removeListener("remote-clipboard-sync-to-remote", callback);
|
|
||||||
},
|
|
||||||
onRemoteClipboardSyncFromRemote: (callback) => {
|
|
||||||
import_electron.ipcRenderer.on("remote-clipboard-sync-from-remote", callback);
|
|
||||||
return () => import_electron.ipcRenderer.removeListener("remote-clipboard-sync-from-remote", callback);
|
|
||||||
},
|
|
||||||
onRemoteClipboardAutoSync: (callback) => {
|
|
||||||
const handler = (_event, text) => callback(text);
|
|
||||||
import_electron.ipcRenderer.on("remote-clipboard-auto-sync", handler);
|
|
||||||
return () => import_electron.ipcRenderer.removeListener("remote-clipboard-auto-sync", handler);
|
|
||||||
},
|
|
||||||
clipboardReadText: () => import_electron.ipcRenderer.invoke("clipboard-read-text"),
|
|
||||||
clipboardWriteText: (text) => import_electron.ipcRenderer.invoke("clipboard-write-text", text)
|
|
||||||
});
|
|
||||||
//# sourceMappingURL=preload.cjs.map
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"version":3,"sources":["../electron/preload.ts"],"sourcesContent":["import { contextBridge, ipcRenderer } from 'electron'\n\nconsole.log('--- PRELOAD SCRIPT LOADED SUCCESSFULLY ---')\n\ncontextBridge.exposeInMainWorld('electronAPI', {\n exportPDF: (title: string, htmlContent?: string) => ipcRenderer.invoke('export-pdf', title, htmlContent),\n selectHtmlFile: () => ipcRenderer.invoke('select-html-file'),\n updateTitlebarButtons: (symbolColor: string) => ipcRenderer.invoke('update-titlebar-buttons', symbolColor),\n onRemoteClipboardSyncToRemote: (callback: () => void) => {\n ipcRenderer.on('remote-clipboard-sync-to-remote', callback);\n return () => ipcRenderer.removeListener('remote-clipboard-sync-to-remote', callback);\n },\n onRemoteClipboardSyncFromRemote: (callback: () => void) => {\n ipcRenderer.on('remote-clipboard-sync-from-remote', callback);\n return () => ipcRenderer.removeListener('remote-clipboard-sync-from-remote', callback);\n },\n onRemoteClipboardAutoSync: (callback: (text: string) => void) => {\n const handler = (_event: Electron.IpcRendererEvent, text: string) => callback(text);\n ipcRenderer.on('remote-clipboard-auto-sync', handler);\n return () => ipcRenderer.removeListener('remote-clipboard-auto-sync', handler);\n },\n clipboardReadText: () => ipcRenderer.invoke('clipboard-read-text'),\n clipboardWriteText: (text: string) => ipcRenderer.invoke('clipboard-write-text', text),\n})\n"],"mappings":";AAAA,sBAA2C;AAE3C,QAAQ,IAAI,4CAA4C;AAExD,8BAAc,kBAAkB,eAAe;AAAA,EAC7C,WAAW,CAAC,OAAe,gBAAyB,4BAAY,OAAO,cAAc,OAAO,WAAW;AAAA,EACvG,gBAAgB,MAAM,4BAAY,OAAO,kBAAkB;AAAA,EAC3D,uBAAuB,CAAC,gBAAwB,4BAAY,OAAO,2BAA2B,WAAW;AAAA,EACzG,+BAA+B,CAAC,aAAyB;AACvD,gCAAY,GAAG,mCAAmC,QAAQ;AAC1D,WAAO,MAAM,4BAAY,eAAe,mCAAmC,QAAQ;AAAA,EACrF;AAAA,EACA,iCAAiC,CAAC,aAAyB;AACzD,gCAAY,GAAG,qCAAqC,QAAQ;AAC5D,WAAO,MAAM,4BAAY,eAAe,qCAAqC,QAAQ;AAAA,EACvF;AAAA,EACA,2BAA2B,CAAC,aAAqC;AAC/D,UAAM,UAAU,CAAC,QAAmC,SAAiB,SAAS,IAAI;AAClF,gCAAY,GAAG,8BAA8B,OAAO;AACpD,WAAO,MAAM,4BAAY,eAAe,8BAA8B,OAAO;AAAA,EAC/E;AAAA,EACA,mBAAmB,MAAM,4BAAY,OAAO,qBAAqB;AAAA,EACjE,oBAAoB,CAAC,SAAiB,4BAAY,OAAO,wBAAwB,IAAI;AACvF,CAAC;","names":[]}
|
|
||||||
2114
docs/vercel/VERCEL_GUIDE.md
Normal file
2114
docs/vercel/VERCEL_GUIDE.md
Normal file
File diff suppressed because it is too large
Load Diff
478
docs/vercel/VERCEL_MARKETPLACE.md
Normal file
478
docs/vercel/VERCEL_MARKETPLACE.md
Normal file
@@ -0,0 +1,478 @@
|
|||||||
|
# Vercel Marketplace 集成文档
|
||||||
|
|
||||||
|
> 更新时间:2026年3月12日
|
||||||
|
> 数据来源:https://vercel.com/marketplace
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、定价模式说明
|
||||||
|
|
||||||
|
| 标记 | 含义 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 🆓 | 免费开源 | 完全免费,开源项目 |
|
||||||
|
| 🆓Free | 免费套餐 | 有免费额度,付费升级 |
|
||||||
|
| 💰 | 付费订阅 | 无免费层或限制大 |
|
||||||
|
| 🏢 | 企业定价 | 需联系销售 |
|
||||||
|
| ❓ | 需查询 | 定价不明确,需查看官网 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、分类列表(共22个)
|
||||||
|
|
||||||
|
| 分类 | URL |
|
||||||
|
|------|-----|
|
||||||
|
| AI Agents & Services | /marketplace/category/agents |
|
||||||
|
| AI | /marketplace/category/ai |
|
||||||
|
| Analytics | /marketplace/category/analytics |
|
||||||
|
| Authentication | /marketplace/category/authentication |
|
||||||
|
| CMS | /marketplace/category/cms |
|
||||||
|
| Commerce | /marketplace/category/commerce |
|
||||||
|
| Database | /marketplace/category/database |
|
||||||
|
| DevTools | /marketplace/category/dev-tools |
|
||||||
|
| Experimentation | /marketplace/category/experimentation |
|
||||||
|
| Flags | /marketplace/category/flags |
|
||||||
|
| Logging | /marketplace/category/logging |
|
||||||
|
| Messaging | /marketplace/category/messaging |
|
||||||
|
| Monitoring | /marketplace/category/monitoring |
|
||||||
|
| Observability | /marketplace/category/observability |
|
||||||
|
| Payments | /marketplace/category/payments |
|
||||||
|
| Productivity | /marketplace/category/productivity |
|
||||||
|
| Searching | /marketplace/category/searching |
|
||||||
|
| Security | /marketplace/category/security |
|
||||||
|
| Storage | /marketplace/category/storage |
|
||||||
|
| Testing | /marketplace/category/testing |
|
||||||
|
| Video | /marketplace/category/video |
|
||||||
|
| Workflow | /marketplace/category/workflow |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、推荐模板(Next.js专用)
|
||||||
|
|
||||||
|
| 模板名称 | 定价 | 描述 | URL |
|
||||||
|
|----------|------|------|-----|
|
||||||
|
| Next.js AI Chatbot | 🆓 | 全功能的可定制 Next.js AI 聊天机器人 | https://vercel.com/templates/ai/nextjs-ai-chatbot |
|
||||||
|
| Statsig + Flags SDK | 🆓Free | 使用 Statsig 和 Flags SDK 在静态页面上运行实验 | https://vercel.com/templates/edge-config/statsig-experimentation-with-flags-sdk |
|
||||||
|
| Next.js + Mux Video | 🆓Free | 使用 Mux 为 Next.js 应用添加视频 | https://vercel.com/templates/next.js/next-video-starter |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、Native Integrations(原生集成)
|
||||||
|
|
||||||
|
### 4.1 Storage(存储)- 12个
|
||||||
|
|
||||||
|
| 名称 | 定价 | 描述 | URL |
|
||||||
|
|------|------|------|-----|
|
||||||
|
| **Neon** | 🆓Free | Postgres无服务器平台,0.5GB免费 | /marketplace/neon |
|
||||||
|
| **AWS** | 🏢 | 亚马逊云服务 | /marketplace/aws |
|
||||||
|
| **Upstash** | 🆓Free | 无服务器Redis/Vector,10K命令/天免费 | /marketplace/upstash |
|
||||||
|
| **Supabase** | 🆓 | 开源Postgres开发平台 | /marketplace/supabase |
|
||||||
|
| **Redis** | 🆓Free | 无服务器Redis,30MB免费 | /marketplace/redis |
|
||||||
|
| **Nile** | 🆓Free | PostgreSQL for B2B | /marketplace/nile |
|
||||||
|
| **MotherDuck** | ❓ | 分析的无服务器后端 | /marketplace/motherduck |
|
||||||
|
| **Convex** | 🆓 | 开源后端平台 | /marketplace/convex |
|
||||||
|
| **Prisma** | 🆓 | 开源ORM/Postgres | /marketplace/prisma |
|
||||||
|
| **Turso Cloud** | 🆓Free | SQLite,500MB免费 | /marketplace/tursocloud |
|
||||||
|
| **MongoDB Atlas** | 🏢 | MongoDB官方NoSQL数据库 | /marketplace/mongodbatlas |
|
||||||
|
| **Mixedbread** | ❓ | 多模态搜索API | /marketplace/mixedbread |
|
||||||
|
|
||||||
|
### 4.2 AI(人工智能)- 4个
|
||||||
|
|
||||||
|
| 名称 | 定价 | 描述 | URL |
|
||||||
|
|------|------|------|-----|
|
||||||
|
| **xAI (Grok)** | 💰 | xAI的Grok AI | /marketplace/xai |
|
||||||
|
| **Groq** | ❓ | AI快速推理服务 | /marketplace/groq |
|
||||||
|
| **fal** | ❓ | 生成式媒体平台 | /marketplace/fal |
|
||||||
|
| **Deep Infra** | ❓ | Deep Infra AI集成 | /marketplace/deepinfra |
|
||||||
|
|
||||||
|
### 4.3 Observability(可观测性)- 5个
|
||||||
|
|
||||||
|
| 名称 | 定价 | 描述 | URL |
|
||||||
|
|------|------|------|-----|
|
||||||
|
| **Sentry** | 🆓Free | 错误监控,5K错误/月免费 | /marketplace/sentry |
|
||||||
|
| **Dash0** | ❓ | 日志、追踪、指标 | /marketplace/dash0 |
|
||||||
|
| **Braintrust** | ❓ | AI评估监控平台 | /marketplace/braintrust |
|
||||||
|
| **Kubiks** | ❓ | 日志追踪、仪表盘、警报 | /marketplace/kubiks |
|
||||||
|
| **Rollbar** | 🆓Free | 实时崩溃报告,$0/月 | /marketplace/rollbar |
|
||||||
|
|
||||||
|
### 4.4 Monitoring(监控)- 1个
|
||||||
|
|
||||||
|
| 名称 | 定价 | 描述 | URL |
|
||||||
|
|------|------|------|-----|
|
||||||
|
| **Checkly** | 💰 | Playwright监控,$29/月起 | /marketplace/checkly |
|
||||||
|
|
||||||
|
### 4.5 Web Automation(网页自动化)- 2个
|
||||||
|
|
||||||
|
| 名称 | 定价 | 描述 | URL |
|
||||||
|
|------|------|------|-----|
|
||||||
|
| **Browserbase** | ❓ | 无服务器浏览器自动化 | /marketplace/browserbase |
|
||||||
|
| **Kernel** | ❓ | AI代理互联网访问infra | /marketplace/kernel |
|
||||||
|
|
||||||
|
### 4.6 Code Review(代码审查)- 3个
|
||||||
|
|
||||||
|
| 名称 | 定价 | 描述 | URL |
|
||||||
|
|------|------|------|-----|
|
||||||
|
| **cubic** | ❓ | 复杂代码库AI审查 | /marketplace/cubic |
|
||||||
|
| **CodeRabbit** | 🆓Free | 代码审查,500行/月免费,$9/月起 | /marketplace/coderabbit |
|
||||||
|
| **Sourcery** | 🆓Free | AI代码审查,免费版无限 | /marketplace/sourcery |
|
||||||
|
|
||||||
|
### 4.7 Support Agent(客服代理)- 2个
|
||||||
|
|
||||||
|
| 名称 | 定价 | 描述 | URL |
|
||||||
|
|------|------|------|-----|
|
||||||
|
| **AssistLoop** | ❓ | AI客服代理 | /marketplace/assistloop |
|
||||||
|
| **Chatbase** | ❓ | 免费AI客服代理 | /marketplace/chatbase |
|
||||||
|
|
||||||
|
### 4.8 Searching(搜索)- 1个
|
||||||
|
|
||||||
|
| 名称 | 定价 | 描述 | URL |
|
||||||
|
|------|------|------|-----|
|
||||||
|
| **Parallel Web Systems** | ❓ | AI网页搜索 | /marketplace/parallel |
|
||||||
|
|
||||||
|
### 4.9 Code Security(代码安全)- 1个
|
||||||
|
|
||||||
|
| 名称 | 定价 | 描述 | URL |
|
||||||
|
|------|------|------|-----|
|
||||||
|
| **Corridor** | ❓ | AI编码安全审查 | /marketplace/corridor |
|
||||||
|
|
||||||
|
### 4.10 Payments(支付)- 1个
|
||||||
|
|
||||||
|
| 名称 | 定价 | 描述 | URL |
|
||||||
|
|------|------|------|-----|
|
||||||
|
| **Stripe** | 🆓Free | 支付平台,交易手续费 | /marketplace/stripe |
|
||||||
|
|
||||||
|
### 4.11 Testing(测试)- 1个
|
||||||
|
|
||||||
|
| 名称 | 定价 | 描述 | URL |
|
||||||
|
|------|------|------|-----|
|
||||||
|
| **Autonoma AI** | ❓ | AI端到端测试 | /marketplace/autonoma-ai |
|
||||||
|
|
||||||
|
### 4.12 Analytics(分析)- 3个
|
||||||
|
|
||||||
|
| 名称 | 定价 | 描述 | URL |
|
||||||
|
|------|------|------|-----|
|
||||||
|
| **Statsig** | ❓ | 特性标志、实验、分析 | /marketplace/statsig |
|
||||||
|
| **Hypertune** | ❓ | 类型安全特性标志 | /marketplace/hypertune |
|
||||||
|
| **PostHog** | 🆓Free | 分析、会话回放,1M事件/月免费 | /marketplace/posthog |
|
||||||
|
|
||||||
|
### 4.13 Authentication(认证)- 2个
|
||||||
|
|
||||||
|
| 名称 | 定价 | 描述 | URL |
|
||||||
|
|------|------|------|-----|
|
||||||
|
| **Descope** | 🆓Free | 拖放式认证 | /marketplace/descope |
|
||||||
|
| **Clerk** | 🆓Free | 认证服务,10K月活免费 | /marketplace/clerk |
|
||||||
|
|
||||||
|
### 4.14 DevTools(开发工具)- 1个
|
||||||
|
|
||||||
|
| 名称 | 定价 | 描述 | URL |
|
||||||
|
|------|------|------|-----|
|
||||||
|
| **Inngest** | 🆓 | 开源AI工作流平台 | /marketplace/inngest |
|
||||||
|
|
||||||
|
### 4.15 Experimentation(实验)- 1个
|
||||||
|
|
||||||
|
| 名称 | 定价 | 描述 | URL |
|
||||||
|
|------|------|------|-----|
|
||||||
|
| **GrowthBook** | 🆓 | 开源特性标志实验 | /marketplace/growthbook |
|
||||||
|
|
||||||
|
### 4.16 Video(视频)- 1个
|
||||||
|
|
||||||
|
| 名称 | 定价 | 描述 | URL |
|
||||||
|
|------|------|------|-----|
|
||||||
|
| **Mux** | 🆓Free | 视频服务,1K分钟免费 | /marketplace/mux |
|
||||||
|
|
||||||
|
### 4.17 CMS(内容管理)- 1个
|
||||||
|
|
||||||
|
| 名称 | 定价 | 描述 | URL |
|
||||||
|
|------|------|------|-----|
|
||||||
|
| **Sanity** | 🆓Free | 内容平台,10K文档免费 | /marketplace/sanity |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、External Integrations(外部集成)
|
||||||
|
|
||||||
|
### 5.1 AI(人工智能)- 6个
|
||||||
|
|
||||||
|
| 名称 | 定价 | 描述 | URL |
|
||||||
|
|------|------|------|-----|
|
||||||
|
| **Deep Infra** | ❓ | AI集成 | /marketplace/deepinfra |
|
||||||
|
| **ElevenLabs** | 💰 | AI文本转语音 | /marketplace/elevenlabs |
|
||||||
|
| **LMNT** | ❓ | 文本转语音&语音克隆 | /marketplace/lmnt |
|
||||||
|
| **Perplexity API** | ❓ | Perplexity LLM | /marketplace/pplx-api |
|
||||||
|
| **Replicate** | 🆓Free | AI模型运行,有免费额度 | /marketplace/replicate |
|
||||||
|
| **Together AI** | 💰 | 生成式AI云平台 | /marketplace/together-ai |
|
||||||
|
|
||||||
|
### 5.2 Analytics(分析)- 7个
|
||||||
|
|
||||||
|
| 名称 | 定价 | 描述 | URL |
|
||||||
|
|------|------|------|-----|
|
||||||
|
| **Hypertune** | ❓ | 特性标志 | /marketplace/hypertune |
|
||||||
|
| **LaunchDarkly** | 💰 | 特性标志,$30/月起 | /marketplace/launchdarkly |
|
||||||
|
| **Statsig** | ❓ | 特性标志实验 | /marketplace/statsig |
|
||||||
|
| **Vercel Web Analytics** | 💰 | 第一方分析,$20/月 | /marketplace/vercel-analytics |
|
||||||
|
| **DevCycle** | ❓ | 特性标志 | /marketplace/devcycle |
|
||||||
|
| **Kameleoon** | ❓ | A/B测试 | /marketplace/kameleoon |
|
||||||
|
| **Split** | 💰 | 特性标志,$49/月起 | /marketplace/split |
|
||||||
|
|
||||||
|
### 5.3 Authentication(认证)- 2个
|
||||||
|
|
||||||
|
| 名称 | 定价 | 描述 | URL |
|
||||||
|
|------|------|------|-----|
|
||||||
|
| **Auth0** | 🆓Free | 认证服务,7K月活免费 | /marketplace/auth0 |
|
||||||
|
| **Clerk** | 🆓Free | 认证服务,10K月活免费 | /marketplace/clerk |
|
||||||
|
|
||||||
|
> ⚠️ **页面显示错误**:Neon 和 Supabase 在页面上被错误地归类为 Authentication,它们实际是 Storage 服务(提供 Postgres 数据库)。
|
||||||
|
|
||||||
|
### 5.4 CMS(内容管理)- 10个
|
||||||
|
|
||||||
|
| 名称 | 定价 | 描述 | URL |
|
||||||
|
|------|------|------|-----|
|
||||||
|
| **Agility CMS** | ❓ | Headless CMS | /marketplace/agility-cms |
|
||||||
|
| **Builder.io** | 🏢 | 可视化CMS,企业定价 | https://vercel.com/new/templates?search=builder&cms=builder.io |
|
||||||
|
| **ButterCMS** | ❓ | Headless CMS | /marketplace/buttercms |
|
||||||
|
| **Contentful** | 🆓Free | 内容平台,25K记录免费 | /marketplace/contentful |
|
||||||
|
| **Contentstack** | 🏢 | 全渠道CMS,企业 | https://vercel.com/guides/integrate-vercel-and-contentstack |
|
||||||
|
| **DatoCMS** | ❓ | Headless CMS | /marketplace/datocms |
|
||||||
|
| **Formspree** | ❓ | 表单后端 | /marketplace/formspree |
|
||||||
|
| **Makeswift** | ❓ | Next.js可视化构建 | /marketplace/makeswift |
|
||||||
|
| **Sanity** | 🆓Free | 内容平台,10K文档免费 | /marketplace/sanity |
|
||||||
|
| **Sitecore XM Cloud** | 🏢 | SaaS CMS | https://vercel.com/docs/integrations/sitecore |
|
||||||
|
|
||||||
|
### 5.5 Commerce(商务)- 7个
|
||||||
|
|
||||||
|
| 名称 | 定价 | 描述 | URL |
|
||||||
|
|------|------|------|-----|
|
||||||
|
| **BigCommerce** | 💰 | 电商平台,$29/月起 | https://vercel.com/templates/next.js/nextjs-commerce |
|
||||||
|
| **Saleor** | 🆓 | 开源商务API | https://vercel.com/templates/next.js/nextjs-saleor-commerce |
|
||||||
|
| **Salesforce Commerce Cloud** | 🏢 | 商务平台,企业 | https://vercel.com/templates/next.js/salesforce-commerce-cloud-starter |
|
||||||
|
| **Shopify** | 💰 | Headless电商,$29/月起 | https://vercel.com/docs/integrations/shopify |
|
||||||
|
| **Sitecore OrderCloud** | 🏢 | B2X商务 | /marketplace/ordercloud |
|
||||||
|
| **Swell** | 🆓 | 开源Headless电商 | /marketplace/swell |
|
||||||
|
| **Wix** | 💰 | 业务解决方案 | /marketplace/wix |
|
||||||
|
|
||||||
|
### 5.6 DevTools(开发工具)- 8个
|
||||||
|
|
||||||
|
| 名称 | 定价 | 描述 | URL |
|
||||||
|
|------|------|------|-----|
|
||||||
|
| **Deploy Summary** | 🆓 | Vercel部署摘要 | /marketplace/deploy-summary |
|
||||||
|
| **Doppler** | ❓ | 密钥管理 | /marketplace/doppler |
|
||||||
|
| **Inngest** | 🆓 | 开源工作流平台 | /marketplace/inngest |
|
||||||
|
| **Liveblocks** | 🆓Free | 实时协作,有免费层 | https://vercel.com/templates/next.js/liveblocks-starter-kit |
|
||||||
|
| **Railway** | 🆓Free | 部署平台,$5额度免费 | /marketplace/railway |
|
||||||
|
| **Raycast** | ❓ | Vercel项目集成 | /marketplace/raycast |
|
||||||
|
| **Svix** | ❓ | Webhook服务 | /marketplace/svix |
|
||||||
|
| **Terraform** | 🆓 | 开源IaC工具 | /marketplace/terraform |
|
||||||
|
|
||||||
|
> ⚠️ **注**:AWS 不在 External Integrations 列表中(通过 /partners/aws 跳转)
|
||||||
|
|
||||||
|
### 5.7 Logging(日志)- 8个
|
||||||
|
|
||||||
|
| 名称 | 定价 | 描述 | URL |
|
||||||
|
|------|------|------|-----|
|
||||||
|
| **Axiom** | 🏢 | 日志服务,企业 | /marketplace/axiom |
|
||||||
|
| **Baselime** | 🏢 | 日志查询,企业 | /marketplace/baselime |
|
||||||
|
| **Better Stack** | ❓ | 日志服务 | /marketplace/betterstack |
|
||||||
|
| **GraphJSON** | ❓ | 日志可视化 | /marketplace/graphjson |
|
||||||
|
| **Logalert** | ❓ | 日志警报 | /marketplace/logalert |
|
||||||
|
| **Logflare** | ❓ | 日志服务 | /marketplace/logflare |
|
||||||
|
| **Profound** | ❓ | AI活动监控 | /marketplace/profound |
|
||||||
|
| **Sematext Logs** | ❓ | 日志服务 | /marketplace/sematext-logs |
|
||||||
|
|
||||||
|
### 5.8 Messaging(消息)- 4个
|
||||||
|
|
||||||
|
| 名称 | 定价 | 描述 | URL |
|
||||||
|
|------|------|------|-----|
|
||||||
|
| **Knock** | ❓ | 消息API | /marketplace/knock |
|
||||||
|
| **Novu** | 🆓 | 开源通知基础设施 | /marketplace/novu |
|
||||||
|
| **Resend** | 🆓Free | 邮件服务,3K邮件免费 | /marketplace/resend |
|
||||||
|
| **Slack** | 🆓Free | 消息通知,免费版可用 | /marketplace/slack |
|
||||||
|
|
||||||
|
### 5.9 Monitoring(监控)- 3个
|
||||||
|
|
||||||
|
| 名称 | 定价 | 描述 | URL |
|
||||||
|
|------|------|------|-----|
|
||||||
|
| **Checkly** | 💰 | 监控平台,$29/月起 | /marketplace/checkly |
|
||||||
|
| **DebugBear** | 💰 | 性能监控,$39/月起 | /marketplace/debugbear |
|
||||||
|
| **Zeitgeist** | ❓ | Vercel部署管理 | /marketplace/zeitgeist-app |
|
||||||
|
|
||||||
|
### 5.10 Observability(可观测性)- 8个
|
||||||
|
|
||||||
|
| 名称 | 定价 | 描述 | URL |
|
||||||
|
|------|------|------|-----|
|
||||||
|
| **Dash0** | ❓ | 可观测平台 | /marketplace/dash0 |
|
||||||
|
| **Datadog** | 💰 | 监控平台,$15/月起 | /marketplace/datadog |
|
||||||
|
| **Highlight** | ❓ | 调试平台 | /marketplace/highlight |
|
||||||
|
| **HyperDX** | ❓ | 可观测平台 | /marketplace/hyperdx |
|
||||||
|
| **New Relic** | 💰 | 监控平台,$49/月起 | /marketplace/newrelic |
|
||||||
|
| **Sentry** | 🆓Free | 错误监控,5K错误/月免费 | /marketplace/sentry |
|
||||||
|
| **shipshape** | ❓ | 部署仪表盘 | /marketplace/shipshape |
|
||||||
|
| **Middleware** | ❓ | AI云可观测平台 | /marketplace/middleware |
|
||||||
|
|
||||||
|
### 5.11 Productivity(生产力)- 3个
|
||||||
|
|
||||||
|
| 名称 | 定价 | 描述 | URL |
|
||||||
|
|------|------|------|-----|
|
||||||
|
| **GitHub Issues** | 🆓 | 评论转GitHub Issue | /marketplace/gh-issues |
|
||||||
|
| **Jira** | 🆓 | 评论转Jira | /marketplace/jira |
|
||||||
|
| **Linear** | 🆓 | 评论转Linear | /marketplace/linear |
|
||||||
|
|
||||||
|
### 5.12 Searching(搜索)- 2个
|
||||||
|
|
||||||
|
| 名称 | 定价 | 描述 | URL |
|
||||||
|
|------|------|------|-----|
|
||||||
|
| **Meilisearch Cloud** | 🏢 | 搜索服务,企业 | /marketplace/meilisearch-cloud |
|
||||||
|
| **Upstash** | 🆓Free | Redis搜索,10K命令/天免费 | /marketplace/upstash |
|
||||||
|
|
||||||
|
### 5.13 Security(安全)- 1个
|
||||||
|
|
||||||
|
| 名称 | 定价 | 描述 | URL |
|
||||||
|
|------|------|------|-----|
|
||||||
|
| **Arcjet** | ❓ | 代码化安全 | /marketplace/arcjet |
|
||||||
|
|
||||||
|
### 5.14 Storage(存储)- 13个
|
||||||
|
|
||||||
|
| 名称 | 定价 | 描述 | URL |
|
||||||
|
|------|------|------|-----|
|
||||||
|
| **AWS S3** | 🏢 | 对象存储 | https://vercel.com/templates/next.js/aws-s3-image-upload-nextjs |
|
||||||
|
| **Azure Cosmos DB** | 🏢 | NoSQL数据库 | /marketplace/azurecosmosdb |
|
||||||
|
| **Couchbase Capella** | 🏢 | NoSQL云数据库 | /marketplace/couchbase-capella |
|
||||||
|
| **DataStax Astra DB** | 🏢 | 向量数据库 | /marketplace/datastax-astra-db |
|
||||||
|
| **Hasura** | 🆓 | 开源GraphQL API | /marketplace/hasura |
|
||||||
|
| **Pinecone** | 🏢 | 向量数据库 | /marketplace/pinecone |
|
||||||
|
| **SingleStoreDB Cloud** | 🏢 | 时序数据库 | /marketplace/singlestoredb-cloud |
|
||||||
|
| **StepZen** | 🆓 | 开源GraphQL | /marketplace/stepzen |
|
||||||
|
| **Thin Backend** | 🆓 | 开源Postgres后端 | /marketplace/thin |
|
||||||
|
| **TiDB Cloud** | 🏢 | 向量MySQL | /marketplace/tidb-cloud |
|
||||||
|
| **Tinybird** | 🏢 | 实时分析 | /marketplace/tinybird |
|
||||||
|
| **Xata** | ❓ | 数据库服务 | /marketplace/xata |
|
||||||
|
| **Tigris** | ❓ | 对象存储 | /marketplace/tigris |
|
||||||
|
|
||||||
|
### 5.15 Testing(测试)- 1个
|
||||||
|
|
||||||
|
| 名称 | 定价 | 描述 | URL |
|
||||||
|
|------|------|------|-----|
|
||||||
|
| **Meticulous AI** | ❓ | AI端到端测试 | /marketplace/meticulous |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、Coming Soon(即将推出)
|
||||||
|
|
||||||
|
| 名称 | 定价 | 描述 |
|
||||||
|
|------|------|------|
|
||||||
|
| **Browser Use** | ❓ | 网页自动化,可订阅通知 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、统计汇总
|
||||||
|
|
||||||
|
| 类别 | 数量 |
|
||||||
|
|------|------|
|
||||||
|
| **总分类数** | 22 |
|
||||||
|
| **模板数** | 3 |
|
||||||
|
| **Native Integrations** | 42 |
|
||||||
|
| **External Integrations** | 90 |
|
||||||
|
| 🆓 免费开源 | ~15 |
|
||||||
|
| 🆓Free 免费套餐 | ~25 |
|
||||||
|
| 💰 付费订阅 | ~15 |
|
||||||
|
| 🏢 企业定价 | ~20 |
|
||||||
|
| ❓ 需查询定价 | ~35 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、Native Integrations 完整列表(42个)
|
||||||
|
|
||||||
|
### Storage(存储)- 12个
|
||||||
|
Neon, AWS, Upstash, Supabase, Redis, Nile, MotherDuck, Convex, Prisma, Turso Cloud, MongoDB Atlas, Mixedbread
|
||||||
|
|
||||||
|
### AI(人工智能)- 4个
|
||||||
|
xAI (Grok), Groq, fal, Deep Infra
|
||||||
|
|
||||||
|
### Observability(可观测性)- 5个
|
||||||
|
Sentry, Dash0, Braintrust, Kubiks, Rollbar
|
||||||
|
|
||||||
|
### Monitoring(监控)- 1个
|
||||||
|
Checkly
|
||||||
|
|
||||||
|
### Web Automation(网页自动化)- 2个
|
||||||
|
Browserbase, Kernel
|
||||||
|
|
||||||
|
### Code Review(代码审查)- 3个
|
||||||
|
cubic, CodeRabbit, Sourcery
|
||||||
|
|
||||||
|
### Support Agent(客服代理)- 2个
|
||||||
|
AssistLoop, Chatbase
|
||||||
|
|
||||||
|
### Searching(搜索)- 1个
|
||||||
|
Parallel Web Systems
|
||||||
|
|
||||||
|
### Code Security(代码安全)- 1个
|
||||||
|
Corridor
|
||||||
|
|
||||||
|
### Payments(支付)- 1个
|
||||||
|
Stripe
|
||||||
|
|
||||||
|
### Testing(测试)- 1个
|
||||||
|
Autonoma AI
|
||||||
|
|
||||||
|
### Analytics(分析)- 3个
|
||||||
|
Statsig, Hypertune, PostHog
|
||||||
|
|
||||||
|
### Authentication(认证)- 2个
|
||||||
|
Descope, Clerk
|
||||||
|
|
||||||
|
### DevTools(开发工具)- 1个
|
||||||
|
Inngest
|
||||||
|
|
||||||
|
### Experimentation(实验)- 1个
|
||||||
|
GrowthBook
|
||||||
|
|
||||||
|
### Video(视频)- 1个
|
||||||
|
Mux
|
||||||
|
|
||||||
|
### CMS(内容管理)- 1个
|
||||||
|
Sanity
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、External Integrations 分类统计(89个)
|
||||||
|
|
||||||
|
| 类别 | 数量 |
|
||||||
|
|------|------|
|
||||||
|
| AI | 6 |
|
||||||
|
| Analytics | 7 |
|
||||||
|
| Authentication | 2 |
|
||||||
|
| CMS | 10 |
|
||||||
|
| Commerce | 7 |
|
||||||
|
| DevTools | 8 |
|
||||||
|
| Logging | 8 |
|
||||||
|
| Messaging | 4 |
|
||||||
|
| Monitoring | 3 |
|
||||||
|
| Observability | 8 |
|
||||||
|
| Productivity | 3 |
|
||||||
|
| Searching | 2 |
|
||||||
|
| Security | 1 |
|
||||||
|
| Storage | 13 |
|
||||||
|
| Testing | 1 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、快速参考
|
||||||
|
|
||||||
|
### ⚠️ Vercel专有(仅6个)
|
||||||
|
这些服务只能在Vercel平台使用:
|
||||||
|
- Vercel Web Analytics
|
||||||
|
- Deploy Summary
|
||||||
|
- Raycast
|
||||||
|
- GitHub Issues
|
||||||
|
- Jira
|
||||||
|
- Linear
|
||||||
|
|
||||||
|
### 🆓 免费开源(完全免费)
|
||||||
|
Supabase, Convex, Inngest, Novu, GrowthBook, Saleor, Swell, Hasura, StepZen, Thin Backend, Terraform, Prisma, Sourcery
|
||||||
|
|
||||||
|
### 🆓Free 免费套餐(适合个人项目)
|
||||||
|
Neon, Upstash, Clerk, Auth0, Sentry, PostHog, Mux, Stripe, Resend, Railway, CodeRabbit, Sanity, Contentful, Turso, Redis, Descope, Replicate, Liveblocks, Rollbar
|
||||||
|
|
||||||
|
### 💰 付费订阅
|
||||||
|
Vercel Analytics($20/月), Checkly($29/月), DebugBear($39/月), Datadog($15/月), New Relic($49/月), LaunchDarkly($30/月), BigCommerce($29/月), Shopify($29/月), ElevenLabs, Together AI
|
||||||
|
|
||||||
|
### 🏢 企业定价
|
||||||
|
AWS, Azure, MongoDB Atlas, Pinecone, DataStax, Couchbase, TiDB, Tinybird, Meilisearch, Contentstack, Builder.io, Sitecore, Salesforce
|
||||||
983
docs/vercel/vercel-mvp-system-v2.md
Normal file
983
docs/vercel/vercel-mvp-system-v2.md
Normal file
@@ -0,0 +1,983 @@
|
|||||||
|
# Vercel MVP Idea 验证系统 v2.0
|
||||||
|
|
||||||
|
> 精简版设计文档 - 专注核心价值
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、系统定位
|
||||||
|
|
||||||
|
**一句话定义**:帮你分析 idea 可行性,生成网站设计文档,追踪验证数据。
|
||||||
|
|
||||||
|
**核心价值**:
|
||||||
|
1. **AI 深度分析** - 不是简单的评分,而是给出具体的市场分析和建议
|
||||||
|
2. **设计文档生成** - 输出专业的网站设计文档,你用专业工具构建
|
||||||
|
3. **数据追踪** - 部署后追踪访问和转化数据
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、核心流程
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 简化版流程 │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||||
|
│ 1. 输入 │ ──▶ │ 2. AI │ ──▶ │ 3. 生成 │
|
||||||
|
│ Idea │ │ 深度分析 │ │ 设计文档 │
|
||||||
|
└─────────────┘ └─────────────┘ └─────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────┐
|
||||||
|
│ 4. 你自己 │
|
||||||
|
│ 构建部署 │
|
||||||
|
└─────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────┐
|
||||||
|
│ 5. 追踪 │
|
||||||
|
│ 验证数据 │
|
||||||
|
└─────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、功能模块
|
||||||
|
|
||||||
|
### 3.1 Idea 管理
|
||||||
|
|
||||||
|
#### 数据模型
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Idea {
|
||||||
|
id: string
|
||||||
|
name: string // "AI 写作助手"
|
||||||
|
description: string // 一句话描述
|
||||||
|
category: 'tool' | 'content' | 'saas' | 'ecommerce' | 'other'
|
||||||
|
targetKeywords: string[] // 目标关键词
|
||||||
|
status: 'draft' | 'analyzing' | 'ready' | 'deployed' | 'validated' | 'abandoned'
|
||||||
|
|
||||||
|
// AI 分析结果
|
||||||
|
analysis?: IdeaAnalysis
|
||||||
|
|
||||||
|
// 设计文档
|
||||||
|
designDoc?: DesignDocument
|
||||||
|
|
||||||
|
// 部署信息
|
||||||
|
deployment?: {
|
||||||
|
url: string
|
||||||
|
deployedAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证数据
|
||||||
|
validation?: {
|
||||||
|
visits: number
|
||||||
|
signups: number
|
||||||
|
lastChecked: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
createdAt: Date
|
||||||
|
updatedAt: Date
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 功能
|
||||||
|
- 创建 / 编辑 / 删除 Idea
|
||||||
|
- 状态管理(草稿 → 分析中 → 就绪 → 已部署 → 验证中 → 已验证 / 已放弃)
|
||||||
|
- 列表展示 + 筛选
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.2 AI 深度分析
|
||||||
|
|
||||||
|
#### 分析维度
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ AI 分析输出 │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
1. 市场分析
|
||||||
|
├── 市场规模估算
|
||||||
|
├── 目标用户画像
|
||||||
|
├── 用户痛点分析
|
||||||
|
└── 竞品概览(3-5个主要竞品)
|
||||||
|
|
||||||
|
2. 可行性评估
|
||||||
|
├── 技术难度 (1-5星)
|
||||||
|
├── 资金需求 (低/中/高)
|
||||||
|
├── 时间成本估算
|
||||||
|
└── 总体可行性得分 (0-100)
|
||||||
|
|
||||||
|
3. 差异化建议
|
||||||
|
├── 现有竞品不足之处
|
||||||
|
├── 你的独特切入点
|
||||||
|
└── MVP 功能建议
|
||||||
|
|
||||||
|
4. 变现路径
|
||||||
|
├── 建议定价
|
||||||
|
├── 收入模型
|
||||||
|
└── 预估时间线
|
||||||
|
|
||||||
|
5. 风险提示
|
||||||
|
├── 主要风险点
|
||||||
|
└── 应对建议
|
||||||
|
|
||||||
|
6. 行动建议
|
||||||
|
├── 是否值得继续
|
||||||
|
├── 下一步行动清单
|
||||||
|
└── 推荐验证方式
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 数据模型
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface IdeaAnalysis {
|
||||||
|
// 市场分析
|
||||||
|
market: {
|
||||||
|
size: string // "中小型市场,预计月搜索量 10K-50K"
|
||||||
|
targetUsers: string[] // ["内容创作者", "营销人员", "自由职业者"]
|
||||||
|
painPoints: string[] // 用户痛点
|
||||||
|
competitors: Competitor[] // 竞品列表
|
||||||
|
}
|
||||||
|
|
||||||
|
// 可行性
|
||||||
|
feasibility: {
|
||||||
|
technicalDifficulty: 1 | 2 | 3 | 4 | 5
|
||||||
|
fundRequirement: 'low' | 'medium' | 'high'
|
||||||
|
timeEstimate: string // "2-4周可完成 MVP"
|
||||||
|
overallScore: number // 0-100
|
||||||
|
}
|
||||||
|
|
||||||
|
// 差异化
|
||||||
|
differentiation: {
|
||||||
|
competitorGaps: string[] // 竞品不足
|
||||||
|
uniqueAngle: string // 你的切入点
|
||||||
|
mvpFeatures: string[] // 建议 MVP 功能
|
||||||
|
}
|
||||||
|
|
||||||
|
// 变现
|
||||||
|
monetization: {
|
||||||
|
pricingModel: 'subscription' | 'one-time' | 'freemium' | 'usage-based'
|
||||||
|
suggestedPrice: { min: number, max: number, currency: string }
|
||||||
|
revenueModel: string // 收入模型说明
|
||||||
|
timeline: string // 预估盈利时间
|
||||||
|
}
|
||||||
|
|
||||||
|
// 风险
|
||||||
|
risks: {
|
||||||
|
main: string // 主要风险
|
||||||
|
mitigation: string // 应对建议
|
||||||
|
}[]
|
||||||
|
|
||||||
|
// 行动建议
|
||||||
|
recommendation: {
|
||||||
|
shouldProceed: boolean // 是否建议继续
|
||||||
|
confidence: number // 信心指数 0-100
|
||||||
|
nextSteps: string[] // 下一步行动
|
||||||
|
validationMethod: string // 推荐验证方式
|
||||||
|
}
|
||||||
|
|
||||||
|
analyzedAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Competitor {
|
||||||
|
name: string
|
||||||
|
url?: string
|
||||||
|
description: string
|
||||||
|
strengths: string[]
|
||||||
|
weaknesses: string[]
|
||||||
|
pricing?: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### AI Prompt 设计
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const ANALYSIS_PROMPT = `
|
||||||
|
你是一位资深的创业顾问和产品经理。请对以下 idea 进行深度分析:
|
||||||
|
|
||||||
|
## Idea 信息
|
||||||
|
- 名称:{name}
|
||||||
|
- 描述:{description}
|
||||||
|
- 分类:{category}
|
||||||
|
- 目标关键词:{keywords}
|
||||||
|
|
||||||
|
## 请输出以下内容:
|
||||||
|
|
||||||
|
### 1. 市场分析
|
||||||
|
- 市场规模(基于你的知识判断)
|
||||||
|
- 目标用户画像(3-5类)
|
||||||
|
- 用户痛点(3-5个)
|
||||||
|
- 主要竞品(3-5个,包括名称、简介、优缺点、定价)
|
||||||
|
|
||||||
|
### 2. 可行性评估
|
||||||
|
- 技术难度(1-5星)
|
||||||
|
- 资金需求(低/中/高)
|
||||||
|
- 时间成本估算
|
||||||
|
- 总体可行性得分(0-100分)
|
||||||
|
|
||||||
|
### 3. 差异化建议
|
||||||
|
- 竞品的不足之处
|
||||||
|
- 建议的独特切入点
|
||||||
|
- MVP 核心功能建议(3-5个)
|
||||||
|
|
||||||
|
### 4. 变现路径
|
||||||
|
- 建议定价模式
|
||||||
|
- 价格区间
|
||||||
|
- 收入模型
|
||||||
|
- 预估盈利时间
|
||||||
|
|
||||||
|
### 5. 风险提示
|
||||||
|
- 主要风险(2-3个)
|
||||||
|
- 应对建议
|
||||||
|
|
||||||
|
### 6. 行动建议
|
||||||
|
- 是否值得继续(是/否)
|
||||||
|
- 信心指数(0-100)
|
||||||
|
- 下一步行动清单
|
||||||
|
- 推荐验证方式
|
||||||
|
|
||||||
|
请以 JSON 格式输出。
|
||||||
|
`
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.3 设计文档生成
|
||||||
|
|
||||||
|
#### 文档结构
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface DesignDocument {
|
||||||
|
id: string
|
||||||
|
ideaId: string
|
||||||
|
|
||||||
|
// 基本信息
|
||||||
|
projectName: string
|
||||||
|
projectSlug: string // URL 友好的名称
|
||||||
|
tagline: string // 一句话介绍
|
||||||
|
|
||||||
|
// 网站类型
|
||||||
|
siteType: 'waitlist' | 'landing' | 'pricing' | 'blog' | 'portfolio'
|
||||||
|
|
||||||
|
// 目标
|
||||||
|
primaryGoal: 'collect_emails' | 'show_product' | 'sell' | 'content'
|
||||||
|
|
||||||
|
// 页面结构
|
||||||
|
pages: PageDefinition[]
|
||||||
|
|
||||||
|
// 设计规范
|
||||||
|
design: {
|
||||||
|
colorScheme: ColorScheme
|
||||||
|
typography: Typography
|
||||||
|
components: ComponentSpec[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内容
|
||||||
|
content: {
|
||||||
|
hero: HeroSection
|
||||||
|
features: FeatureSection
|
||||||
|
pricing?: PricingSection
|
||||||
|
faq?: FaqSection
|
||||||
|
cta: CtaSection
|
||||||
|
}
|
||||||
|
|
||||||
|
// SEO
|
||||||
|
seo: {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
keywords: string[]
|
||||||
|
ogImage?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 技术建议
|
||||||
|
techStack: {
|
||||||
|
framework: string // 推荐框架
|
||||||
|
hosting: string // 推荐托管
|
||||||
|
analytics: string[] // 分析工具
|
||||||
|
integrations: string[] // 集成建议
|
||||||
|
}
|
||||||
|
|
||||||
|
createdAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PageDefinition {
|
||||||
|
name: string // "首页", "定价页"
|
||||||
|
path: string // "/", "/pricing"
|
||||||
|
sections: string[] // 页面包含的区块
|
||||||
|
purpose: string // 页面目的
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ColorScheme {
|
||||||
|
primary: string // 主色
|
||||||
|
secondary: string // 辅色
|
||||||
|
accent: string // 强调色
|
||||||
|
background: string // 背景色
|
||||||
|
text: string // 文字色
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Typography {
|
||||||
|
headingFont: string
|
||||||
|
bodyFont: string
|
||||||
|
headingSizes: { h1: string, h2: string, h3: string }
|
||||||
|
bodySize: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HeroSection {
|
||||||
|
headline: string
|
||||||
|
subheadline: string
|
||||||
|
cta: {
|
||||||
|
text: string
|
||||||
|
action: string // "signup" | "learn_more" | "get_started"
|
||||||
|
}
|
||||||
|
visual: {
|
||||||
|
type: 'image' | 'video' | 'illustration' | 'screenshot'
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FeatureSection {
|
||||||
|
title: string
|
||||||
|
subtitle?: string
|
||||||
|
features: {
|
||||||
|
icon: string // 图标建议
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PricingSection {
|
||||||
|
plans: {
|
||||||
|
name: string // "Free", "Pro", "Enterprise"
|
||||||
|
price: string // "$0", "$9.99/mo"
|
||||||
|
features: string[]
|
||||||
|
highlighted: boolean // 是否推荐
|
||||||
|
cta: string // 按钮文字
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FaqSection {
|
||||||
|
items: {
|
||||||
|
question: string
|
||||||
|
answer: string
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CtaSection {
|
||||||
|
headline: string
|
||||||
|
description: string
|
||||||
|
buttonText: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 网站类型模板
|
||||||
|
|
||||||
|
| 类型 | 适用场景 | 核心目标 | 页面结构 |
|
||||||
|
|------|----------|----------|----------|
|
||||||
|
| **Waitlist** | 产品未上线 | 收集邮箱 | Hero + Features + Waitlist Form |
|
||||||
|
| **Landing** | 产品展示 | 展示 + 转化 | Hero + Features + Testimonials + CTA |
|
||||||
|
| **Pricing** | SaaS 产品 | 销售订阅 | Hero + Features + Pricing + FAQ |
|
||||||
|
| **Blog** | 内容/SEO | 流量 + 订阅 | Hero + Articles + Newsletter |
|
||||||
|
| **Portfolio** | 个人/作品 | 展示能力 | Hero + Projects + About + Contact |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.4 数据追踪
|
||||||
|
|
||||||
|
#### 追踪内容
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ValidationData {
|
||||||
|
ideaId: string
|
||||||
|
|
||||||
|
// 访问数据
|
||||||
|
traffic: {
|
||||||
|
totalVisits: number
|
||||||
|
uniqueVisitors: number
|
||||||
|
avgDuration: string // "2:34"
|
||||||
|
bounceRate: number // 百分比
|
||||||
|
trend: 'up' | 'down' | 'stable'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转化数据
|
||||||
|
conversion: {
|
||||||
|
emailSignups: number
|
||||||
|
conversionRate: number // 百分比
|
||||||
|
sources: {
|
||||||
|
direct: number
|
||||||
|
organic: number
|
||||||
|
social: number
|
||||||
|
referral: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 时间线
|
||||||
|
timeline: {
|
||||||
|
date: string
|
||||||
|
visits: number
|
||||||
|
signups: number
|
||||||
|
}[]
|
||||||
|
|
||||||
|
lastUpdated: Date
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 数据来源
|
||||||
|
- **Vercel Analytics API** - 访问量、访客数
|
||||||
|
- **自定义追踪** - 表单提交、按钮点击
|
||||||
|
- **UTM 参数** - 流量来源追踪
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、数据流设计
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 数据流 │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌──────────────────┐
|
||||||
|
│ 用户输入 Idea │
|
||||||
|
│ - 名称 │
|
||||||
|
│ - 描述 │
|
||||||
|
│ - 分类 │
|
||||||
|
│ - 关键词 │
|
||||||
|
└────────┬─────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────┐
|
||||||
|
│ AI 分析引擎 │
|
||||||
|
│ (调用 LLM API) │
|
||||||
|
│ │
|
||||||
|
│ 输出: │
|
||||||
|
│ - 市场分析 │
|
||||||
|
│ - 可行性评估 │
|
||||||
|
│ - 差异化建议 │
|
||||||
|
│ - 变现路径 │
|
||||||
|
│ - 风险提示 │
|
||||||
|
│ - 行动建议 │
|
||||||
|
└──────────────┬───────────────┘
|
||||||
|
│
|
||||||
|
┌──────────────┴───────────────┐
|
||||||
|
│ 用户决定是否继续 │
|
||||||
|
└──────────────┬───────────────┘
|
||||||
|
│ 继续
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────┐
|
||||||
|
│ 选择网站类型 + 目标 │
|
||||||
|
│ - Waitlist / Landing / ... │
|
||||||
|
│ - 收集邮箱 / 展示 / 销售 │
|
||||||
|
└──────────────┬───────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────┐
|
||||||
|
│ 生成设计文档 │
|
||||||
|
│ │
|
||||||
|
│ 包含: │
|
||||||
|
│ - 页面结构 │
|
||||||
|
│ - 设计规范 │
|
||||||
|
│ - 内容文案 │
|
||||||
|
│ - SEO 配置 │
|
||||||
|
│ - 技术建议 │
|
||||||
|
└──────────────┬───────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────┐
|
||||||
|
│ 导出设计文档 │
|
||||||
|
│ - Markdown 格式 │
|
||||||
|
│ - JSON 格式 │
|
||||||
|
│ - 复制到剪贴板 │
|
||||||
|
└──────────────┬───────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────┐
|
||||||
|
│ 你自己构建和部署 │
|
||||||
|
│ (使用 v0 / Cursor / 等) │
|
||||||
|
└──────────────┬───────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────┐
|
||||||
|
│ 录入部署 URL │
|
||||||
|
│ 系统开始追踪数据 │
|
||||||
|
└──────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、技术架构
|
||||||
|
|
||||||
|
### 5.1 技术栈
|
||||||
|
|
||||||
|
| 层级 | 技术 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 桌面端 | Electron | 现有 |
|
||||||
|
| 前端 | React + TypeScript | 现有 |
|
||||||
|
| 样式 | Tailwind CSS | 现有 |
|
||||||
|
| 后端 | Express | 现有 |
|
||||||
|
| 数据库 | SQLite | 本地存储 |
|
||||||
|
| AI | OpenAI API / Anthropic API | LLM 调用 |
|
||||||
|
| 数据 | Vercel Analytics API | 访问数据 |
|
||||||
|
|
||||||
|
### 5.2 模块结构
|
||||||
|
|
||||||
|
```
|
||||||
|
api/modules/idea-validator/
|
||||||
|
├── index.ts # 模块导出
|
||||||
|
├── routes.ts # API 路由
|
||||||
|
├── service.ts # 业务逻辑
|
||||||
|
├── analyzer.ts # AI 分析逻辑
|
||||||
|
├── generator.ts # 设计文档生成
|
||||||
|
├── tracker.ts # 数据追踪
|
||||||
|
├── prompts.ts # AI Prompt 模板
|
||||||
|
├── types.ts # 类型定义
|
||||||
|
└── db/
|
||||||
|
├── ideas.ts # Idea 数据操作
|
||||||
|
├── analysis.ts # 分析结果存储
|
||||||
|
└── documents.ts # 设计文档存储
|
||||||
|
|
||||||
|
src/modules/idea-validator/
|
||||||
|
├── index.ts
|
||||||
|
├── pages/
|
||||||
|
│ └── IdeaValidator.tsx # 主面板
|
||||||
|
├── components/
|
||||||
|
│ ├── IdeaList.tsx # Idea 列表
|
||||||
|
│ ├── IdeaCard.tsx # Idea 卡片
|
||||||
|
│ ├── IdeaForm.tsx # 创建/编辑表单
|
||||||
|
│ ├── AnalysisView.tsx # 分析结果展示
|
||||||
|
│ ├── DesignDocView.tsx # 设计文档预览
|
||||||
|
│ ├── ExportPanel.tsx # 导出面板
|
||||||
|
│ ├── ValidationChart.tsx # 验证数据图表
|
||||||
|
│ └── Recommendation.tsx # AI 建议展示
|
||||||
|
└── stores/
|
||||||
|
└── ideaStore.ts # 状态管理
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、API 设计
|
||||||
|
|
||||||
|
### Idea 管理
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/ideas # 获取所有 Idea
|
||||||
|
POST /api/ideas # 创建 Idea
|
||||||
|
GET /api/ideas/:id # 获取单个 Idea
|
||||||
|
PUT /api/ideas/:id # 更新 Idea
|
||||||
|
DELETE /api/ideas/:id # 删除 Idea
|
||||||
|
```
|
||||||
|
|
||||||
|
### AI 分析
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/ideas/:id/analyze # 执行 AI 分析
|
||||||
|
GET /api/ideas/:id/analysis # 获取分析结果
|
||||||
|
```
|
||||||
|
|
||||||
|
### 设计文档
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/ideas/:id/design # 生成设计文档
|
||||||
|
GET /api/ideas/:id/design # 获取设计文档
|
||||||
|
PUT /api/ideas/:id/design # 更新设计文档
|
||||||
|
GET /api/ideas/:id/design/export # 导出文档 (markdown/json)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 数据追踪
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/ideas/:id/deployment # 录入部署信息
|
||||||
|
GET /api/ideas/:id/validation # 获取验证数据
|
||||||
|
POST /api/ideas/:id/sync-analytics # 同步 Vercel 数据
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、页面设计
|
||||||
|
|
||||||
|
### 7.1 主面板
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Idea Validator [+ 新建 Idea] │
|
||||||
|
├─────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌───────────┐ │
|
||||||
|
│ │ 💡 总 Idea │ │ ✅ 已验证 │ │ 🚀 已部署 │ │ 📧 邮箱 │ │
|
||||||
|
│ │ 12 │ │ 3 │ │ 5 │ │ 1,234 │ │
|
||||||
|
│ └─────────────┘ └─────────────┘ └─────────────┘ └───────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 我的 Ideas 筛选 ▾ │ │
|
||||||
|
│ │ ───────────────────────────────────────────────────────── │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ 💡 AI 写作助手 │ │
|
||||||
|
│ │ 可行性: 78/100 │ 状态: 🟢 已验证 │ 邮箱: 234 │ │
|
||||||
|
│ │ [查看分析] [设计文档] [验证数据] │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ 💡 PDF 转换工具 │ │
|
||||||
|
│ │ 可行性: 65/100 │ 状态: 🚀 已部署 │ 邮箱: 89 │ │
|
||||||
|
│ │ [查看分析] [设计文档] [验证数据] │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ 💡 时间追踪 App │ │
|
||||||
|
│ │ 可行性: 45/100 │ 状态: 📝 草稿 │ │ │
|
||||||
|
│ │ [继续分析] [删除] │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 创建 Idea 流程
|
||||||
|
|
||||||
|
```
|
||||||
|
Step 1: 基本信息
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 创建新 Idea Step 1/3 │
|
||||||
|
├─────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ Idea 名称 * │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ AI 写作助手 │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ 一句话描述 * │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 帮助内容创作者快速生成高质量文章的 AI 工具 │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ 分类 │
|
||||||
|
│ ○ 工具类 ● SaaS ○ 内容类 ○ 电商 ○ 其他 │
|
||||||
|
│ │
|
||||||
|
│ 目标关键词(按回车添加) │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ AI writing ✕ content generator ✕ 写作工具 ✕ │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ [取消] [下一步 →] │
|
||||||
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
Step 2: AI 分析中
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ AI 正在分析... │
|
||||||
|
├─────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────┐ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ 分析市场中... │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ └─────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ████████░░░░░░░░ 45% │
|
||||||
|
│ │
|
||||||
|
│ 正在分析竞品... │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
Step 3: 分析结果
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 分析结果 [重新分析] │
|
||||||
|
├─────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 🎯 可行性评分 │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ████████████████████████████░░░░░░░░░░ 78/100 │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ 💡 AI 建议:这个 idea 值得尝试! │ │
|
||||||
|
│ │ 信心指数:72% │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 📊 市场分析 │ │
|
||||||
|
│ │ ───────────────────────────────────────────────────────── │ │
|
||||||
|
│ │ 市场规模:中小型,月搜索量约 20K-50K │ │
|
||||||
|
│ │ 目标用户:内容创作者、营销人员、自由职业者 │ │
|
||||||
|
│ │ 用户痛点:写作效率低、灵感枯竭、SEO优化困难 │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 🏆 竞品分析 │ │
|
||||||
|
│ │ ───────────────────────────────────────────────────────── │ │
|
||||||
|
│ │ 1. Jasper AI - 功能强大但价格贵 ($49/月起) │ │
|
||||||
|
│ │ 2. Copy.ai - 简单易用但专业度不够 │ │
|
||||||
|
│ │ 3. Writesonic - 性价比高但中文支持弱 │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ 💡 差异化机会:专注中文市场 + 更低价格 + 更简洁的体验 │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 💰 变现建议 │ │
|
||||||
|
│ │ ───────────────────────────────────────────────────────── │ │
|
||||||
|
│ │ 定价模式:Freemium + 订阅 │ │
|
||||||
|
│ │ 建议价格:免费版 + $9.99/月 Pro版 │ │
|
||||||
|
│ │ 预估盈利:6-12个月 │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ ⚠️ 风险提示 │ │
|
||||||
|
│ │ ───────────────────────────────────────────────────────── │ │
|
||||||
|
│ │ 1. AI 领域竞争激烈,需快速迭代 │ │
|
||||||
|
│ │ 2. 依赖大模型 API,成本需控制 │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ 建议:从细分场景切入,如"小红书文案生成" │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 📋 下一步行动 │ │
|
||||||
|
│ │ ───────────────────────────────────────────────────────── │ │
|
||||||
|
│ │ ✅ 选择一个细分场景(如小红书文案) │ │
|
||||||
|
│ │ ✅ 生成设计文档 │ │
|
||||||
|
│ │ ✅ 构建简单落地页,验证需求 │ │
|
||||||
|
│ │ ✅ 收集 100 个邮箱后再开发产品 │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ [放弃] [生成设计文档 →] │
|
||||||
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 设计文档页面
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 设计文档 - AI 写作助手 [导出 ▾] [编辑] │
|
||||||
|
├─────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 基本信息 │ │
|
||||||
|
│ │ ───────────────────────────────────────────────────────── │ │
|
||||||
|
│ │ 项目名称:AIWriter │ │
|
||||||
|
│ │ 项目 Slug:ai-writer │ │
|
||||||
|
│ │ 标语:让写作更简单,让创作更高效 │ │
|
||||||
|
│ │ 网站类型:Waitlist (收集邮箱) │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 页面结构 │ │
|
||||||
|
│ │ ───────────────────────────────────────────────────────── │ │
|
||||||
|
│ │ 首页 (/) │ │
|
||||||
|
│ │ ├── Hero Section (主视觉区) │ │
|
||||||
|
│ │ ├── Features Section (功能特点) │ │
|
||||||
|
│ │ ├── How It Works (使用流程) │ │
|
||||||
|
│ │ ├── Waitlist Form (邮箱收集) │ │
|
||||||
|
│ │ └── Footer (页脚) │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Hero Section │ │
|
||||||
|
│ │ ───────────────────────────────────────────────────────── │ │
|
||||||
|
│ │ 主标题:AI 驱动的智能写作助手 │ │
|
||||||
|
│ │ 副标题:告别写作焦虑,3分钟生成高质量文章 │ │
|
||||||
|
│ │ CTA 按钮:加入等待名单 │ │
|
||||||
|
│ │ 视觉元素:产品截图 / 动态演示 │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Features │ │
|
||||||
|
│ │ ───────────────────────────────────────────────────────── │ │
|
||||||
|
│ │ 1. 🚀 快速生成 - 3秒内生成完整文章 │ │
|
||||||
|
│ │ 2. 🎯 SEO优化 - 自动优化关键词和结构 │ │
|
||||||
|
│ │ 3. 🌍 多语言 - 支持10+种语言 │ │
|
||||||
|
│ │ 4. ✏️ 风格定制 - 适配你的写作风格 │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 设计规范 │ │
|
||||||
|
│ │ ───────────────────────────────────────────────────────── │ │
|
||||||
|
│ │ 主色:#6366F1 (Indigo) │ │
|
||||||
|
│ │ 辅色:#EC4899 (Pink) │ │
|
||||||
|
│ │ 背景:#FFFFFF / #F8FAFC │ │
|
||||||
|
│ │ 字体:Inter (英文) / Noto Sans SC (中文) │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ SEO 配置 │ │
|
||||||
|
│ │ ───────────────────────────────────────────────────────── │ │
|
||||||
|
│ │ Title: AI 写作助手 - 智能内容生成工具 │ │
|
||||||
|
│ │ Description: AI 驱动的写作助手,帮助内容创作者... │ │
|
||||||
|
│ │ Keywords: AI写作, 内容生成, 文案工具, ... │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 技术建议 │ │
|
||||||
|
│ │ ───────────────────────────────────────────────────────── │ │
|
||||||
|
│ │ 框架:Next.js 14 (App Router) │ │
|
||||||
|
│ │ 托管:Vercel │ │
|
||||||
|
│ │ 样式:Tailwind CSS │ │
|
||||||
|
│ │ 分析:Vercel Analytics + 自定义追踪 │ │
|
||||||
|
│ │ 邮箱收集:Resend / ConvertKit │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.4 导出面板
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 导出设计文档 │
|
||||||
|
├─────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ 格式选择: │
|
||||||
|
│ ● Markdown ○ JSON ○ PDF │
|
||||||
|
│ │
|
||||||
|
│ 包含内容: │
|
||||||
|
│ ☑ 基本信息 │
|
||||||
|
│ ☑ 页面结构 │
|
||||||
|
│ ☑ 内容文案 │
|
||||||
|
│ ☑ 设计规范 │
|
||||||
|
│ ☑ SEO 配置 │
|
||||||
|
│ ☑ 技术建议 │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 预览 │ │
|
||||||
|
│ │ ───────────────────────────────────────────────────────── │ │
|
||||||
|
│ │ # AI Writer - 设计文档 │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ## 基本信息 │ │
|
||||||
|
│ │ - 项目名称:AIWriter │ │
|
||||||
|
│ │ - 标语:让写作更简单,让创作更高效 │ │
|
||||||
|
│ │ ... │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ [复制到剪贴板] [下载文件] [发送到邮箱] │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.5 验证数据页面
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 验证数据 - AI 写作助手 [同步数据] [设置] │
|
||||||
|
├─────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ 部署 URL:https://ai-writer.vercel.app [打开] [编辑] │
|
||||||
|
│ 部署时间:2024-03-10 │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌───────────┐ │
|
||||||
|
│ │ 👁️ 总访问 │ │ 👥 独立访客 │ │ 📧 邮箱 │ │ 📈 转化率 │ │
|
||||||
|
│ │ 1,234 │ │ 892 │ │ 234 │ │ 26.2% │ │
|
||||||
|
│ │ ↑ 12% │ │ ↑ 8% │ │ ↑ 15% │ │ ↑ 2.1% │ │
|
||||||
|
│ └─────────────┘ └─────────────┘ └─────────────┘ └───────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 访问趋势 (最近7天) │ │
|
||||||
|
│ │ ───────────────────────────────────────────────────────── │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ 200 ┤ │ │
|
||||||
|
│ │ 150 ┤ ╭──╮ │ │
|
||||||
|
│ │ 100 ┤ ╭──╯ ╰──╮ │ │
|
||||||
|
│ │ 50 ┤───╯ ╰───╮ │ │
|
||||||
|
│ │ 0 ┼──────────────────╰─── │ │
|
||||||
|
│ │ Mon Tue Wed Thu Fri Sat Sun │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 流量来源 │ │
|
||||||
|
│ │ ───────────────────────────────────────────────────────── │ │
|
||||||
|
│ │ Direct ████████████████████░░░░░░░░░░░░░░░░░░░░ 45% │ │
|
||||||
|
│ │ Social ██████████████░░░░░░░░░░░░░░░░░░░░░░░░░░ 30% │ │
|
||||||
|
│ │ Organic ████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 20% │ │
|
||||||
|
│ │ Referral ████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 5% │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 📊 验证评估 │ │
|
||||||
|
│ │ ───────────────────────────────────────────────────────── │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ 当前数据表明这个 idea 有一定潜力: │ │
|
||||||
|
│ │ ✅ 转化率 26% 高于行业平均 (15-20%) │ │
|
||||||
|
│ │ ✅ 流量稳步增长 │ │
|
||||||
|
│ │ ⚠️ 社交媒体流量占比较高,需关注长期留存 │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ 建议: │ │
|
||||||
|
│ │ • 继续收集数据,目标 500+ 邮箱 │ │
|
||||||
|
│ │ • 开始开发 MVP 核心功能 │ │
|
||||||
|
│ │ • 尝试 SEO 获取更多自然流量 │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、实施计划
|
||||||
|
|
||||||
|
### 第一阶段:核心功能(2周)
|
||||||
|
|
||||||
|
**Week 1**
|
||||||
|
- [ ] Idea CRUD(创建/读取/更新/删除)
|
||||||
|
- [ ] SQLite 数据存储
|
||||||
|
- [ ] 基础 UI 组件
|
||||||
|
|
||||||
|
**Week 2**
|
||||||
|
- [ ] AI 分析功能
|
||||||
|
- [ ] 分析结果展示
|
||||||
|
- [ ] 设计文档生成(基础版)
|
||||||
|
|
||||||
|
### 第二阶段:完善功能(2周)
|
||||||
|
|
||||||
|
**Week 3**
|
||||||
|
- [ ] 设计文档编辑
|
||||||
|
- [ ] 导出功能(Markdown/JSON)
|
||||||
|
- [ ] 网站类型模板优化
|
||||||
|
|
||||||
|
**Week 4**
|
||||||
|
- [ ] Vercel 数据追踪集成
|
||||||
|
- [ ] 验证数据展示
|
||||||
|
- [ ] 数据同步功能
|
||||||
|
|
||||||
|
### 第三阶段:优化迭代(按需)
|
||||||
|
|
||||||
|
- [ ] 更丰富的分析维度
|
||||||
|
- [ ] 设计文档模板库
|
||||||
|
- [ ] 数据可视化增强
|
||||||
|
- [ ] AI 建议优化
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、配置需求
|
||||||
|
|
||||||
|
### AI 服务配置
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 配置项
|
||||||
|
interface AIConfig {
|
||||||
|
provider: 'openai' | 'anthropic' | 'azure'
|
||||||
|
apiKey: string
|
||||||
|
model: string // 'gpt-4' | 'claude-3-opus' 等
|
||||||
|
maxTokens: number
|
||||||
|
temperature: number
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vercel 配置
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Vercel API 配置
|
||||||
|
interface VercelConfig {
|
||||||
|
apiToken: string // Vercel API Token
|
||||||
|
teamId?: string // 团队 ID(可选)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、后续扩展方向
|
||||||
|
|
||||||
|
### 可选功能(按需添加)
|
||||||
|
|
||||||
|
1. **多语言支持** - 分析结果和设计文档多语言
|
||||||
|
2. **设计文档版本** - 保存多个版本,对比选择
|
||||||
|
3. **团队协作** - 分享 idea 和分析结果
|
||||||
|
4. **模板市场** - 用户分享设计文档模板
|
||||||
|
5. **自动化建议** - 基于数据自动给出优化建议
|
||||||
|
6. **竞品监控** - 定期更新竞品信息
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*文档版本:v2.0*
|
||||||
|
*最后更新:2024-03-12*
|
||||||
462
electron/main.ts
462
electron/main.ts
@@ -5,6 +5,10 @@ import fs from 'fs';
|
|||||||
import log from 'electron-log';
|
import log from 'electron-log';
|
||||||
import { generatePdf } from './services/pdfGenerator';
|
import { generatePdf } from './services/pdfGenerator';
|
||||||
import { selectHtmlFile } from './services/htmlImport';
|
import { selectHtmlFile } from './services/htmlImport';
|
||||||
|
import { opencodeService } from './services/opencodeService';
|
||||||
|
import { xcOpenCodeWebService } from './services/xcOpenCodeWebService';
|
||||||
|
import { sddService } from './services/sddService';
|
||||||
|
import { terminalService } from './services/terminalService';
|
||||||
import { electronState } from './state';
|
import { electronState } from './state';
|
||||||
|
|
||||||
log.initialize();
|
log.initialize();
|
||||||
@@ -12,24 +16,23 @@ log.initialize();
|
|||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
process.env.NOTEBOOK_ROOT = path.join(app.getPath('documents'), 'XCNote');
|
|
||||||
|
|
||||||
if (!fs.existsSync(process.env.NOTEBOOK_ROOT)) {
|
|
||||||
try {
|
|
||||||
fs.mkdirSync(process.env.NOTEBOOK_ROOT, { recursive: true });
|
|
||||||
} catch (err) {
|
|
||||||
log.error('Failed to create notebook directory:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
electronState.setDevelopment(!app.isPackaged);
|
electronState.setDevelopment(!app.isPackaged);
|
||||||
|
|
||||||
let lastClipboardText = '';
|
let lastClipboardText = '';
|
||||||
|
let clipboardWatcherTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
function stopClipboardWatcher() {
|
||||||
|
if (clipboardWatcherTimer) {
|
||||||
|
clearInterval(clipboardWatcherTimer);
|
||||||
|
clipboardWatcherTimer = null;
|
||||||
|
log.info('[ClipboardWatcher] Stopped');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function startClipboardWatcher() {
|
function startClipboardWatcher() {
|
||||||
lastClipboardText = clipboard.readText();
|
lastClipboardText = clipboard.readText();
|
||||||
|
|
||||||
setInterval(() => {
|
clipboardWatcherTimer = setInterval(() => {
|
||||||
try {
|
try {
|
||||||
const currentText = clipboard.readText();
|
const currentText = clipboard.readText();
|
||||||
if (currentText && currentText !== lastClipboardText) {
|
if (currentText && currentText !== lastClipboardText) {
|
||||||
@@ -50,8 +53,8 @@ async function createWindow() {
|
|||||||
const initialSymbolColor = nativeTheme.shouldUseDarkColors ? '#ffffff' : '#000000';
|
const initialSymbolColor = nativeTheme.shouldUseDarkColors ? '#ffffff' : '#000000';
|
||||||
|
|
||||||
const mainWindow = new BrowserWindow({
|
const mainWindow = new BrowserWindow({
|
||||||
width: 1280,
|
width: 1600,
|
||||||
height: 800,
|
height: 900,
|
||||||
minWidth: 1600,
|
minWidth: 1600,
|
||||||
minHeight: 900,
|
minHeight: 900,
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
@@ -95,6 +98,65 @@ async function createWindow() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createSecondaryWindow(tabData: { route: string; title: string }): Promise<number> {
|
||||||
|
const initialSymbolColor = nativeTheme.shouldUseDarkColors ? '#ffffff' : '#000000';
|
||||||
|
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
width: 1200,
|
||||||
|
height: 800,
|
||||||
|
minWidth: 800,
|
||||||
|
minHeight: 600,
|
||||||
|
autoHideMenuBar: true,
|
||||||
|
titleBarStyle: 'hidden',
|
||||||
|
titleBarOverlay: {
|
||||||
|
color: '#00000000',
|
||||||
|
symbolColor: initialSymbolColor,
|
||||||
|
height: 32,
|
||||||
|
},
|
||||||
|
webPreferences: {
|
||||||
|
nodeIntegration: false,
|
||||||
|
contextIsolation: true,
|
||||||
|
sandbox: false,
|
||||||
|
webviewTag: true,
|
||||||
|
preload: path.join(__dirname, 'preload.cjs'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
electronState.addWindow(win);
|
||||||
|
win.setMenu(null);
|
||||||
|
|
||||||
|
win.on('closed', () => {
|
||||||
|
electronState.removeWindow(win.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
|
if (url.startsWith('http:') || url.startsWith('https:')) {
|
||||||
|
shell.openExternal(url);
|
||||||
|
return { action: 'deny' };
|
||||||
|
}
|
||||||
|
return { action: 'allow' };
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseUrl = electronState.isDevelopment()
|
||||||
|
? 'http://localhost:5173'
|
||||||
|
: `http://localhost:${electronState.getServerPort()}`;
|
||||||
|
|
||||||
|
const fullUrl = `${baseUrl}${tabData.route}`;
|
||||||
|
log.info(`[PopOut] Loading secondary window with URL: ${fullUrl}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await win.loadURL(fullUrl);
|
||||||
|
} catch (e) {
|
||||||
|
log.error('[PopOut] Failed to load URL:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (electronState.isDevelopment()) {
|
||||||
|
win.webContents.openDevTools();
|
||||||
|
}
|
||||||
|
|
||||||
|
return win.id;
|
||||||
|
}
|
||||||
|
|
||||||
ipcMain.handle('export-pdf', async (event, title, htmlContent) => {
|
ipcMain.handle('export-pdf', async (event, title, htmlContent) => {
|
||||||
const win = BrowserWindow.fromWebContents(event.sender);
|
const win = BrowserWindow.fromWebContents(event.sender);
|
||||||
if (!win) return { success: false, error: 'No window found' };
|
if (!win) return { success: false, error: 'No window found' };
|
||||||
@@ -156,6 +218,343 @@ ipcMain.handle('clipboard-write-text', async (event, text: string) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('remote-fetch-drives', async (_event, serverHost: string, port: number, password?: string) => {
|
||||||
|
try {
|
||||||
|
let url = `http://${serverHost}:${port}/api/files/drives`;
|
||||||
|
if (password) {
|
||||||
|
url += `?password=${encodeURIComponent(password)}`;
|
||||||
|
}
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch drives: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
const items = data.items || [];
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: items.map((item: { name: string; isDirectory: boolean; size: number }) => ({
|
||||||
|
name: item.name,
|
||||||
|
path: item.name,
|
||||||
|
type: item.isDirectory ? 'dir' : 'file',
|
||||||
|
size: item.size,
|
||||||
|
modified: '',
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
log.error('Remote fetch drives failed:', error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('remote-fetch-files', async (_event, serverHost: string, port: number, filePath: string, password?: string) => {
|
||||||
|
try {
|
||||||
|
let url = `http://${serverHost}:${port}/api/files/browse?path=${encodeURIComponent(filePath)}&allowSystem=true`;
|
||||||
|
if (password) {
|
||||||
|
url += `&password=${encodeURIComponent(password)}`;
|
||||||
|
}
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch files: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
const items = data.items || [];
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: items.map((item: { name: string; isDirectory: boolean; size: number; modified: Date }) => ({
|
||||||
|
name: item.name,
|
||||||
|
path: data.currentPath ? `${data.currentPath}/${item.name}` : item.name,
|
||||||
|
type: item.isDirectory ? 'dir' : 'file',
|
||||||
|
size: item.size,
|
||||||
|
modified: item.modified?.toString(),
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
log.error('Remote fetch files failed:', error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('remote-upload-file', async (_event, id: string, serverHost: string, port: number, filePath: string, remotePath: string, password?: string) => {
|
||||||
|
try {
|
||||||
|
const win = electronState.getMainWindow();
|
||||||
|
if (!win) {
|
||||||
|
throw new Error('No window found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullPath = path.resolve(filePath);
|
||||||
|
if (!fs.existsSync(fullPath)) {
|
||||||
|
throw new Error('File not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = fs.statSync(fullPath);
|
||||||
|
const fileSize = stats.size;
|
||||||
|
const fileName = path.basename(fullPath);
|
||||||
|
|
||||||
|
let url = `http://${serverHost}:${port}/api/files/upload/start`;
|
||||||
|
if (password) {
|
||||||
|
url += `?password=${encodeURIComponent(password)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startResponse = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ filename: fileName, fileSize }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!startResponse.ok) {
|
||||||
|
throw new Error(`Failed to start upload: ${startResponse.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { fileId, chunkSize } = await startResponse.json();
|
||||||
|
const CHUNK_SIZE = chunkSize || (64 * 1024);
|
||||||
|
const totalChunks = Math.ceil(fileSize / CHUNK_SIZE);
|
||||||
|
|
||||||
|
const readStream = fs.createReadStream(fullPath, { highWaterMark: CHUNK_SIZE });
|
||||||
|
let chunkIndex = 0;
|
||||||
|
let uploadedBytes = 0;
|
||||||
|
|
||||||
|
for await (const chunk of readStream) {
|
||||||
|
const formData = new FormData();
|
||||||
|
const blob = new Blob([chunk]);
|
||||||
|
formData.append('chunk', blob, fileName);
|
||||||
|
formData.append('fileId', fileId);
|
||||||
|
formData.append('chunkIndex', chunkIndex.toString());
|
||||||
|
|
||||||
|
const chunkUrl = `http://${serverHost}:${port}/api/files/upload/chunk${password ? `?password=${encodeURIComponent(password)}` : ''}`;
|
||||||
|
const chunkResponse = await fetch(chunkUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!chunkResponse.ok) {
|
||||||
|
throw new Error(`Failed to upload chunk ${chunkIndex}: ${chunkResponse.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadedBytes += chunk.length;
|
||||||
|
const progress = Math.round((uploadedBytes / fileSize) * 100);
|
||||||
|
win.webContents.send('upload-progress', { id, progress });
|
||||||
|
|
||||||
|
chunkIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergeUrl = `http://${serverHost}:${port}/api/files/upload/merge${password ? `?password=${encodeURIComponent(password)}` : ''}`;
|
||||||
|
const mergeResponse = await fetch(mergeUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ fileId, totalChunks, filename: fileName, path: remotePath }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!mergeResponse.ok) {
|
||||||
|
throw new Error(`Failed to merge chunks: ${mergeResponse.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error: any) {
|
||||||
|
log.error('Remote upload failed:', error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('remote-download-file', async (_event, id: string, serverHost: string, port: number, fileName: string, remotePath: string, localPath: string, password?: string) => {
|
||||||
|
try {
|
||||||
|
log.info('Remote download params:', { id, serverHost, port, fileName, remotePath, localPath, password });
|
||||||
|
|
||||||
|
const win = electronState.getMainWindow();
|
||||||
|
if (!win) {
|
||||||
|
throw new Error('No window found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullRemotePath = remotePath ? `${remotePath}\\${fileName}` : fileName;
|
||||||
|
let url = `http://${serverHost}:${port}/api/files/${encodeURIComponent(fullRemotePath)}`;
|
||||||
|
if (password) {
|
||||||
|
url += `?password=${encodeURIComponent(password)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Download failed: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentLength = response.headers.get('Content-Length');
|
||||||
|
if (!contentLength) {
|
||||||
|
throw new Error('Server did not return Content-Length');
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalSize = parseInt(contentLength, 10);
|
||||||
|
const targetDir = localPath || 'C:\\';
|
||||||
|
const targetPath = path.join(targetDir, fileName);
|
||||||
|
|
||||||
|
if (!fs.existsSync(targetDir)) {
|
||||||
|
fs.mkdirSync(targetDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileStream = fs.createWriteStream(targetPath);
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
if (!reader) {
|
||||||
|
throw new Error('Failed to get response body reader');
|
||||||
|
}
|
||||||
|
|
||||||
|
let downloadedSize = 0;
|
||||||
|
const CHUNK_SIZE = 64 * 1024;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
downloadedSize += value.length;
|
||||||
|
fileStream.write(value);
|
||||||
|
|
||||||
|
const progress = Math.round((downloadedSize / totalSize) * 100);
|
||||||
|
win.webContents.send('download-progress', { id, progress });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fileStream.end();
|
||||||
|
return { success: true, filePath: targetPath };
|
||||||
|
} catch (error: any) {
|
||||||
|
log.error('Remote download failed:', error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('opencode-get-status', () => {
|
||||||
|
return opencodeService.getStatus();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('opencode-start-server', async () => {
|
||||||
|
return await opencodeService.start();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('opencode-stop-server', async () => {
|
||||||
|
return await opencodeService.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('xc-opencode-web-get-status', () => {
|
||||||
|
return xcOpenCodeWebService.getStatus();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('xc-opencode-web-get-port', () => {
|
||||||
|
return { port: xcOpenCodeWebService.port };
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('xc-opencode-web-start', async () => {
|
||||||
|
return await xcOpenCodeWebService.start();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('xc-opencode-web-stop', async () => {
|
||||||
|
return await xcOpenCodeWebService.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('sdd-get-status', () => {
|
||||||
|
return sddService.getStatus();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('sdd-get-port', () => {
|
||||||
|
return { port: sddService.port };
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('sdd-start', async () => {
|
||||||
|
return await sddService.start();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('sdd-stop', async () => {
|
||||||
|
return await sddService.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('terminal-get-status', () => {
|
||||||
|
return terminalService.getStatus();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('terminal-get-port', () => {
|
||||||
|
return { port: terminalService.port };
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('terminal-start', async () => {
|
||||||
|
return await terminalService.start();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('terminal-stop', async () => {
|
||||||
|
return await terminalService.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('create-window', async (_event, tabData: { route: string; title: string }) => {
|
||||||
|
try {
|
||||||
|
log.info('[PopOut] Creating new window for:', tabData);
|
||||||
|
const windowId = await createSecondaryWindow(tabData);
|
||||||
|
return { success: true, windowId };
|
||||||
|
} catch (error: any) {
|
||||||
|
log.error('[PopOut] Failed to create window:', error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('transfer-tab-data', async (_event, windowId: number, tabData: any) => {
|
||||||
|
try {
|
||||||
|
const win = electronState.getWindow(windowId);
|
||||||
|
if (!win) {
|
||||||
|
return { success: false, error: 'Window not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
if (win.webContents.isLoading()) {
|
||||||
|
win.webContents.once('did-finish-load', () => resolve());
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
setTimeout(resolve, 2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
win.webContents.send('tab-data-received', tabData);
|
||||||
|
log.info('[PopOut] Tab data sent to window:', windowId);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error: any) {
|
||||||
|
log.error('[PopOut] Failed to transfer tab data:', error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('window-minimize', async (event) => {
|
||||||
|
const win = BrowserWindow.fromWebContents(event.sender);
|
||||||
|
if (win) {
|
||||||
|
win.minimize();
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
return { success: false };
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('window-maximize', async (event) => {
|
||||||
|
const win = BrowserWindow.fromWebContents(event.sender);
|
||||||
|
if (win) {
|
||||||
|
if (win.isMaximized()) {
|
||||||
|
win.unmaximize();
|
||||||
|
} else {
|
||||||
|
win.maximize();
|
||||||
|
}
|
||||||
|
return { success: true, isMaximized: win.isMaximized() };
|
||||||
|
}
|
||||||
|
return { success: false };
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('window-close', async (event) => {
|
||||||
|
const win = BrowserWindow.fromWebContents(event.sender);
|
||||||
|
if (win) {
|
||||||
|
win.close();
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
return { success: false };
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('window-is-maximized', async (event) => {
|
||||||
|
const win = BrowserWindow.fromWebContents(event.sender);
|
||||||
|
if (win) {
|
||||||
|
return { success: true, isMaximized: win.isMaximized() };
|
||||||
|
}
|
||||||
|
return { success: false, isMaximized: false };
|
||||||
|
});
|
||||||
|
|
||||||
async function startServer() {
|
async function startServer() {
|
||||||
if (electronState.isDevelopment()) {
|
if (electronState.isDevelopment()) {
|
||||||
log.info('In dev mode, assuming external servers are running.');
|
log.info('In dev mode, assuming external servers are running.');
|
||||||
@@ -181,7 +580,20 @@ async function startServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.whenReady().then(async () => {
|
app.whenReady().then(async () => {
|
||||||
|
process.env.NOTEBOOK_ROOT = path.join(app.getPath('documents'), 'XCDesktop');
|
||||||
|
|
||||||
|
if (!fs.existsSync(process.env.NOTEBOOK_ROOT)) {
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(process.env.NOTEBOOK_ROOT, { recursive: true });
|
||||||
|
} catch (err) {
|
||||||
|
log.error('Failed to create notebook directory:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await startServer();
|
await startServer();
|
||||||
|
|
||||||
|
await opencodeService.start();
|
||||||
|
|
||||||
await createWindow();
|
await createWindow();
|
||||||
|
|
||||||
startClipboardWatcher();
|
startClipboardWatcher();
|
||||||
@@ -211,7 +623,31 @@ app.whenReady().then(async () => {
|
|||||||
|
|
||||||
app.on('window-all-closed', () => {
|
app.on('window-all-closed', () => {
|
||||||
globalShortcut.unregisterAll();
|
globalShortcut.unregisterAll();
|
||||||
|
opencodeService.stop();
|
||||||
|
xcOpenCodeWebService.stop();
|
||||||
|
sddService.stop();
|
||||||
|
terminalService.stop();
|
||||||
|
stopClipboardWatcher();
|
||||||
if (process.platform !== 'darwin') {
|
if (process.platform !== 'darwin') {
|
||||||
app.quit();
|
app.quit();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let isQuitting = false;
|
||||||
|
|
||||||
|
app.on('before-quit', async (event) => {
|
||||||
|
if (isQuitting) return;
|
||||||
|
isQuitting = true;
|
||||||
|
|
||||||
|
log.info('[App] before-quit received, cleaning up...');
|
||||||
|
stopClipboardWatcher();
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
opencodeService.stop(),
|
||||||
|
xcOpenCodeWebService.stop(),
|
||||||
|
sddService.stop(),
|
||||||
|
terminalService.stop()
|
||||||
|
]);
|
||||||
|
|
||||||
|
log.info('[App] All services stopped');
|
||||||
|
});
|
||||||
|
|||||||
@@ -19,6 +19,49 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
ipcRenderer.on('remote-clipboard-auto-sync', handler);
|
ipcRenderer.on('remote-clipboard-auto-sync', handler);
|
||||||
return () => ipcRenderer.removeListener('remote-clipboard-auto-sync', handler);
|
return () => ipcRenderer.removeListener('remote-clipboard-auto-sync', handler);
|
||||||
},
|
},
|
||||||
|
onDownloadProgress: (callback: (data: { progress: number; id: string }) => void) => {
|
||||||
|
const handler = (_event: Electron.IpcRendererEvent, data: { progress: number; id: string }) => callback(data);
|
||||||
|
ipcRenderer.on('download-progress', handler);
|
||||||
|
return () => ipcRenderer.removeListener('download-progress', handler);
|
||||||
|
},
|
||||||
|
onUploadProgress: (callback: (data: { progress: number; id: string }) => void) => {
|
||||||
|
const handler = (_event: Electron.IpcRendererEvent, data: { progress: number; id: string }) => callback(data);
|
||||||
|
ipcRenderer.on('upload-progress', handler);
|
||||||
|
return () => ipcRenderer.removeListener('upload-progress', handler);
|
||||||
|
},
|
||||||
clipboardReadText: () => ipcRenderer.invoke('clipboard-read-text'),
|
clipboardReadText: () => ipcRenderer.invoke('clipboard-read-text'),
|
||||||
clipboardWriteText: (text: string) => ipcRenderer.invoke('clipboard-write-text', text),
|
clipboardWriteText: (text: string) => ipcRenderer.invoke('clipboard-write-text', text),
|
||||||
|
remoteFetchDrives: (serverHost: string, port: number, password?: string) =>
|
||||||
|
ipcRenderer.invoke('remote-fetch-drives', serverHost, port, password),
|
||||||
|
remoteFetchFiles: (serverHost: string, port: number, filePath: string, password?: string) =>
|
||||||
|
ipcRenderer.invoke('remote-fetch-files', serverHost, port, filePath, password),
|
||||||
|
remoteUploadFile: (id: string, serverHost: string, port: number, filePath: string, remotePath: string, password?: string) =>
|
||||||
|
ipcRenderer.invoke('remote-upload-file', id, serverHost, port, filePath, remotePath, password),
|
||||||
|
remoteDownloadFile: (id: string, serverHost: string, port: number, fileName: string, remotePath: string, localPath: string, password?: string) =>
|
||||||
|
ipcRenderer.invoke('remote-download-file', id, serverHost, port, fileName, remotePath, localPath, password),
|
||||||
|
opencodeStartServer: () => ipcRenderer.invoke('opencode-start-server'),
|
||||||
|
opencodeStopServer: () => ipcRenderer.invoke('opencode-stop-server'),
|
||||||
|
xcOpenCodeWebStart: () => ipcRenderer.invoke('xc-opencode-web-start'),
|
||||||
|
xcOpenCodeWebStop: () => ipcRenderer.invoke('xc-opencode-web-stop'),
|
||||||
|
xcOpenCodeWebGetStatus: () => ipcRenderer.invoke('xc-opencode-web-get-status'),
|
||||||
|
xcOpenCodeWebGetPort: () => ipcRenderer.invoke('xc-opencode-web-get-port'),
|
||||||
|
sddStart: () => ipcRenderer.invoke('sdd-start'),
|
||||||
|
sddStop: () => ipcRenderer.invoke('sdd-stop'),
|
||||||
|
sddGetStatus: () => ipcRenderer.invoke('sdd-get-status'),
|
||||||
|
sddGetPort: () => ipcRenderer.invoke('sdd-get-port'),
|
||||||
|
terminalStart: () => ipcRenderer.invoke('terminal-start'),
|
||||||
|
terminalStop: () => ipcRenderer.invoke('terminal-stop'),
|
||||||
|
terminalGetStatus: () => ipcRenderer.invoke('terminal-get-status'),
|
||||||
|
terminalGetPort: () => ipcRenderer.invoke('terminal-get-port'),
|
||||||
|
createWindow: (tabData: { route: string; title: string }) => ipcRenderer.invoke('create-window', tabData),
|
||||||
|
transferTabData: (windowId: number, tabData: any) => ipcRenderer.invoke('transfer-tab-data', windowId, tabData),
|
||||||
|
onTabDataReceived: (callback: (tabData: any) => void) => {
|
||||||
|
const handler = (_event: Electron.IpcRendererEvent, tabData: any) => callback(tabData);
|
||||||
|
ipcRenderer.on('tab-data-received', handler);
|
||||||
|
return () => ipcRenderer.removeListener('tab-data-received', handler);
|
||||||
|
},
|
||||||
|
windowMinimize: () => ipcRenderer.invoke('window-minimize'),
|
||||||
|
windowMaximize: () => ipcRenderer.invoke('window-maximize'),
|
||||||
|
windowClose: () => ipcRenderer.invoke('window-close'),
|
||||||
|
windowIsMaximized: () => ipcRenderer.invoke('window-is-maximized'),
|
||||||
})
|
})
|
||||||
|
|||||||
196
electron/services/opencodeService.ts
Normal file
196
electron/services/opencodeService.ts
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import { spawn, exec, ChildProcess } from 'child_process';
|
||||||
|
import log from 'electron-log';
|
||||||
|
|
||||||
|
const OPENCODE_PORT = 4096;
|
||||||
|
const HEALTH_CHECK_INTERVAL = 10000;
|
||||||
|
const MAX_RESTART_ATTEMPTS = 3;
|
||||||
|
const HEALTH_CHECK_TIMEOUT = 2000;
|
||||||
|
|
||||||
|
class OpenCodeService {
|
||||||
|
private process: ChildProcess | null = null;
|
||||||
|
private healthCheckTimer: NodeJS.Timeout | null = null;
|
||||||
|
private restartAttempts = 0;
|
||||||
|
private _isRunning = false;
|
||||||
|
|
||||||
|
get port(): number {
|
||||||
|
return OPENCODE_PORT;
|
||||||
|
}
|
||||||
|
|
||||||
|
isRunning(): boolean {
|
||||||
|
return this._isRunning;
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatus(): { running: boolean; port: number; restartAttempts: number } {
|
||||||
|
return {
|
||||||
|
running: this._isRunning,
|
||||||
|
port: this.port,
|
||||||
|
restartAttempts: this.restartAttempts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getAuthHeaders(): Record<string, string> {
|
||||||
|
const password = 'xc_opencode_password';
|
||||||
|
const encoded = Buffer.from(`:${password}`).toString('base64');
|
||||||
|
return {
|
||||||
|
'Authorization': `Basic ${encoded}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async checkHealth(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`http://127.0.0.1:${this.port}/session`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: this.getAuthHeaders(),
|
||||||
|
signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT),
|
||||||
|
});
|
||||||
|
return response.ok;
|
||||||
|
} catch (error) {
|
||||||
|
log.warn('[OpenCodeService] Health check failed:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async restart(): Promise<void> {
|
||||||
|
if (this.restartAttempts >= MAX_RESTART_ATTEMPTS) {
|
||||||
|
log.error('[OpenCodeService] Max restart attempts reached, giving up');
|
||||||
|
this._isRunning = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.restartAttempts++;
|
||||||
|
log.info(`[OpenCodeService] Attempting restart (${this.restartAttempts}/${MAX_RESTART_ATTEMPTS})...`);
|
||||||
|
|
||||||
|
await this.stop();
|
||||||
|
await this.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private startHealthCheck(): void {
|
||||||
|
if (this.healthCheckTimer) {
|
||||||
|
clearInterval(this.healthCheckTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.healthCheckTimer = setInterval(async () => {
|
||||||
|
const isHealthy = await this.checkHealth();
|
||||||
|
if (!isHealthy && this._isRunning) {
|
||||||
|
log.warn('[OpenCodeService] Health check failed, attempting restart...');
|
||||||
|
await this.restart();
|
||||||
|
}
|
||||||
|
}, HEALTH_CHECK_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<{ success: boolean; error?: string }> {
|
||||||
|
if (this._isRunning && this.process) {
|
||||||
|
log.info('[OpenCodeService] Already running');
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
log.info('[OpenCodeService] Starting OpenCode server...');
|
||||||
|
|
||||||
|
this.process = spawn('opencode', ['serve'], {
|
||||||
|
stdio: 'pipe',
|
||||||
|
shell: true,
|
||||||
|
detached: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.process.stdout?.on('data', (data) => {
|
||||||
|
log.info(`[OpenCode] ${data}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.process.stderr?.on('data', (data) => {
|
||||||
|
log.error(`[OpenCode error] ${data}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.process.on('error', (err) => {
|
||||||
|
log.error('[OpenCodeService] Process error:', err);
|
||||||
|
this._isRunning = false;
|
||||||
|
this.process = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.process.on('exit', (code) => {
|
||||||
|
log.info(`[OpenCodeService] Process exited with code ${code}`);
|
||||||
|
this._isRunning = false;
|
||||||
|
this.process = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const maxWaitTime = 30000;
|
||||||
|
const checkInterval = 500;
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
while (Date.now() - startTime < maxWaitTime) {
|
||||||
|
const isHealthy = await this.checkHealth();
|
||||||
|
if (isHealthy) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, checkInterval));
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalHealth = await this.checkHealth();
|
||||||
|
if (!finalHealth) {
|
||||||
|
log.warn('[OpenCodeService] Health check failed after max wait time');
|
||||||
|
}
|
||||||
|
|
||||||
|
this._isRunning = true;
|
||||||
|
this.restartAttempts = 0;
|
||||||
|
this.startHealthCheck();
|
||||||
|
|
||||||
|
log.info(`[OpenCodeService] Started successfully on port ${this.port}`);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error: any) {
|
||||||
|
log.error('[OpenCodeService] Failed to start:', error);
|
||||||
|
this._isRunning = false;
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<{ success: boolean; error?: string }> {
|
||||||
|
if (this.healthCheckTimer) {
|
||||||
|
clearInterval(this.healthCheckTimer);
|
||||||
|
this.healthCheckTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.process) {
|
||||||
|
log.info('[OpenCodeService] Not running');
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
log.info('[OpenCodeService] Stopping...');
|
||||||
|
|
||||||
|
const pid = this.process?.pid;
|
||||||
|
const processRef = this.process;
|
||||||
|
this.process = null;
|
||||||
|
this._isRunning = false;
|
||||||
|
|
||||||
|
if (pid && process.platform === 'win32') {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
exec(`taskkill /F /T /PID ${pid}`, (error) => {
|
||||||
|
if (error) {
|
||||||
|
log.warn('[OpenCodeService] taskkill failed, process may already be dead:', error.message);
|
||||||
|
}
|
||||||
|
this.restartAttempts = 0;
|
||||||
|
log.info('[OpenCodeService] Stopped');
|
||||||
|
resolve({ success: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else if (pid && processRef) {
|
||||||
|
processRef.kill('SIGTERM');
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 1000));
|
||||||
|
if (!processRef.killed) {
|
||||||
|
processRef.kill('SIGKILL');
|
||||||
|
}
|
||||||
|
this.restartAttempts = 0;
|
||||||
|
log.info('[OpenCodeService] Stopped');
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
this.restartAttempts = 0;
|
||||||
|
return { success: true };
|
||||||
|
} catch (error: any) {
|
||||||
|
log.error('[OpenCodeService] Failed to stop:', error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const opencodeService = new OpenCodeService();
|
||||||
190
electron/services/sddService.ts
Normal file
190
electron/services/sddService.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import { spawn, exec, ChildProcess } from 'child_process';
|
||||||
|
import { app } from 'electron';
|
||||||
|
import path from 'path';
|
||||||
|
import log from 'electron-log';
|
||||||
|
|
||||||
|
const SDD_PORT = 9998;
|
||||||
|
const HEALTH_CHECK_INTERVAL = 10000;
|
||||||
|
const HEALTH_CHECK_TIMEOUT = 2000;
|
||||||
|
|
||||||
|
class SDDService {
|
||||||
|
private process: ChildProcess | null = null;
|
||||||
|
private processPid: number | null = null;
|
||||||
|
private healthCheckTimer: NodeJS.Timeout | null = null;
|
||||||
|
private _isRunning = false;
|
||||||
|
private _isStarting = false;
|
||||||
|
|
||||||
|
get port(): number {
|
||||||
|
return SDD_PORT;
|
||||||
|
}
|
||||||
|
|
||||||
|
isRunning(): boolean {
|
||||||
|
return this._isRunning;
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatus(): { running: boolean; port: number } {
|
||||||
|
return {
|
||||||
|
running: this._isRunning,
|
||||||
|
port: this.port,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getExePath(): string {
|
||||||
|
const exeName = 'XCSDD.exe';
|
||||||
|
const isPackaged = app.isPackaged;
|
||||||
|
|
||||||
|
let basePath: string;
|
||||||
|
if (isPackaged) {
|
||||||
|
basePath = path.join(process.resourcesPath, 'app.asar.unpacked');
|
||||||
|
} else {
|
||||||
|
basePath = process.cwd();
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.join(basePath, 'services', 'xcsdd', exeName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getExeArgs(): string[] {
|
||||||
|
return ['--port', this.port.toString(), '--headless'];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async checkHealth(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), HEALTH_CHECK_TIMEOUT);
|
||||||
|
|
||||||
|
const response = await fetch(`http://127.0.0.1:${this.port}`, {
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
return response.ok || response.status === 401;
|
||||||
|
} catch (error) {
|
||||||
|
log.warn('[SDDService] Health check failed:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private startHealthCheck(): void {
|
||||||
|
if (this.healthCheckTimer) {
|
||||||
|
clearInterval(this.healthCheckTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.healthCheckTimer = setInterval(async () => {
|
||||||
|
const isHealthy = await this.checkHealth();
|
||||||
|
if (!isHealthy && this._isRunning) {
|
||||||
|
log.warn('[SDDService] Service not responding');
|
||||||
|
}
|
||||||
|
}, HEALTH_CHECK_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<{ success: boolean; error?: string }> {
|
||||||
|
if (this._isRunning && this.process) {
|
||||||
|
log.info('[SDDService] Already running');
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._isStarting) {
|
||||||
|
log.info('[SDDService] Already starting');
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
this._isStarting = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const exePath = this.getExePath();
|
||||||
|
const exeArgs = this.getExeArgs();
|
||||||
|
log.info(`[SDDService] Starting from: ${exePath} with args: ${exeArgs.join(' ')}`);
|
||||||
|
|
||||||
|
this.process = spawn(exePath, exeArgs, {
|
||||||
|
stdio: 'pipe',
|
||||||
|
});
|
||||||
|
|
||||||
|
this.processPid = this.process.pid ?? null;
|
||||||
|
|
||||||
|
this.process.stdout?.on('data', (data) => {
|
||||||
|
log.info(`[SDD] ${data}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.process.stderr?.on('data', (data) => {
|
||||||
|
log.error(`[SDD error] ${data}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.process.on('error', (err) => {
|
||||||
|
log.error('[SDDService] Process error:', err);
|
||||||
|
this._isRunning = false;
|
||||||
|
this._isStarting = false;
|
||||||
|
this.process = null;
|
||||||
|
this.processPid = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.process.on('exit', (code) => {
|
||||||
|
log.info(`[SDDService] Process exited with code ${code}`);
|
||||||
|
this._isRunning = false;
|
||||||
|
this._isStarting = false;
|
||||||
|
this.process = null;
|
||||||
|
this.processPid = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const maxWaitTime = 30000;
|
||||||
|
const checkInterval = 500;
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
while (Date.now() - startTime < maxWaitTime) {
|
||||||
|
const isHealthy = await this.checkHealth();
|
||||||
|
if (isHealthy) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, checkInterval));
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalHealth = await this.checkHealth();
|
||||||
|
if (!finalHealth) {
|
||||||
|
log.warn('[SDDService] Health check failed after max wait time');
|
||||||
|
}
|
||||||
|
|
||||||
|
this._isRunning = true;
|
||||||
|
this._isStarting = false;
|
||||||
|
this.startHealthCheck();
|
||||||
|
|
||||||
|
log.info(`[SDDService] Started successfully on port ${this.port}`);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error: any) {
|
||||||
|
log.error('[SDDService] Failed to start:', error);
|
||||||
|
this._isRunning = false;
|
||||||
|
this._isStarting = false;
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<{ success: boolean; error?: string }> {
|
||||||
|
if (this.healthCheckTimer) {
|
||||||
|
clearInterval(this.healthCheckTimer);
|
||||||
|
this.healthCheckTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.processPid) {
|
||||||
|
log.info('[SDDService] Not running');
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
log.info(`[SDDService] Stopping process ${this.processPid}...`);
|
||||||
|
|
||||||
|
exec(`taskkill /F /T /PID ${this.processPid}`, (error) => {
|
||||||
|
this.process = null;
|
||||||
|
this.processPid = null;
|
||||||
|
this._isRunning = false;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
log.error('[SDDService] Failed to stop:', error);
|
||||||
|
resolve({ success: false, error: error.message });
|
||||||
|
} else {
|
||||||
|
log.info('[SDDService] Stopped');
|
||||||
|
resolve({ success: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sddService = new SDDService();
|
||||||
190
electron/services/terminalService.ts
Normal file
190
electron/services/terminalService.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import { spawn, exec, ChildProcess } from 'child_process';
|
||||||
|
import { app } from 'electron';
|
||||||
|
import path from 'path';
|
||||||
|
import log from 'electron-log';
|
||||||
|
|
||||||
|
const TERMINAL_PORT = 9997;
|
||||||
|
const HEALTH_CHECK_INTERVAL = 10000;
|
||||||
|
const HEALTH_CHECK_TIMEOUT = 2000;
|
||||||
|
|
||||||
|
class TerminalService {
|
||||||
|
private process: ChildProcess | null = null;
|
||||||
|
private processPid: number | null = null;
|
||||||
|
private healthCheckTimer: NodeJS.Timeout | null = null;
|
||||||
|
private _isRunning = false;
|
||||||
|
private _isStarting = false;
|
||||||
|
|
||||||
|
get port(): number {
|
||||||
|
return TERMINAL_PORT;
|
||||||
|
}
|
||||||
|
|
||||||
|
isRunning(): boolean {
|
||||||
|
return this._isRunning;
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatus(): { running: boolean; port: number } {
|
||||||
|
return {
|
||||||
|
running: this._isRunning,
|
||||||
|
port: this.port,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getExePath(): string {
|
||||||
|
const exeName = 'XCTerminal.exe';
|
||||||
|
const isPackaged = app.isPackaged;
|
||||||
|
|
||||||
|
let basePath: string;
|
||||||
|
if (isPackaged) {
|
||||||
|
basePath = path.join(process.resourcesPath, 'app.asar.unpacked');
|
||||||
|
} else {
|
||||||
|
basePath = process.cwd();
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.join(basePath, 'services', 'xcterminal', exeName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getExeArgs(): string[] {
|
||||||
|
return ['--port', this.port.toString()];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async checkHealth(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), HEALTH_CHECK_TIMEOUT);
|
||||||
|
|
||||||
|
const response = await fetch(`http://127.0.0.1:${this.port}`, {
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
return response.ok || response.status === 401;
|
||||||
|
} catch (error) {
|
||||||
|
log.warn('[TerminalService] Health check failed:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private startHealthCheck(): void {
|
||||||
|
if (this.healthCheckTimer) {
|
||||||
|
clearInterval(this.healthCheckTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.healthCheckTimer = setInterval(async () => {
|
||||||
|
const isHealthy = await this.checkHealth();
|
||||||
|
if (!isHealthy && this._isRunning) {
|
||||||
|
log.warn('[TerminalService] Service not responding');
|
||||||
|
}
|
||||||
|
}, HEALTH_CHECK_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<{ success: boolean; error?: string }> {
|
||||||
|
if (this._isRunning && this.process) {
|
||||||
|
log.info('[TerminalService] Already running');
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._isStarting) {
|
||||||
|
log.info('[TerminalService] Already starting');
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
this._isStarting = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const exePath = this.getExePath();
|
||||||
|
const exeArgs = this.getExeArgs();
|
||||||
|
log.info(`[TerminalService] Starting from: ${exePath} with args: ${exeArgs.join(' ')}`);
|
||||||
|
|
||||||
|
this.process = spawn(exePath, exeArgs, {
|
||||||
|
stdio: 'pipe',
|
||||||
|
});
|
||||||
|
|
||||||
|
this.processPid = this.process.pid ?? null;
|
||||||
|
|
||||||
|
this.process.stdout?.on('data', (data) => {
|
||||||
|
log.info(`[Terminal] ${data}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.process.stderr?.on('data', (data) => {
|
||||||
|
log.error(`[Terminal error] ${data}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.process.on('error', (err) => {
|
||||||
|
log.error('[TerminalService] Process error:', err);
|
||||||
|
this._isRunning = false;
|
||||||
|
this._isStarting = false;
|
||||||
|
this.process = null;
|
||||||
|
this.processPid = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.process.on('exit', (code) => {
|
||||||
|
log.info(`[TerminalService] Process exited with code ${code}`);
|
||||||
|
this._isRunning = false;
|
||||||
|
this._isStarting = false;
|
||||||
|
this.process = null;
|
||||||
|
this.processPid = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const maxWaitTime = 30000;
|
||||||
|
const checkInterval = 500;
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
while (Date.now() - startTime < maxWaitTime) {
|
||||||
|
const isHealthy = await this.checkHealth();
|
||||||
|
if (isHealthy) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, checkInterval));
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalHealth = await this.checkHealth();
|
||||||
|
if (!finalHealth) {
|
||||||
|
log.warn('[TerminalService] Health check failed after max wait time');
|
||||||
|
}
|
||||||
|
|
||||||
|
this._isRunning = true;
|
||||||
|
this._isStarting = false;
|
||||||
|
this.startHealthCheck();
|
||||||
|
|
||||||
|
log.info(`[TerminalService] Started successfully on port ${this.port}`);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error: any) {
|
||||||
|
log.error('[TerminalService] Failed to start:', error);
|
||||||
|
this._isRunning = false;
|
||||||
|
this._isStarting = false;
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<{ success: boolean; error?: string }> {
|
||||||
|
if (this.healthCheckTimer) {
|
||||||
|
clearInterval(this.healthCheckTimer);
|
||||||
|
this.healthCheckTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.processPid) {
|
||||||
|
log.info('[TerminalService] Not running');
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
log.info(`[TerminalService] Stopping process ${this.processPid}...`);
|
||||||
|
|
||||||
|
exec(`taskkill /F /T /PID ${this.processPid}`, (error) => {
|
||||||
|
this.process = null;
|
||||||
|
this.processPid = null;
|
||||||
|
this._isRunning = false;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
log.error('[TerminalService] Failed to stop:', error);
|
||||||
|
resolve({ success: false, error: error.message });
|
||||||
|
} else {
|
||||||
|
log.info('[TerminalService] Stopped');
|
||||||
|
resolve({ success: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const terminalService = new TerminalService();
|
||||||
190
electron/services/xcOpenCodeWebService.ts
Normal file
190
electron/services/xcOpenCodeWebService.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import { spawn, exec, ChildProcess } from 'child_process';
|
||||||
|
import { app } from 'electron';
|
||||||
|
import path from 'path';
|
||||||
|
import log from 'electron-log';
|
||||||
|
|
||||||
|
const XCOPENCODEWEB_PORT = 9999;
|
||||||
|
const HEALTH_CHECK_INTERVAL = 10000;
|
||||||
|
const HEALTH_CHECK_TIMEOUT = 2000;
|
||||||
|
|
||||||
|
class XCOpenCodeWebService {
|
||||||
|
private process: ChildProcess | null = null;
|
||||||
|
private processPid: number | null = null;
|
||||||
|
private healthCheckTimer: NodeJS.Timeout | null = null;
|
||||||
|
private _isRunning = false;
|
||||||
|
private _isStarting = false;
|
||||||
|
|
||||||
|
get port(): number {
|
||||||
|
return XCOPENCODEWEB_PORT;
|
||||||
|
}
|
||||||
|
|
||||||
|
isRunning(): boolean {
|
||||||
|
return this._isRunning;
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatus(): { running: boolean; port: number } {
|
||||||
|
return {
|
||||||
|
running: this._isRunning,
|
||||||
|
port: this.port,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getExePath(): string {
|
||||||
|
const exeName = 'XCOpenCodeWeb.exe';
|
||||||
|
const isPackaged = app.isPackaged;
|
||||||
|
|
||||||
|
let basePath: string;
|
||||||
|
if (isPackaged) {
|
||||||
|
basePath = path.join(process.resourcesPath, 'app.asar.unpacked');
|
||||||
|
} else {
|
||||||
|
basePath = process.cwd();
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.join(basePath, 'services', 'xcopencodeweb', exeName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getExeArgs(): string[] {
|
||||||
|
return ['--port', this.port.toString()];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async checkHealth(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), HEALTH_CHECK_TIMEOUT);
|
||||||
|
|
||||||
|
const response = await fetch(`http://127.0.0.1:${this.port}`, {
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
return response.ok || response.status === 401;
|
||||||
|
} catch (error) {
|
||||||
|
log.warn('[XCOpenCodeWebService] Health check failed:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private startHealthCheck(): void {
|
||||||
|
if (this.healthCheckTimer) {
|
||||||
|
clearInterval(this.healthCheckTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.healthCheckTimer = setInterval(async () => {
|
||||||
|
const isHealthy = await this.checkHealth();
|
||||||
|
if (!isHealthy && this._isRunning) {
|
||||||
|
log.warn('[XCOpenCodeWebService] Service not responding');
|
||||||
|
}
|
||||||
|
}, HEALTH_CHECK_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<{ success: boolean; error?: string }> {
|
||||||
|
if (this._isRunning && this.process) {
|
||||||
|
log.info('[XCOpenCodeWebService] Already running');
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._isStarting) {
|
||||||
|
log.info('[XCOpenCodeWebService] Already starting');
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
this._isStarting = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const exePath = this.getExePath();
|
||||||
|
const exeArgs = this.getExeArgs();
|
||||||
|
log.info(`[XCOpenCodeWebService] Starting from: ${exePath} with args: ${exeArgs.join(' ')}`);
|
||||||
|
|
||||||
|
this.process = spawn(exePath, exeArgs, {
|
||||||
|
stdio: 'pipe',
|
||||||
|
});
|
||||||
|
|
||||||
|
this.processPid = this.process.pid ?? null;
|
||||||
|
|
||||||
|
this.process.stdout?.on('data', (data) => {
|
||||||
|
log.info(`[XCOpenCodeWeb] ${data}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.process.stderr?.on('data', (data) => {
|
||||||
|
log.error(`[XCOpenCodeWeb error] ${data}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.process.on('error', (err) => {
|
||||||
|
log.error('[XCOpenCodeWebService] Process error:', err);
|
||||||
|
this._isRunning = false;
|
||||||
|
this._isStarting = false;
|
||||||
|
this.process = null;
|
||||||
|
this.processPid = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.process.on('exit', (code) => {
|
||||||
|
log.info(`[XCOpenCodeWebService] Process exited with code ${code}`);
|
||||||
|
this._isRunning = false;
|
||||||
|
this._isStarting = false;
|
||||||
|
this.process = null;
|
||||||
|
this.processPid = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const maxWaitTime = 30000;
|
||||||
|
const checkInterval = 500;
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
while (Date.now() - startTime < maxWaitTime) {
|
||||||
|
const isHealthy = await this.checkHealth();
|
||||||
|
if (isHealthy) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, checkInterval));
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalHealth = await this.checkHealth();
|
||||||
|
if (!finalHealth) {
|
||||||
|
log.warn('[XCOpenCodeWebService] Health check failed after max wait time');
|
||||||
|
}
|
||||||
|
|
||||||
|
this._isRunning = true;
|
||||||
|
this._isStarting = false;
|
||||||
|
this.startHealthCheck();
|
||||||
|
|
||||||
|
log.info(`[XCOpenCodeWebService] Started successfully on port ${this.port}`);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error: any) {
|
||||||
|
log.error('[XCOpenCodeWebService] Failed to start:', error);
|
||||||
|
this._isRunning = false;
|
||||||
|
this._isStarting = false;
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<{ success: boolean; error?: string }> {
|
||||||
|
if (this.healthCheckTimer) {
|
||||||
|
clearInterval(this.healthCheckTimer);
|
||||||
|
this.healthCheckTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.processPid) {
|
||||||
|
log.info('[XCOpenCodeWebService] Not running');
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
log.info(`[XCOpenCodeWebService] Stopping process ${this.processPid}...`);
|
||||||
|
|
||||||
|
exec(`taskkill /F /PID ${this.processPid}`, (error) => {
|
||||||
|
this.process = null;
|
||||||
|
this.processPid = null;
|
||||||
|
this._isRunning = false;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
log.error('[XCOpenCodeWebService] Failed to stop:', error);
|
||||||
|
resolve({ success: false, error: error.message });
|
||||||
|
} else {
|
||||||
|
log.info('[XCOpenCodeWebService] Stopped');
|
||||||
|
resolve({ success: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const xcOpenCodeWebService = new XCOpenCodeWebService();
|
||||||
@@ -13,6 +13,8 @@ class ElectronState {
|
|||||||
isDev: false,
|
isDev: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private windows = new Map<number, BrowserWindow>()
|
||||||
|
|
||||||
getMainWindow(): BrowserWindow | null {
|
getMainWindow(): BrowserWindow | null {
|
||||||
return this.state.mainWindow
|
return this.state.mainWindow
|
||||||
}
|
}
|
||||||
@@ -37,7 +39,24 @@ class ElectronState {
|
|||||||
this.state.isDev = isDev
|
this.state.isDev = isDev
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addWindow(window: BrowserWindow): void {
|
||||||
|
this.windows.set(window.id, window)
|
||||||
|
}
|
||||||
|
|
||||||
|
removeWindow(id: number): void {
|
||||||
|
this.windows.delete(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
getWindow(id: number): BrowserWindow | undefined {
|
||||||
|
return this.windows.get(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllWindows(): BrowserWindow[] {
|
||||||
|
return Array.from(this.windows.values())
|
||||||
|
}
|
||||||
|
|
||||||
reset(): void {
|
reset(): void {
|
||||||
|
this.windows.clear()
|
||||||
this.state = {
|
this.state = {
|
||||||
mainWindow: null,
|
mainWindow: null,
|
||||||
serverPort: 3001,
|
serverPort: 3001,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>XCNote</title>
|
<title>XCDesktop</title>
|
||||||
<script type="module">
|
<script type="module">
|
||||||
if (import.meta.hot?.on) {
|
if (import.meta.hot?.on) {
|
||||||
import.meta.hot.on('vite:error', (error) => {
|
import.meta.hot.on('vite:error', (error) => {
|
||||||
|
|||||||
591
package-lock.json
generated
591
package-lock.json
generated
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "xcnote",
|
"name": "xcdesktop",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "xcnote",
|
"name": "xcdesktop",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
@@ -20,11 +20,15 @@
|
|||||||
"@milkdown/preset-commonmark": "^7.18.0",
|
"@milkdown/preset-commonmark": "^7.18.0",
|
||||||
"@milkdown/preset-gfm": "^7.18.0",
|
"@milkdown/preset-gfm": "^7.18.0",
|
||||||
"@milkdown/react": "^7.18.0",
|
"@milkdown/react": "^7.18.0",
|
||||||
|
"@opencode-ai/sdk": "^1.2.26",
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@types/jszip": "^3.4.0",
|
"@types/jszip": "^3.4.0",
|
||||||
"@types/multer": "^2.0.0",
|
"@types/multer": "^2.0.0",
|
||||||
"@types/prismjs": "^1.26.5",
|
"@types/prismjs": "^1.26.5",
|
||||||
"axios": "^1.13.5",
|
"axios": "^1.13.5",
|
||||||
"chokidar": "^5.0.0",
|
"chokidar": "^5.0.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^17.2.1",
|
"dotenv": "^17.2.1",
|
||||||
@@ -40,6 +44,7 @@
|
|||||||
"react-router-dom": "^7.3.0",
|
"react-router-dom": "^7.3.0",
|
||||||
"recharts": "^3.7.0",
|
"recharts": "^3.7.0",
|
||||||
"remark-breaks": "^4.0.0",
|
"remark-breaks": "^4.0.0",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
"zod": "^4.3.6",
|
"zod": "^4.3.6",
|
||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
@@ -3305,6 +3310,12 @@
|
|||||||
"url": "https://github.com/sponsors/ocavue"
|
"url": "https://github.com/sponsors/ocavue"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@opencode-ai/sdk": {
|
||||||
|
"version": "1.2.26",
|
||||||
|
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.2.26.tgz",
|
||||||
|
"integrity": "sha512-HPB+0pfvTMPj2KEjNLF3oqgldKW8koTJ7ssqXwzndazqxS+gUynzvdIKIQP4+QIInNcc5nJMG9JtfLcePGgTLQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@pkgjs/parseargs": {
|
"node_modules/@pkgjs/parseargs": {
|
||||||
"version": "0.11.0",
|
"version": "0.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||||
@@ -3323,6 +3334,419 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/primitive": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-alert-dialog": {
|
||||||
|
"version": "1.1.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz",
|
||||||
|
"integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-dialog": "1.1.15",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-slot": "1.2.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-compose-refs": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-context": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-dialog": {
|
||||||
|
"version": "1.1.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
|
||||||
|
"integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-dismissable-layer": "1.1.11",
|
||||||
|
"@radix-ui/react-focus-guards": "1.1.3",
|
||||||
|
"@radix-ui/react-focus-scope": "1.1.7",
|
||||||
|
"@radix-ui/react-id": "1.1.1",
|
||||||
|
"@radix-ui/react-portal": "1.1.9",
|
||||||
|
"@radix-ui/react-presence": "1.1.5",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-slot": "1.2.3",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||||
|
"aria-hidden": "^1.2.4",
|
||||||
|
"react-remove-scroll": "^2.6.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-dismissable-layer": {
|
||||||
|
"version": "1.1.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
|
||||||
|
"integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||||
|
"@radix-ui/react-use-escape-keydown": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-focus-guards": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-focus-scope": {
|
||||||
|
"version": "1.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz",
|
||||||
|
"integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-id": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-portal": {
|
||||||
|
"version": "1.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
|
||||||
|
"integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-presence": {
|
||||||
|
"version": "1.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
|
||||||
|
"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-primitive": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-slot": "1.2.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-use-controllable-state": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-use-effect-event": "0.0.2",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-use-effect-event": {
|
||||||
|
"version": "0.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
|
||||||
|
"integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-use-escape-keydown": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-use-layout-effect": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@reduxjs/toolkit": {
|
"node_modules/@reduxjs/toolkit": {
|
||||||
"version": "2.11.2",
|
"version": "2.11.2",
|
||||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
||||||
@@ -4308,7 +4732,7 @@
|
|||||||
"version": "18.3.7",
|
"version": "18.3.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
||||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -5464,6 +5888,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Python-2.0"
|
"license": "Python-2.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/aria-hidden": {
|
||||||
|
"version": "1.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
|
||||||
|
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/aria-query": {
|
"node_modules/aria-query": {
|
||||||
"version": "5.3.0",
|
"version": "5.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
|
||||||
@@ -6313,6 +6749,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/class-variance-authority": {
|
||||||
|
"version": "0.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
|
||||||
|
"integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"clsx": "^2.1.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://polar.sh/cva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cli-cursor": {
|
"node_modules/cli-cursor": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz",
|
||||||
@@ -7132,6 +7580,12 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"node_modules/detect-node-es": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/devlop": {
|
"node_modules/devlop": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
|
||||||
@@ -8602,6 +9056,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/get-nonce": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/get-proto": {
|
"node_modules/get-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||||
@@ -12680,6 +13143,53 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-remove-scroll": {
|
||||||
|
"version": "2.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
||||||
|
"integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"react-remove-scroll-bar": "^2.3.7",
|
||||||
|
"react-style-singleton": "^2.2.3",
|
||||||
|
"tslib": "^2.1.0",
|
||||||
|
"use-callback-ref": "^1.3.3",
|
||||||
|
"use-sidecar": "^1.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-remove-scroll-bar": {
|
||||||
|
"version": "2.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
|
||||||
|
"integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"react-style-singleton": "^2.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-router": {
|
"node_modules/react-router": {
|
||||||
"version": "7.13.0",
|
"version": "7.13.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.0.tgz",
|
||||||
@@ -12731,6 +13241,28 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-style-singleton": {
|
||||||
|
"version": "2.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
||||||
|
"integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"get-nonce": "^1.0.0",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/read-binary-file-arch": {
|
"node_modules/read-binary-file-arch": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz",
|
||||||
@@ -13943,6 +14475,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/tailwind-merge": {
|
||||||
|
"version": "3.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz",
|
||||||
|
"integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/dcastil"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tailwindcss": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "3.4.19",
|
"version": "3.4.19",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
||||||
@@ -14901,6 +15443,49 @@
|
|||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-callback-ref": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/use-sidecar": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"detect-node-es": "^1.1.0",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/use-sync-external-store": {
|
"node_modules/use-sync-external-store": {
|
||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||||
|
|||||||
22
package.json
22
package.json
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "xcnote",
|
"name": "xcdesktop",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"description": "一个功能强大的本地 Markdown 笔记管理工具,支持时间追踪、任务管理、AI 集成等高级功能",
|
"description": "一站式 AI 工作台 - 集成笔记管理、时间追踪、任务管理、AI 辅助等多种功能",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"markdown",
|
"markdown",
|
||||||
"note",
|
"note",
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
"author": "Your Name",
|
"author": "Your Name",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/your-username/xcnote.git"
|
"url": "https://github.com/your-username/xcdesktop.git"
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "dist-electron/main.js",
|
"main": "dist-electron/main.js",
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
"watch:electron": "tsup --config tsup.electron.ts --watch",
|
"watch:electron": "tsup --config tsup.electron.ts --watch",
|
||||||
"electron:dev": "concurrently -k \"npm run server:dev\" \"npm run client:dev\" \"wait-on tcp:3001 tcp:5173 && npm run watch:electron\"",
|
"electron:dev": "concurrently -k \"npm run server:dev\" \"npm run client:dev\" \"wait-on tcp:3001 tcp:5173 && npm run watch:electron\"",
|
||||||
"build:electron": "tsup --config tsup.electron.ts",
|
"build:electron": "tsup --config tsup.electron.ts",
|
||||||
"build:api": "tsup electron/server.ts --format esm --out-dir dist-api --clean --no-splitting --target esnext",
|
"build:api": "tsup electron/server.ts --format esm --out-dir dist-api --clean --target esnext --external electron",
|
||||||
"electron:build": "npm run build && npm run build:electron && npm run build:api && electron-builder"
|
"electron:build": "npm run build && npm run build:electron && npm run build:api && electron-builder"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -51,16 +51,21 @@
|
|||||||
"@milkdown/preset-commonmark": "^7.18.0",
|
"@milkdown/preset-commonmark": "^7.18.0",
|
||||||
"@milkdown/preset-gfm": "^7.18.0",
|
"@milkdown/preset-gfm": "^7.18.0",
|
||||||
"@milkdown/react": "^7.18.0",
|
"@milkdown/react": "^7.18.0",
|
||||||
|
"@opencode-ai/sdk": "^1.2.26",
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@types/jszip": "^3.4.0",
|
"@types/jszip": "^3.4.0",
|
||||||
"@types/multer": "^2.0.0",
|
"@types/multer": "^2.0.0",
|
||||||
"@types/prismjs": "^1.26.5",
|
"@types/prismjs": "^1.26.5",
|
||||||
"axios": "^1.13.5",
|
"axios": "^1.13.5",
|
||||||
"chokidar": "^5.0.0",
|
"chokidar": "^5.0.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^17.2.1",
|
"dotenv": "^17.2.1",
|
||||||
"electron-log": "^5.4.3",
|
"electron-log": "^5.4.3",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
|
"form-data": "^4.0.5",
|
||||||
"github-slugger": "^2.0.0",
|
"github-slugger": "^2.0.0",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"lucide-react": "^0.511.0",
|
"lucide-react": "^0.511.0",
|
||||||
@@ -71,6 +76,7 @@
|
|||||||
"react-router-dom": "^7.3.0",
|
"react-router-dom": "^7.3.0",
|
||||||
"recharts": "^3.7.0",
|
"recharts": "^3.7.0",
|
||||||
"remark-breaks": "^4.0.0",
|
"remark-breaks": "^4.0.0",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
"zod": "^4.3.6",
|
"zod": "^4.3.6",
|
||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
@@ -111,8 +117,8 @@
|
|||||||
"wait-on": "^9.0.3"
|
"wait-on": "^9.0.3"
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"appId": "com.xcnote.app",
|
"appId": "com.xcdesktop.app",
|
||||||
"productName": "XCNote",
|
"productName": "XCDesktop",
|
||||||
"directories": {
|
"directories": {
|
||||||
"output": "release"
|
"output": "release"
|
||||||
},
|
},
|
||||||
@@ -122,10 +128,12 @@
|
|||||||
"dist-api/**/*",
|
"dist-api/**/*",
|
||||||
"shared/**/*",
|
"shared/**/*",
|
||||||
"tools/**/*",
|
"tools/**/*",
|
||||||
|
"services/**/*",
|
||||||
"package.json"
|
"package.json"
|
||||||
],
|
],
|
||||||
"asarUnpack": [
|
"asarUnpack": [
|
||||||
"tools/**/*"
|
"tools/**/*",
|
||||||
|
"services/**/*"
|
||||||
],
|
],
|
||||||
"win": {
|
"win": {
|
||||||
"target": "nsis"
|
"target": "nsis"
|
||||||
|
|||||||
@@ -23,9 +23,14 @@
|
|||||||
"tokenExpiry": 3600
|
"tokenExpiry": 3600
|
||||||
},
|
},
|
||||||
"frp": {
|
"frp": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"opencode": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"xcopencodeweb": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"frpcPath": "./frp/frpc.exe",
|
"port": 3002
|
||||||
"configPath": "./frp/frpc.toml"
|
|
||||||
},
|
},
|
||||||
"gitea": {
|
"gitea": {
|
||||||
"enabled": true
|
"enabled": true
|
||||||
|
|||||||
@@ -2,22 +2,36 @@ serverAddr = "146.56.248.142"
|
|||||||
serverPort = 7000
|
serverPort = 7000
|
||||||
auth.token = "wzw20040525"
|
auth.token = "wzw20040525"
|
||||||
|
|
||||||
log.to = "C:\\Users\\xuanchi\\Desktop\\remote\\logs\\frpc.log"
|
log.to = "D:\\Xuanchi\\高斯泼溅\\XCDesktop\\remote\\logs\\frpc.log"
|
||||||
log.level = "info"
|
log.level = "info"
|
||||||
log.maxDays = 7
|
log.maxDays = 7
|
||||||
|
|
||||||
[[proxies]]
|
[[proxies]]
|
||||||
name = "remote-desktop"
|
name = "desktop-remote"
|
||||||
type = "tcp"
|
type = "tcp"
|
||||||
localIP = "127.0.0.1"
|
localIP = "127.0.0.1"
|
||||||
localPort = 3000
|
localPort = 3000
|
||||||
remotePort = 8080
|
remotePort = 8080
|
||||||
|
|
||||||
[[proxies]]
|
[[proxies]]
|
||||||
name = "gitea-web"
|
name = "gitea-remote"
|
||||||
type = "tcp"
|
type = "tcp"
|
||||||
localIP = "127.0.0.1"
|
localIP = "127.0.0.1"
|
||||||
localPort = 3001
|
localPort = 3001
|
||||||
remotePort = 8081
|
remotePort = 8081
|
||||||
|
|
||||||
|
[[proxies]]
|
||||||
|
name = "opencode-remote"
|
||||||
|
type = "tcp"
|
||||||
|
localIP = "127.0.0.1"
|
||||||
|
localPort = 3002
|
||||||
|
remotePort = 8082
|
||||||
|
|
||||||
|
[[proxies]]
|
||||||
|
name = "filetransfer-remote"
|
||||||
|
type = "tcp"
|
||||||
|
localIP = "127.0.0.1"
|
||||||
|
localPort = 3003
|
||||||
|
remotePort = 8083
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,17 +7,31 @@ log.level = "info"
|
|||||||
log.maxDays = 7
|
log.maxDays = 7
|
||||||
|
|
||||||
[[proxies]]
|
[[proxies]]
|
||||||
name = "remote-desktop"
|
name = "desktop-remote"
|
||||||
type = "tcp"
|
type = "tcp"
|
||||||
localIP = "127.0.0.1"
|
localIP = "127.0.0.1"
|
||||||
localPort = 3000
|
localPort = 3000
|
||||||
remotePort = 8080
|
remotePort = 8080
|
||||||
|
|
||||||
[[proxies]]
|
[[proxies]]
|
||||||
name = "gitea-web"
|
name = "gitea-remote"
|
||||||
type = "tcp"
|
type = "tcp"
|
||||||
localIP = "127.0.0.1"
|
localIP = "127.0.0.1"
|
||||||
localPort = 3001
|
localPort = 3001
|
||||||
remotePort = 8081
|
remotePort = 8081
|
||||||
|
|
||||||
|
[[proxies]]
|
||||||
|
name = "opencode-remote"
|
||||||
|
type = "tcp"
|
||||||
|
localIP = "127.0.0.1"
|
||||||
|
localPort = 3002
|
||||||
|
remotePort = 8082
|
||||||
|
|
||||||
|
[[proxies]]
|
||||||
|
name = "filetransfer-remote"
|
||||||
|
type = "tcp"
|
||||||
|
localIP = "127.0.0.1"
|
||||||
|
localPort = 3003
|
||||||
|
remotePort = 8083
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,77 +1,2 @@
|
|||||||
APP_NAME = Xuanchi Git
|
|
||||||
RUN_USER = xuanchi
|
|
||||||
WORK_PATH = C:\Users\xuanchi\Desktop\remote\gitea
|
|
||||||
RUN_MODE = prod
|
|
||||||
|
|
||||||
[database]
|
|
||||||
DB_TYPE = sqlite3
|
|
||||||
HOST = 127.0.0.1:5432
|
|
||||||
NAME = gitea
|
|
||||||
USER = gitea
|
|
||||||
PASSWD =
|
|
||||||
SCHEMA =
|
|
||||||
SSL_MODE = disable
|
|
||||||
PATH = C:\Users\xuanchi\Desktop\remote\gitea\data\gitea.db
|
|
||||||
LOG_SQL = false
|
|
||||||
|
|
||||||
[repository]
|
|
||||||
ROOT = C:/Users/xuanchi/Desktop/remote/gitea/data/gitea-repositories
|
|
||||||
|
|
||||||
[server]
|
[server]
|
||||||
SSH_DOMAIN = localhost
|
|
||||||
DOMAIN = localhost
|
|
||||||
HTTP_PORT = 3001
|
HTTP_PORT = 3001
|
||||||
ROOT_URL = http://localhost:3001/
|
|
||||||
APP_DATA_PATH = C:\Users\xuanchi\Desktop\remote\gitea\data
|
|
||||||
DISABLE_SSH = false
|
|
||||||
SSH_PORT = 22
|
|
||||||
LFS_START_SERVER = true
|
|
||||||
LFS_JWT_SECRET = R7kPD0XqG0zTeGhLu1r8t4h-Y3FfofYW1_6GPhNnVZg
|
|
||||||
OFFLINE_MODE = true
|
|
||||||
|
|
||||||
[lfs]
|
|
||||||
PATH = C:/Users/xuanchi/Desktop/remote/gitea/data/lfs
|
|
||||||
|
|
||||||
[mailer]
|
|
||||||
ENABLED = false
|
|
||||||
|
|
||||||
[service]
|
|
||||||
REGISTER_EMAIL_CONFIRM = false
|
|
||||||
ENABLE_NOTIFY_MAIL = false
|
|
||||||
DISABLE_REGISTRATION = false
|
|
||||||
ALLOW_ONLY_EXTERNAL_REGISTRATION = false
|
|
||||||
ENABLE_CAPTCHA = false
|
|
||||||
REQUIRE_SIGNIN_VIEW = false
|
|
||||||
DEFAULT_KEEP_EMAIL_PRIVATE = false
|
|
||||||
DEFAULT_ALLOW_CREATE_ORGANIZATION = true
|
|
||||||
DEFAULT_ENABLE_TIMETRACKING = true
|
|
||||||
NO_REPLY_ADDRESS = noreply.localhost
|
|
||||||
|
|
||||||
[openid]
|
|
||||||
ENABLE_OPENID_SIGNIN = true
|
|
||||||
ENABLE_OPENID_SIGNUP = true
|
|
||||||
|
|
||||||
[cron.update_checker]
|
|
||||||
ENABLED = false
|
|
||||||
|
|
||||||
[session]
|
|
||||||
PROVIDER = file
|
|
||||||
|
|
||||||
[log]
|
|
||||||
MODE = console
|
|
||||||
LEVEL = info
|
|
||||||
ROOT_PATH = C:/Users/xuanchi/Desktop/remote/gitea/log
|
|
||||||
|
|
||||||
[repository.pull-request]
|
|
||||||
DEFAULT_MERGE_STYLE = merge
|
|
||||||
|
|
||||||
[repository.signing]
|
|
||||||
DEFAULT_TRUST_MODEL = committer
|
|
||||||
|
|
||||||
[security]
|
|
||||||
INSTALL_LOCK = true
|
|
||||||
INTERNAL_TOKEN = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE3NzI2Mzc5MjN9.PnM7mBbYjj8dz5dHYDwncvicVHOllHb0cWM9lSUdqIM
|
|
||||||
PASSWORD_HASH_ALGO = pbkdf2
|
|
||||||
|
|
||||||
[oauth2]
|
|
||||||
JWT_SECRET = aLiiycJwXwTxX9ZaE2By40OGAxkVcPsOwz-WzJWHfzA
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.0 KiB |
Binary file not shown.
@@ -1,23 +0,0 @@
|
|||||||
[diff]
|
|
||||||
algorithm = histogram
|
|
||||||
[core]
|
|
||||||
logallrefupdates = true
|
|
||||||
quotePath = false
|
|
||||||
commitGraph = true
|
|
||||||
longpaths = true
|
|
||||||
[gc]
|
|
||||||
reflogexpire = 90
|
|
||||||
writeCommitGraph = true
|
|
||||||
[user]
|
|
||||||
name = Gitea
|
|
||||||
email = gitea@fake.local
|
|
||||||
[receive]
|
|
||||||
advertisePushOptions = true
|
|
||||||
procReceiveRefs = refs/for
|
|
||||||
[fetch]
|
|
||||||
writeCommitGraph = true
|
|
||||||
[safe]
|
|
||||||
directory = *
|
|
||||||
[uploadpack]
|
|
||||||
allowfilter = true
|
|
||||||
allowAnySHA1InWant = true
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"storage":"boltdb","index_type":"scorch"}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"version":5}
|
|
||||||
Binary file not shown.
@@ -1,52 +0,0 @@
|
|||||||
-----BEGIN PRIVATE KEY-----
|
|
||||||
MIIJQQIBADANBgkqhkiG9w0BAQEFAASCCSswggknAgEAAoICAQDE959xE4ZOnhPD
|
|
||||||
4iDxlW5dVyXkzpbqADw9+N1D8vzCpvG2937zuQsMQ5BL1Qhzb6fQ8+Mo77TnDFLQ
|
|
||||||
zXSW2r/zs1QNHZeCuPN9etrlkNKQT6zLFXobRXDeHf/CDvI2l6gJI1s9sMHgurm2
|
|
||||||
XBRefVFqJUkeR3CLw2vNn09FxnwGQoTMyUejuA+n6ITII/0RDEgXOdZwKDOEXZom
|
|
||||||
N7J25YPhsYZ1BGj38tysb/gWNhsFrxVCHPOGiuDll5x5fGMRmb436PvvWktX0Yfg
|
|
||||||
4NTa6Re5IHpVCSAQR64HD8j0FsGsMDvXhrja5mdRWWjgnbuprtr/f8ei2AEj8CCe
|
|
||||||
mh7OKb6y1pryLq12hins4cw3PdzS/3+I0VSa5uUpdvZRymjMLJr9tD2UdfJWr6lB
|
|
||||||
Jp0U0wQ0vBl08kHsTIBOPGJlFtSZtwzP8+C2eAEw20C15qh7sE/NkbS5oyV0XZfB
|
|
||||||
gtVB0W+FCJNfC5HRuBl3x0Ei0jpFG/QncuHXXripgF/bNcDp+K5iGyJAWMSb9aJp
|
|
||||||
3lmijC99FGPMLiA4j5QkcR82yIWEVlrgYVEnP9dqaBEt4Rs2Gl1618uj0zdNq6Nj
|
|
||||||
DyBB6zihf0PnBjvGSG2uqQKz2236MvhxYmPn8vxDuvq7/xdax0So+OGuptbHXhDR
|
|
||||||
aLNqYCw24Vqg2sXhiuHbdDMrGQUlgQIDAQABAoICAFtlM3y6vJV6UF2Sbgrrddyl
|
|
||||||
9ZVoLyrBlTKEadd/xr1jzcFCsovRD0lPiINHhLZb1xjkMkHqiJy1YTA2RaVCN9OT
|
|
||||||
IKs9UfJ8c6+D9FKVkr8X2WwauSAyZp8KeITJfqbKVFR5LXtAq5XlzwrJS0JVEBQa
|
|
||||||
3QTJzXVs5nbxN01/PkmBUDHeK/nSDKGzCPn9iQ4CDumIEvLUFKOU81RMf+kfssRl
|
|
||||||
JajitPponPD+u7VCweMvTMPyvyVtB9JBOYA9sZKXLmavG0gDM/a56Tz11o8FndZv
|
|
||||||
NZSDuXcUa7InJu3sKU4Gy53Ei743LzXWMQ4Y4t1nn4Ly/eWYKV9NqzUs+qJbSHrO
|
|
||||||
cmFqYr6ypG86TbGk1ji3eDRjmvsUYM/lTNurAh9BPzGz0mTlrUGXc+OPmAU1LD6T
|
|
||||||
EeArMLHVxunON27T6ytHUV4VDbd8oa1KzsdiKEW71/WsK09kFLkOcGVR30OzLni5
|
|
||||||
pQRxUukVX7twcU4hmrheSNB+CMtxACfHRxPalczVCkX/xp2jxwfPGjqr4aSQqQfK
|
|
||||||
+hMDy5/jc6PrhPd7RSadWPVnFYJaYofWXgi/uU+26jrRmNswetNdvPv9zu5nS1Hs
|
|
||||||
VFK8Q5zgqsdG+QLEyNWtnz5fwkNMx49cB154V8AR1ZI6XLgbRUL069gNpvHzLl/N
|
|
||||||
eTb9MKj2d1/b6f3XZxEpAoIBAQDz7tEI0YC1Q5M0EQC0j8zvZy5FLP4NtwE9ZbHt
|
|
||||||
NsjsHC5xIb3ujhtIje66X/5cnwGy0Bh2QbMDeFXWKlHcw+CiFe1/13kLLUoGetMN
|
|
||||||
yHXLPCVx3iYNIopZgG8Db7qM7rYkmgVyE6VWyyMzVrFbO/rvtX8BK9jjrDVNRzsF
|
|
||||||
g1MvGpqSPZu4O3j5BK13U0+eBhTQIvVijvUVCc50ftQHXjhwGfvUsXoggemG0Q89
|
|
||||||
UpcKHvsAZbPOtP16HHcTuWk1SyF4qeVN+z1O+gOqebXW8qdzIBsDE2kUcq9bube5
|
|
||||||
5dJ8CknbKNYc/dTIMEdKu2zXDK57eL8OUZcT3csuI001d7JfAoIBAQDOtgfRub07
|
|
||||||
xNAeH9zK/4/5Smj2D+Ja6C6a37s5fsS1m1KhG0JvD7f4dhABxIf7PY0mvyxSpJt9
|
|
||||||
LzyYPzR8B/DoaM5L3aDxcIhsp7JQ+STj1V2SKsNW8M4kRTiEPZh81jzZYtBCxe1o
|
|
||||||
sLu3lhICusrLtfSSbix2ijMdPDEe1aDMT/P3TVrHUk2u5fMrvZHDB6VqvidXqLyG
|
|
||||||
R4ifF4eblfbWPtlnNIh4V2vCX4+dfH25oyaO/KKTMuV+0h2+h6AjTOcV2ntXlyZK
|
|
||||||
/oUDWRPbM6m019DEBoG9VBawyY6aGTM44RHjKBBz6itj938TlseF2qm4BPctfIF+
|
|
||||||
gVvKqwSM/PQfAoIBAHCEmlts9+ek0gPUS/T919QeThOOm2mMHsBJZnc7LBbtMOby
|
|
||||||
X3/ogOFIxvOlT9k9ZzUqE/6Ic6CII1/0iWpB2B4r6y9rHuRu8ZRnl27mJp+mkMcj
|
|
||||||
Z33rjtGWEp8NLInRmqbrfNOQCFYuwX/u30RsOGXV3E2YAiWSy8tnrevvFbHGncIT
|
|
||||||
NP8YP8btx24hObp0p6kSVwotUxNvQJIv5nG3nmTnN2h2rRTNmACd8l+g9xauD62x
|
|
||||||
O+1/QuTOuIaaodL5YukbxS/hUfhaDtLV7XDG4UKTbqJOk8vg0s6Grh7Lyfl5bXPw
|
|
||||||
NEOPOlVVH61zItNXgCxoGAjszblWN2CC3BxrqBUCggEASt9QMbzvOAjvwRmVZcnv
|
|
||||||
okI7hnT2bisPRnWnYQnzwjwCT+yJwaSV5F8PKTTAdFY1HEW+jiilUVCcyCCMqChQ
|
|
||||||
MD5WCtC6DPnP0FtlkULNA+EyxVDL9F/Aqw6PjAarhvwqiirqeGUsuvDY7YRj/a0e
|
|
||||||
6256qddSL5WbMgmtWRfT6G1FVtwj93JuRN1xmPRPKa9JUUKTCYNK1fBvIgDp04cc
|
|
||||||
IzockO9MRxqTI5JteIOxHl5kBwKm+F5FFgyRTYPekyq1wQqkBnPvINbT4wSO1qT9
|
|
||||||
4U0Shw48TBF7LomzJ0ndbcrIKdlHLFUzZkAtPTEuD+PF+auCxG0GkoXUc7JCMbcl
|
|
||||||
zQKCAQBuyk/G/9G458QPfvp4if0TNMPuX+yzcmjEcOf4x2vgTA1t6me4nPm4xEF2
|
|
||||||
TUO/hECYiNG1DyeN/rRdW6lce9CZMaP9D1Dc1g4fAGfxBBhrBvV7o4h0dffpOTNh
|
|
||||||
wyKx08C8rmImmvlt3aWyH+2sTe43GScbchSkEid8M/0RpaKp4b61zBhGUgYspKNS
|
|
||||||
QL+7dTq9iosBTaR+zWKBv6cfOahxXFdVQYkh1woDCLdWX5FCKjV/gD46UFEtMTYf
|
|
||||||
ABcF+5u2Pbxsf49gUN1wiJBx0o5+81IB34lIscp8wA+3ZRUeDnAbaEPTeoVIP+Of
|
|
||||||
ohu0miobcXoUkKle/P8TFqStcnW6
|
|
||||||
-----END PRIVATE KEY-----
|
|
||||||
Binary file not shown.
@@ -1 +0,0 @@
|
|||||||
MANIFEST-000050
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
MANIFEST-000048
|
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
=============== Mar 4, 2026 (CST) ===============
|
|
||||||
23:25:27.295431 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
|
||||||
23:25:27.296428 db@open opening
|
|
||||||
23:25:27.297425 version@stat F·[] S·0B[] Sc·[]
|
|
||||||
23:25:27.297425 db@janitor F·2 G·0
|
|
||||||
23:25:27.297425 db@open done T·997.1µs
|
|
||||||
=============== Mar 5, 2026 (CST) ===============
|
|
||||||
00:58:48.117008 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
|
||||||
00:58:48.118039 version@stat F·[] S·0B[] Sc·[]
|
|
||||||
00:58:48.118039 db@open opening
|
|
||||||
00:58:48.118039 journal@recovery F·1
|
|
||||||
00:58:48.118039 journal@recovery recovering @1
|
|
||||||
00:58:48.119011 memdb@flush created L0@2 N·28 S·574B "act..igh,v28":"web..low,v19"
|
|
||||||
00:58:48.120008 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
|
||||||
00:58:48.122002 db@janitor F·3 G·0
|
|
||||||
00:58:48.122002 db@open done T·3.9631ms
|
|
||||||
=============== Mar 5, 2026 (CST) ===============
|
|
||||||
16:44:08.552284 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
|
||||||
16:44:08.552284 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
|
||||||
16:44:08.552284 db@open opening
|
|
||||||
16:44:08.552284 journal@recovery F·1
|
|
||||||
16:44:08.552284 journal@recovery recovering @3
|
|
||||||
16:44:08.553282 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
|
||||||
16:44:08.555630 db@janitor F·3 G·0
|
|
||||||
16:44:08.555630 db@open done T·3.3467ms
|
|
||||||
=============== Mar 5, 2026 (CST) ===============
|
|
||||||
16:58:44.704290 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
|
||||||
16:58:44.704290 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
|
||||||
16:58:44.704290 db@open opening
|
|
||||||
16:58:44.704290 journal@recovery F·1
|
|
||||||
16:58:44.704290 journal@recovery recovering @5
|
|
||||||
16:58:44.705289 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
|
||||||
16:58:44.709300 db@janitor F·3 G·0
|
|
||||||
16:58:44.709300 db@open done T·5.0092ms
|
|
||||||
=============== Mar 5, 2026 (CST) ===============
|
|
||||||
16:58:55.013041 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
|
||||||
16:58:55.014038 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
|
||||||
16:58:55.014038 db@open opening
|
|
||||||
16:58:55.014038 journal@recovery F·1
|
|
||||||
16:58:55.014038 journal@recovery recovering @7
|
|
||||||
16:58:55.014038 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
|
||||||
16:58:55.019025 db@janitor F·3 G·0
|
|
||||||
16:58:55.019025 db@open done T·4.9869ms
|
|
||||||
=============== Mar 5, 2026 (CST) ===============
|
|
||||||
17:01:48.341808 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
|
||||||
17:01:48.341808 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
|
||||||
17:01:48.341808 db@open opening
|
|
||||||
17:01:48.341808 journal@recovery F·1
|
|
||||||
17:01:48.341808 journal@recovery recovering @9
|
|
||||||
17:01:48.342805 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
|
||||||
17:01:48.346766 db@janitor F·3 G·0
|
|
||||||
17:01:48.346766 db@open done T·4.9576ms
|
|
||||||
=============== Mar 5, 2026 (CST) ===============
|
|
||||||
17:05:22.244372 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
|
||||||
17:05:22.245370 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
|
||||||
17:05:22.245370 db@open opening
|
|
||||||
17:05:22.245370 journal@recovery F·1
|
|
||||||
17:05:22.245370 journal@recovery recovering @11
|
|
||||||
17:05:22.245370 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
|
||||||
17:05:22.250356 db@janitor F·3 G·0
|
|
||||||
17:05:22.250356 db@open done T·4.9865ms
|
|
||||||
=============== Mar 5, 2026 (CST) ===============
|
|
||||||
17:15:26.696181 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
|
||||||
17:15:26.696181 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
|
||||||
17:15:26.696181 db@open opening
|
|
||||||
17:15:26.696181 journal@recovery F·1
|
|
||||||
17:15:26.696181 journal@recovery recovering @13
|
|
||||||
17:15:26.696181 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
|
||||||
17:15:26.701109 db@janitor F·3 G·0
|
|
||||||
17:15:26.701109 db@open done T·4.9277ms
|
|
||||||
=============== Mar 5, 2026 (CST) ===============
|
|
||||||
17:42:26.549391 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
|
||||||
17:42:26.550343 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
|
||||||
17:42:26.550343 db@open opening
|
|
||||||
17:42:26.550343 journal@recovery F·1
|
|
||||||
17:42:26.550343 journal@recovery recovering @15
|
|
||||||
17:42:26.552338 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
|
||||||
17:42:26.554337 db@janitor F·3 G·0
|
|
||||||
17:42:26.554337 db@open done T·3.9934ms
|
|
||||||
=============== Mar 5, 2026 (CST) ===============
|
|
||||||
17:53:41.022244 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
|
||||||
17:53:41.022244 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
|
||||||
17:53:41.022244 db@open opening
|
|
||||||
17:53:41.022244 journal@recovery F·1
|
|
||||||
17:53:41.022244 journal@recovery recovering @17
|
|
||||||
17:53:41.023231 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
|
||||||
17:53:41.026274 db@janitor F·3 G·0
|
|
||||||
17:53:41.026274 db@open done T·4.0304ms
|
|
||||||
=============== Mar 5, 2026 (CST) ===============
|
|
||||||
18:06:44.931577 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
|
||||||
18:06:44.932090 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
|
||||||
18:06:44.932090 db@open opening
|
|
||||||
18:06:44.932090 journal@recovery F·1
|
|
||||||
18:06:44.932602 journal@recovery recovering @19
|
|
||||||
18:06:44.933113 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
|
||||||
18:06:44.935926 db@janitor F·3 G·0
|
|
||||||
18:06:44.935926 db@open done T·3.8362ms
|
|
||||||
=============== Mar 5, 2026 (CST) ===============
|
|
||||||
18:17:10.357320 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
|
||||||
18:17:10.357320 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
|
||||||
18:17:10.357833 db@open opening
|
|
||||||
18:17:10.357841 journal@recovery F·1
|
|
||||||
18:17:10.357841 journal@recovery recovering @21
|
|
||||||
18:17:10.357841 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
|
||||||
18:17:10.361450 db@janitor F·3 G·0
|
|
||||||
18:17:10.361450 db@open done T·3.6088ms
|
|
||||||
=============== Mar 5, 2026 (CST) ===============
|
|
||||||
18:20:47.461717 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
|
||||||
18:20:47.462247 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
|
||||||
18:20:47.462247 db@open opening
|
|
||||||
18:20:47.462247 journal@recovery F·1
|
|
||||||
18:20:47.462247 journal@recovery recovering @23
|
|
||||||
18:20:47.464218 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
|
||||||
18:20:47.466420 db@janitor F·3 G·0
|
|
||||||
18:20:47.466420 db@open done T·4.1729ms
|
|
||||||
=============== Mar 5, 2026 (CST) ===============
|
|
||||||
18:24:07.472215 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
|
||||||
18:24:07.473212 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
|
||||||
18:24:07.473212 db@open opening
|
|
||||||
18:24:07.473212 journal@recovery F·1
|
|
||||||
18:24:07.473212 journal@recovery recovering @25
|
|
||||||
18:24:07.473212 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
|
||||||
18:24:07.476910 db@janitor F·3 G·0
|
|
||||||
18:24:07.476910 db@open done T·3.6976ms
|
|
||||||
=============== Mar 5, 2026 (CST) ===============
|
|
||||||
18:41:46.328705 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
|
||||||
18:41:46.329704 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
|
||||||
18:41:46.329704 db@open opening
|
|
||||||
18:41:46.329704 journal@recovery F·1
|
|
||||||
18:41:46.329704 journal@recovery recovering @27
|
|
||||||
18:41:46.329704 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
|
||||||
18:41:46.334694 db@janitor F·3 G·0
|
|
||||||
18:41:46.334694 db@open done T·4.9906ms
|
|
||||||
=============== Mar 5, 2026 (CST) ===============
|
|
||||||
20:17:07.605654 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
|
||||||
20:17:07.606653 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
|
||||||
20:17:07.606653 db@open opening
|
|
||||||
20:17:07.606653 journal@recovery F·1
|
|
||||||
20:17:07.606653 journal@recovery recovering @29
|
|
||||||
20:17:07.606653 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
|
||||||
20:17:07.609645 db@janitor F·3 G·0
|
|
||||||
20:17:07.609645 db@open done T·2.9923ms
|
|
||||||
=============== Mar 5, 2026 (CST) ===============
|
|
||||||
20:18:45.285721 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
|
||||||
20:18:45.286227 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
|
||||||
20:18:45.286227 db@open opening
|
|
||||||
20:18:45.286227 journal@recovery F·1
|
|
||||||
20:18:45.286227 journal@recovery recovering @31
|
|
||||||
20:18:45.286227 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
|
||||||
20:18:45.290050 db@janitor F·3 G·0
|
|
||||||
20:18:45.290050 db@open done T·3.823ms
|
|
||||||
=============== Mar 5, 2026 (CST) ===============
|
|
||||||
20:24:14.214040 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
|
||||||
20:24:14.215038 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
|
||||||
20:24:14.215038 db@open opening
|
|
||||||
20:24:14.215038 journal@recovery F·1
|
|
||||||
20:24:14.217038 journal@recovery recovering @33
|
|
||||||
20:24:14.217038 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
|
||||||
20:24:14.221083 db@janitor F·3 G·0
|
|
||||||
20:24:14.221083 db@open done T·6.0448ms
|
|
||||||
=============== Mar 5, 2026 (CST) ===============
|
|
||||||
20:27:37.450756 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
|
||||||
20:27:37.451263 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
|
||||||
20:27:37.451263 db@open opening
|
|
||||||
20:27:37.451263 journal@recovery F·1
|
|
||||||
20:27:37.451263 journal@recovery recovering @35
|
|
||||||
20:27:37.451263 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
|
||||||
20:27:37.454842 db@janitor F·3 G·0
|
|
||||||
20:27:37.454842 db@open done T·3.579ms
|
|
||||||
=============== Mar 5, 2026 (CST) ===============
|
|
||||||
20:30:19.881992 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
|
||||||
20:30:19.881992 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
|
||||||
20:30:19.881992 db@open opening
|
|
||||||
20:30:19.881992 journal@recovery F·1
|
|
||||||
20:30:19.881992 journal@recovery recovering @37
|
|
||||||
20:30:19.882990 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
|
||||||
20:30:19.884984 db@janitor F·3 G·0
|
|
||||||
20:30:19.884984 db@open done T·2.9921ms
|
|
||||||
=============== Mar 5, 2026 (CST) ===============
|
|
||||||
20:33:21.085745 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
|
||||||
20:33:21.086742 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
|
||||||
20:33:21.086742 db@open opening
|
|
||||||
20:33:21.086742 journal@recovery F·1
|
|
||||||
20:33:21.086742 journal@recovery recovering @39
|
|
||||||
20:33:21.088251 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
|
||||||
20:33:21.093539 db@janitor F·3 G·0
|
|
||||||
20:33:21.093539 db@open done T·6.7975ms
|
|
||||||
=============== Mar 5, 2026 (CST) ===============
|
|
||||||
20:35:21.489745 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
|
||||||
20:35:21.490699 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
|
||||||
20:35:21.490699 db@open opening
|
|
||||||
20:35:21.490699 journal@recovery F·1
|
|
||||||
20:35:21.490699 journal@recovery recovering @41
|
|
||||||
20:35:21.491220 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
|
||||||
20:35:21.495194 db@janitor F·3 G·0
|
|
||||||
20:35:21.495194 db@open done T·4.4953ms
|
|
||||||
=============== Mar 5, 2026 (CST) ===============
|
|
||||||
20:42:06.364290 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
|
||||||
20:42:06.364290 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
|
||||||
20:42:06.364290 db@open opening
|
|
||||||
20:42:06.364796 journal@recovery F·1
|
|
||||||
20:42:06.364796 journal@recovery recovering @43
|
|
||||||
20:42:06.364796 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
|
||||||
20:42:06.369434 db@janitor F·3 G·0
|
|
||||||
20:42:06.369434 db@open done T·5.1446ms
|
|
||||||
=============== Mar 5, 2026 (CST) ===============
|
|
||||||
20:46:58.679386 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
|
||||||
20:46:58.679386 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
|
||||||
20:46:58.679386 db@open opening
|
|
||||||
20:46:58.679386 journal@recovery F·1
|
|
||||||
20:46:58.679386 journal@recovery recovering @45
|
|
||||||
20:46:58.680383 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
|
||||||
20:46:58.683452 db@janitor F·3 G·0
|
|
||||||
20:46:58.683452 db@open done T·4.0659ms
|
|
||||||
=============== Mar 5, 2026 (CST) ===============
|
|
||||||
20:49:54.485040 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
|
||||||
20:49:54.485040 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
|
||||||
20:49:54.485040 db@open opening
|
|
||||||
20:49:54.485040 journal@recovery F·1
|
|
||||||
20:49:54.485040 journal@recovery recovering @47
|
|
||||||
20:49:54.486036 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
|
||||||
20:49:54.488644 db@janitor F·3 G·0
|
|
||||||
20:49:54.488644 db@open done T·3.6037ms
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
27
remote/package-lock.json
generated
27
remote/package-lock.json
generated
@@ -13,6 +13,7 @@
|
|||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"config": "^3.3.12",
|
"config": "^3.3.12",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
|
"cors": "^2.8.6",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"h264-live-player": "^1.3.1",
|
"h264-live-player": "^1.3.1",
|
||||||
@@ -771,6 +772,23 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cors": {
|
||||||
|
"version": "2.8.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
|
||||||
|
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"object-assign": "^4",
|
||||||
|
"vary": "^1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@@ -1985,6 +2003,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/object-assign": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/object-inspect": {
|
"node_modules/object-inspect": {
|
||||||
"version": "1.13.4",
|
"version": "1.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"config": "^3.3.12",
|
"config": "^3.3.12",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
|
"cors": "^2.8.6",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"h264-live-player": "^1.3.1",
|
"h264-live-player": "^1.3.1",
|
||||||
|
|||||||
@@ -118,6 +118,16 @@ class App {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.container.register('xcOpenCodeWebService', (c) => {
|
||||||
|
const XCOpenCodeWebService = require('../services/opencode/XCOpenCodeWebService');
|
||||||
|
const config = c.resolve('config');
|
||||||
|
const xcopencodewebConfig = config.getSection('xcopencodeweb') || {};
|
||||||
|
return new XCOpenCodeWebService({
|
||||||
|
enabled: xcopencodewebConfig.enabled !== false,
|
||||||
|
port: xcopencodewebConfig.port
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
this.container.register('giteaService', (c) => {
|
this.container.register('giteaService', (c) => {
|
||||||
const GiteaService = require('../services/network/GiteaService');
|
const GiteaService = require('../services/network/GiteaService');
|
||||||
const config = c.resolve('config');
|
const config = c.resolve('config');
|
||||||
@@ -135,6 +145,7 @@ class App {
|
|||||||
const serverConfig = config.getSection('server') || {};
|
const serverConfig = config.getSection('server') || {};
|
||||||
return new Server({
|
return new Server({
|
||||||
port: serverConfig.port || 3000,
|
port: serverConfig.port || 3000,
|
||||||
|
fileTransferPort: serverConfig.fileTransferPort || 3003,
|
||||||
host: serverConfig.host || '0.0.0.0'
|
host: serverConfig.host || '0.0.0.0'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -191,6 +202,10 @@ class App {
|
|||||||
frpService.start();
|
frpService.start();
|
||||||
logger.info('FRP service started');
|
logger.info('FRP service started');
|
||||||
|
|
||||||
|
const xcOpenCodeWebService = this.container.resolve('xcOpenCodeWebService');
|
||||||
|
xcOpenCodeWebService.start();
|
||||||
|
logger.info('XCOpenCodeWeb service started');
|
||||||
|
|
||||||
const giteaService = this.container.resolve('giteaService');
|
const giteaService = this.container.resolve('giteaService');
|
||||||
giteaService.start();
|
giteaService.start();
|
||||||
logger.info('Gitea service started');
|
logger.info('Gitea service started');
|
||||||
@@ -223,6 +238,17 @@ class App {
|
|||||||
const authMiddleware = require('../middlewares/auth');
|
const authMiddleware = require('../middlewares/auth');
|
||||||
const routes = require('../routes');
|
const routes = require('../routes');
|
||||||
|
|
||||||
|
// 简单的 CORS 中间件
|
||||||
|
httpServer.use((req, res, next) => {
|
||||||
|
res.header('Access-Control-Allow-Origin', '*');
|
||||||
|
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||||
|
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
return res.sendStatus(200);
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
httpServer.use(cookieParser());
|
httpServer.use(cookieParser());
|
||||||
httpServer.use(express.json());
|
httpServer.use(express.json());
|
||||||
httpServer.use(express.urlencoded({ extended: true }));
|
httpServer.use(express.urlencoded({ extended: true }));
|
||||||
@@ -241,6 +267,11 @@ class App {
|
|||||||
});
|
});
|
||||||
|
|
||||||
httpServer.use(async (req, res, next) => {
|
httpServer.use(async (req, res, next) => {
|
||||||
|
// 放行 CORS 预检请求
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
if (!authService.hasPassword()) {
|
if (!authService.hasPassword()) {
|
||||||
res.locals.authenticated = true;
|
res.locals.authenticated = true;
|
||||||
return next();
|
return next();
|
||||||
@@ -453,6 +484,10 @@ class App {
|
|||||||
frpService.stop();
|
frpService.stop();
|
||||||
logger.info('FRP service stopped');
|
logger.info('FRP service stopped');
|
||||||
|
|
||||||
|
const xcOpenCodeWebService = this.container.resolve('xcOpenCodeWebService');
|
||||||
|
xcOpenCodeWebService.stop();
|
||||||
|
logger.info('XCOpenCodeWeb service stopped');
|
||||||
|
|
||||||
const giteaService = this.container.resolve('giteaService');
|
const giteaService = this.container.resolve('giteaService');
|
||||||
giteaService.stop();
|
giteaService.stop();
|
||||||
logger.info('Gitea service stopped');
|
logger.info('Gitea service stopped');
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const multer = require('multer');
|
const multer = require('multer');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
const { fileService } = require('../services/file');
|
const { fileService } = require('../services/file');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
@@ -17,14 +19,71 @@ router.get('/', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post('/upload', upload.single('file'), (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!req.file) {
|
||||||
|
return res.status(400).json({ error: 'No file provided' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const remotePath = req.body.path || '';
|
||||||
|
const filename = req.file.originalname;
|
||||||
|
|
||||||
|
let targetDir;
|
||||||
|
|
||||||
|
if (remotePath) {
|
||||||
|
if (remotePath.match(/^[A-Z]:\\?$/i)) {
|
||||||
|
targetDir = remotePath;
|
||||||
|
} else {
|
||||||
|
targetDir = remotePath;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
targetDir = 'C:\\';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(targetDir)) {
|
||||||
|
fs.mkdirSync(targetDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetPath = path.join(targetDir, filename);
|
||||||
|
fs.writeFileSync(targetPath, req.file.buffer);
|
||||||
|
|
||||||
|
res.json({ success: true, filename });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to upload file', { error: error.message });
|
||||||
|
res.status(500).json({ error: 'Failed to upload file' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/drives', (req, res) => {
|
||||||
|
try {
|
||||||
|
const drives = fileService.getDrives();
|
||||||
|
res.json({ items: drives });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to get drives', { error: error.message });
|
||||||
|
res.status(500).json({ error: 'Failed to get drives' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/browse', (req, res) => {
|
router.get('/browse', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const path = req.query.path || '';
|
const filePath = req.query.path || '';
|
||||||
const result = fileService.browseDirectory(path);
|
const allowSystem = req.query.allowSystem === 'true';
|
||||||
|
|
||||||
|
if (allowSystem && !filePath) {
|
||||||
|
const drives = fileService.getDrives();
|
||||||
|
res.json({
|
||||||
|
items: drives,
|
||||||
|
currentPath: '',
|
||||||
|
parentPath: null
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = fileService.browseDirectory(filePath, allowSystem);
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to browse directory', { error: error.message });
|
logger.error('Failed to browse directory', { error: error.message, stack: error.stack });
|
||||||
res.status(500).json({ error: 'Failed to browse directory' });
|
res.status(500).json({ error: error.message || 'Failed to browse directory' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -98,13 +157,13 @@ router.post('/upload/chunk', upload.single('chunk'), (req, res) => {
|
|||||||
|
|
||||||
router.post('/upload/merge', (req, res) => {
|
router.post('/upload/merge', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { fileId, totalChunks, filename } = req.body;
|
const { fileId, totalChunks, filename, path: targetPath } = req.body;
|
||||||
|
|
||||||
if (!fileId || !totalChunks || !filename) {
|
if (!fileId || !totalChunks || !filename) {
|
||||||
return res.status(400).json({ error: 'Missing required fields' });
|
return res.status(400).json({ error: 'Missing required fields' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const success = fileService.mergeChunks(fileId, parseInt(totalChunks), filename);
|
const success = fileService.mergeChunks(fileId, parseInt(totalChunks), filename, targetPath);
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
res.json({ success: true, filename });
|
res.json({ success: true, filename });
|
||||||
|
|||||||
261
remote/src/server/FileHandler.js
Normal file
261
remote/src/server/FileHandler.js
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
const crypto = require('crypto');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
const MessageTypes = require('./messageTypes');
|
||||||
|
const { fileService } = require('../services/file');
|
||||||
|
|
||||||
|
const CHUNK_SIZE = 5 * 1024 * 1024;
|
||||||
|
|
||||||
|
class FileHandler {
|
||||||
|
constructor() {
|
||||||
|
this.uploadSessions = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMessage(message, ws) {
|
||||||
|
const { type, requestId, ...data } = message;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case MessageTypes.FILE_LIST_GET:
|
||||||
|
this.handleFileList(ws, requestId);
|
||||||
|
break;
|
||||||
|
case MessageTypes.FILE_BROWSE:
|
||||||
|
this.handleFileBrowse(ws, data, requestId);
|
||||||
|
break;
|
||||||
|
case MessageTypes.FILE_UPLOAD_START:
|
||||||
|
this.handleFileUploadStart(ws, data, requestId);
|
||||||
|
break;
|
||||||
|
case MessageTypes.FILE_UPLOAD_CHUNK:
|
||||||
|
this.handleFileUploadChunk(ws, data, requestId);
|
||||||
|
break;
|
||||||
|
case MessageTypes.FILE_UPLOAD_MERGE:
|
||||||
|
this.handleFileUploadMerge(ws, data, requestId);
|
||||||
|
break;
|
||||||
|
case MessageTypes.FILE_DOWNLOAD_START:
|
||||||
|
this.handleFileDownload(ws, data, requestId);
|
||||||
|
break;
|
||||||
|
case MessageTypes.FILE_DELETE:
|
||||||
|
this.handleFileDelete(ws, data, requestId);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
logger.debug('Unknown file message type', { type });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendResponse(ws, type, requestId, payload) {
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type,
|
||||||
|
requestId,
|
||||||
|
...payload
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFileList(ws, requestId) {
|
||||||
|
try {
|
||||||
|
const files = fileService.getFileList();
|
||||||
|
this.sendResponse(ws, MessageTypes.FILE_LIST, requestId, { files });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to get file list', { error: error.message });
|
||||||
|
this.sendResponse(ws, MessageTypes.FILE_LIST, requestId, { files: [], error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFileBrowse(ws, data, requestId) {
|
||||||
|
try {
|
||||||
|
const { path: dirPath, allowSystem } = data;
|
||||||
|
|
||||||
|
if (allowSystem && !dirPath) {
|
||||||
|
const drives = fileService.getDrives();
|
||||||
|
this.sendResponse(ws, MessageTypes.FILE_BROWSE_RESULT, requestId, {
|
||||||
|
items: drives,
|
||||||
|
currentPath: '',
|
||||||
|
parentPath: null
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = fileService.browseDirectory(dirPath || '', allowSystem === true);
|
||||||
|
this.sendResponse(ws, MessageTypes.FILE_BROWSE_RESULT, requestId, result);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to browse directory', { error: error.message });
|
||||||
|
this.sendResponse(ws, MessageTypes.FILE_BROWSE_RESULT, requestId, { items: [], error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFileUploadStart(ws, data, requestId) {
|
||||||
|
try {
|
||||||
|
const { filename, totalChunks, fileSize } = data;
|
||||||
|
const fileId = data.fileId || requestId || crypto.randomBytes(16).toString('hex');
|
||||||
|
|
||||||
|
this.uploadSessions.set(fileId, {
|
||||||
|
filename,
|
||||||
|
totalChunks,
|
||||||
|
fileSize,
|
||||||
|
chunks: new Map(),
|
||||||
|
createdAt: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
this.sendResponse(ws, MessageTypes.FILE_UPLOAD_START, requestId, {
|
||||||
|
fileId,
|
||||||
|
chunkSize: CHUNK_SIZE,
|
||||||
|
message: 'Upload session started'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to start upload', { error: error.message });
|
||||||
|
this.sendResponse(ws, MessageTypes.FILE_UPLOAD_RESULT, requestId, { success: false, error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFileUploadChunk(ws, data, requestId) {
|
||||||
|
try {
|
||||||
|
const { fileId, chunkIndex } = data;
|
||||||
|
const session = this.uploadSessions.get(fileId);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
this.sendResponse(ws, MessageTypes.FILE_UPLOAD_RESULT, requestId, { success: false, error: 'Upload session not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let chunkData;
|
||||||
|
if (data.data) {
|
||||||
|
chunkData = Buffer.from(data.data, 'base64');
|
||||||
|
} else if (data.buffer) {
|
||||||
|
chunkData = Buffer.from(data.buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!chunkData) {
|
||||||
|
this.sendResponse(ws, MessageTypes.FILE_UPLOAD_RESULT, requestId, { success: false, error: 'No chunk data provided' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
session.chunks.set(chunkIndex, chunkData);
|
||||||
|
|
||||||
|
this.sendResponse(ws, MessageTypes.FILE_UPLOAD_CHUNK, requestId, { success: true, chunkIndex });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to upload chunk', { error: error.message });
|
||||||
|
this.sendResponse(ws, MessageTypes.FILE_UPLOAD_RESULT, requestId, { success: false, error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFileUploadMerge(ws, data, requestId) {
|
||||||
|
try {
|
||||||
|
const { fileId, filename } = data;
|
||||||
|
const session = this.uploadSessions.get(fileId);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
this.sendResponse(ws, MessageTypes.FILE_UPLOAD_RESULT, requestId, { success: false, error: 'Upload session not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = fileService.mergeChunks(fileId, session.totalChunks, filename);
|
||||||
|
|
||||||
|
this.uploadSessions.delete(fileId);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
fileService.cleanupChunks(fileId);
|
||||||
|
this.sendResponse(ws, MessageTypes.FILE_UPLOAD_RESULT, requestId, { success: true, filename });
|
||||||
|
} else {
|
||||||
|
fileService.cleanupChunks(fileId);
|
||||||
|
this.sendResponse(ws, MessageTypes.FILE_UPLOAD_RESULT, requestId, { success: false, error: 'Failed to merge chunks' });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to merge chunks', { error: error.message });
|
||||||
|
this.sendResponse(ws, MessageTypes.FILE_UPLOAD_RESULT, requestId, { success: false, error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFileDownload(ws, data, requestId) {
|
||||||
|
try {
|
||||||
|
const { filename, filePath, allowSystem } = data;
|
||||||
|
|
||||||
|
let fullFilePath;
|
||||||
|
if (allowSystem && filePath && (path.isAbsolute(filePath) || filePath.includes(':') || filePath.startsWith('\\') || filePath.startsWith('/'))) {
|
||||||
|
if (path.isAbsolute(filePath)) {
|
||||||
|
fullFilePath = filePath;
|
||||||
|
} else {
|
||||||
|
fullFilePath = filePath.replace(/\//g, '\\');
|
||||||
|
}
|
||||||
|
} else if (filePath) {
|
||||||
|
fullFilePath = path.join(fileService.uploadDir, filePath);
|
||||||
|
} else {
|
||||||
|
fullFilePath = path.join(fileService.uploadDir, filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(fullFilePath)) {
|
||||||
|
this.sendResponse(ws, MessageTypes.FILE_DOWNLOAD_COMPLETE, requestId, { success: false, error: 'File not found: ' + fullFilePath });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stat = fs.statSync(fullFilePath);
|
||||||
|
const fileSize = stat.size;
|
||||||
|
const totalChunks = Math.ceil(fileSize / CHUNK_SIZE);
|
||||||
|
|
||||||
|
this.sendResponse(ws, MessageTypes.FILE_DOWNLOAD_START, requestId, {
|
||||||
|
filename,
|
||||||
|
size: fileSize,
|
||||||
|
chunkSize: CHUNK_SIZE,
|
||||||
|
totalChunks
|
||||||
|
});
|
||||||
|
|
||||||
|
const stream = fs.createReadStream(fullFilePath);
|
||||||
|
let chunkIndex = 0;
|
||||||
|
|
||||||
|
stream.on('data', (chunk) => {
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: MessageTypes.FILE_DOWNLOAD_CHUNK,
|
||||||
|
chunkIndex,
|
||||||
|
data: chunk.toString('base64'),
|
||||||
|
progress: Math.round(((chunkIndex + 1) / totalChunks) * 100)
|
||||||
|
}));
|
||||||
|
|
||||||
|
chunkIndex++;
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on('end', () => {
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: MessageTypes.FILE_DOWNLOAD_COMPLETE,
|
||||||
|
success: true,
|
||||||
|
filename
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on('error', (error) => {
|
||||||
|
logger.error('File download error', { error: error.message });
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: MessageTypes.FILE_DOWNLOAD_COMPLETE,
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to start download', { error: error.message });
|
||||||
|
this.sendResponse(ws, MessageTypes.FILE_DOWNLOAD_COMPLETE, requestId, { success: false, error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFileDelete(ws, data, requestId) {
|
||||||
|
try {
|
||||||
|
const { filename, filePath } = data;
|
||||||
|
const targetPath = filePath || '';
|
||||||
|
const success = fileService.deleteFile(filename, targetPath);
|
||||||
|
|
||||||
|
this.sendResponse(ws, MessageTypes.FILE_DELETE_RESULT, requestId, { success, filename });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to delete file', { error: error.message });
|
||||||
|
this.sendResponse(ws, MessageTypes.FILE_DELETE_RESULT, requestId, { success: false, error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
const now = Date.now();
|
||||||
|
const maxAge = 30 * 60 * 1000;
|
||||||
|
|
||||||
|
for (const [fileId, session] of this.uploadSessions) {
|
||||||
|
if (now - session.createdAt > maxAge) {
|
||||||
|
this.uploadSessions.delete(fileId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = FileHandler;
|
||||||
78
remote/src/server/FileServer.js
Normal file
78
remote/src/server/FileServer.js
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const http = require('http');
|
||||||
|
const WebSocket = require('ws');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
const FileHandler = require('./FileHandler');
|
||||||
|
|
||||||
|
class FileServer {
|
||||||
|
constructor(port = 3001) {
|
||||||
|
this.port = port;
|
||||||
|
this.app = express();
|
||||||
|
this.server = http.createServer(this.app);
|
||||||
|
this.wss = null;
|
||||||
|
this.fileHandler = new FileHandler();
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.server.listen({ port: this.port, host: '0.0.0.0' }, () => {
|
||||||
|
logger.info('File server started', { port: this.port });
|
||||||
|
this._setupWebSocket();
|
||||||
|
resolve({ port: this.port });
|
||||||
|
});
|
||||||
|
this.server.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_setupWebSocket() {
|
||||||
|
this.wss = new WebSocket.Server({ server: this.server, path: '/ws' });
|
||||||
|
|
||||||
|
this.wss.on('connection', (ws, req) => {
|
||||||
|
logger.info('File client connected', { ip: req.socket.remoteAddress });
|
||||||
|
|
||||||
|
ws.on('message', (data, isBinary) => {
|
||||||
|
try {
|
||||||
|
if (isBinary) {
|
||||||
|
logger.warn('Received binary data on file WebSocket, ignoring');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataStr = data.toString();
|
||||||
|
logger.info('Raw message received:', dataStr.substring(0, 300));
|
||||||
|
const message = JSON.parse(dataStr);
|
||||||
|
logger.info('File message parsed', { type: message.type, requestId: message.requestId });
|
||||||
|
this.fileHandler.handleMessage(message, ws);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to parse file message', { error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('close', () => {
|
||||||
|
logger.info('File client disconnected');
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('error', (error) => {
|
||||||
|
logger.error('File WebSocket error', { error: error.message });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.wss.on('error', (error) => {
|
||||||
|
logger.error('File WebSocket server error', { error: error.message });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (this.wss) {
|
||||||
|
this.wss.clients.forEach(client => client.close());
|
||||||
|
this.wss.close();
|
||||||
|
}
|
||||||
|
this.server.close((err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = FileServer;
|
||||||
@@ -5,9 +5,11 @@ const path = require('path');
|
|||||||
class Server {
|
class Server {
|
||||||
constructor(config = {}) {
|
constructor(config = {}) {
|
||||||
this.port = config.port || 3000;
|
this.port = config.port || 3000;
|
||||||
|
this.fileTransferPort = config.fileTransferPort || 3003;
|
||||||
this.host = config.host || '0.0.0.0';
|
this.host = config.host || '0.0.0.0';
|
||||||
this.app = express();
|
this.app = express();
|
||||||
this.server = http.createServer(this.app);
|
this.server = http.createServer(this.app);
|
||||||
|
this.fileTransferServer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
use(...args) {
|
use(...args) {
|
||||||
@@ -28,20 +30,33 @@ class Server {
|
|||||||
start() {
|
start() {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
this.server.listen({ port: this.port, host: this.host }, () => {
|
this.server.listen({ port: this.port, host: this.host }, () => {
|
||||||
resolve(this.getAddress());
|
console.log(`Server started on port ${this.port}`);
|
||||||
});
|
});
|
||||||
this.server.on('error', reject);
|
this.server.on('error', reject);
|
||||||
|
|
||||||
|
this.fileTransferServer = http.createServer(this.app);
|
||||||
|
this.fileTransferServer.listen({ port: this.fileTransferPort, host: this.host }, () => {
|
||||||
|
console.log(`File transfer server started on port ${this.fileTransferPort}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
resolve(this.getAddress());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
stop() {
|
stop() {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
this.server.close((err) => {
|
const closeFileTransfer = this.fileTransferServer
|
||||||
if (err) {
|
? new Promise((res) => this.fileTransferServer.close(res))
|
||||||
reject(err);
|
: Promise.resolve();
|
||||||
} else {
|
|
||||||
resolve();
|
closeFileTransfer.then(() => {
|
||||||
}
|
this.server.close((err) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ class FileService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getFilePath(filename) {
|
getFilePath(filename) {
|
||||||
const filePath = path.join(this.uploadDir, path.basename(filename));
|
if (!filename) return null;
|
||||||
|
const filePath = path.normalize(filename);
|
||||||
if (!fs.existsSync(filePath)) {
|
if (!fs.existsSync(filePath)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -90,9 +91,19 @@ class FileService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mergeChunks(fileId, totalChunks, filename) {
|
mergeChunks(fileId, totalChunks, filename, targetPath) {
|
||||||
try {
|
try {
|
||||||
const filePath = path.join(this.uploadDir, path.basename(filename));
|
let targetDir = targetPath || 'C:\\';
|
||||||
|
if (!fs.existsSync(targetDir)) {
|
||||||
|
fs.mkdirSync(targetDir, { recursive: true });
|
||||||
|
}
|
||||||
|
const filePath = path.join(targetDir, filename);
|
||||||
|
const dir = path.dirname(filePath);
|
||||||
|
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
const fd = fs.openSync(filePath, 'w');
|
const fd = fs.openSync(filePath, 'w');
|
||||||
|
|
||||||
for (let i = 0; i < totalChunks; i++) {
|
for (let i = 0; i < totalChunks; i++) {
|
||||||
@@ -136,48 +147,81 @@ class FileService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
browseDirectory(relativePath = '') {
|
getDrives() {
|
||||||
try {
|
const drives = [];
|
||||||
|
const letters = 'CDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
|
||||||
|
for (const letter of letters) {
|
||||||
|
const drivePath = `${letter}:\\`;
|
||||||
|
try {
|
||||||
|
fs.accessSync(drivePath);
|
||||||
|
drives.push({ name: `${letter}:`, isDirectory: true, size: 0 });
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return drives;
|
||||||
|
}
|
||||||
|
|
||||||
|
browseDirectory(relativePath = '', allowSystem = false) {
|
||||||
|
let targetDir;
|
||||||
|
let currentPath;
|
||||||
|
|
||||||
|
if (allowSystem) {
|
||||||
|
currentPath = path.normalize(relativePath || '').replace(/^(\.\.(\/|\\|$))+/, '');
|
||||||
|
if (!currentPath) {
|
||||||
|
currentPath = '';
|
||||||
|
}
|
||||||
|
targetDir = currentPath || 'C:\\';
|
||||||
|
} else {
|
||||||
const safePath = path.normalize(relativePath || '').replace(/^(\.\.(\/|\\|$))+/, '');
|
const safePath = path.normalize(relativePath || '').replace(/^(\.\.(\/|\\|$))+/, '');
|
||||||
const targetDir = path.join(this.uploadDir, safePath);
|
targetDir = path.join(this.uploadDir, safePath);
|
||||||
|
currentPath = safePath;
|
||||||
|
|
||||||
if (!targetDir.startsWith(this.uploadDir)) {
|
if (!targetDir.startsWith(this.uploadDir)) {
|
||||||
return { error: 'Access denied', items: [], currentPath: '' };
|
return { error: 'Access denied', items: [], currentPath: '' };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!fs.existsSync(targetDir)) {
|
|
||||||
return { error: 'Directory not found', items: [], currentPath: safePath };
|
|
||||||
}
|
|
||||||
|
|
||||||
const items = fs.readdirSync(targetDir).map(name => {
|
|
||||||
const itemPath = path.join(targetDir, name);
|
|
||||||
const stat = fs.statSync(itemPath);
|
|
||||||
const isDirectory = stat.isDirectory();
|
|
||||||
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
isDirectory,
|
|
||||||
size: isDirectory ? 0 : stat.size,
|
|
||||||
modified: stat.mtime,
|
|
||||||
type: isDirectory ? 'directory' : path.extname(name)
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
items.sort((a, b) => {
|
|
||||||
if (a.isDirectory && !b.isDirectory) return -1;
|
|
||||||
if (!a.isDirectory && b.isDirectory) return 1;
|
|
||||||
return a.name.localeCompare(b.name);
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
items,
|
|
||||||
currentPath: safePath,
|
|
||||||
parentPath: safePath ? path.dirname(safePath) : null
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to browse directory', { error: error.message });
|
|
||||||
return { error: error.message, items: [], currentPath: relativePath };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const items = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const files = fs.readdirSync(targetDir);
|
||||||
|
|
||||||
|
for (const name of files) {
|
||||||
|
if (name.startsWith('.')) continue;
|
||||||
|
if (name.startsWith('$')) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const itemPath = path.join(targetDir, name);
|
||||||
|
const stat = fs.statSync(itemPath);
|
||||||
|
const isDirectory = stat.isDirectory();
|
||||||
|
items.push({
|
||||||
|
name,
|
||||||
|
isDirectory,
|
||||||
|
size: isDirectory ? 0 : stat.size,
|
||||||
|
modified: stat.mtime,
|
||||||
|
type: isDirectory ? 'directory' : path.extname(name)
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug('Skipped inaccessible file', { name, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Failed to read directory', { targetDir, error: err.message });
|
||||||
|
return { items: [], currentPath: currentPath, parentPath: path.dirname(currentPath) || null };
|
||||||
|
}
|
||||||
|
|
||||||
|
items.sort((a, b) => {
|
||||||
|
if (a.isDirectory && !b.isDirectory) return -1;
|
||||||
|
if (!a.isDirectory && b.isDirectory) return 1;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
const parentPath = currentPath ? path.dirname(currentPath) : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
currentPath: currentPath,
|
||||||
|
parentPath: parentPath === currentPath ? null : parentPath
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,9 +64,15 @@ class FRPService {
|
|||||||
configPath: runtimeConfigPath
|
configPath: runtimeConfigPath
|
||||||
});
|
});
|
||||||
|
|
||||||
this.process = spawn(this.frpcPath, ['-c', runtimeConfigPath], {
|
this.process = spawn('cmd.exe', [
|
||||||
|
'/c',
|
||||||
|
this.frpcPath,
|
||||||
|
'-c',
|
||||||
|
runtimeConfigPath
|
||||||
|
], {
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
windowsHide: true
|
windowsHide: true,
|
||||||
|
shell: false
|
||||||
});
|
});
|
||||||
|
|
||||||
this.isRunning = true;
|
this.isRunning = true;
|
||||||
|
|||||||
129
remote/src/services/opencode/OpenCodeService.js
Normal file
129
remote/src/services/opencode/OpenCodeService.js
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
const { spawn, execSync } = require('child_process');
|
||||||
|
const path = require('path');
|
||||||
|
const config = require('../../config');
|
||||||
|
const logger = require('../../utils/logger');
|
||||||
|
|
||||||
|
class OpenCodeService {
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.process = null;
|
||||||
|
this.isRunning = false;
|
||||||
|
this.port = options.port;
|
||||||
|
this.enabled = options.enabled !== false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = execSync('powershell -Command "Get-Command opencode -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source"', { encoding: 'utf8', windowsHide: true, maxBuffer: 1024 * 1024 });
|
||||||
|
const opencodePath = result.trim();
|
||||||
|
if (opencodePath && opencodePath.length > 0) {
|
||||||
|
let finalPath = opencodePath;
|
||||||
|
if (!finalPath.toLowerCase().endsWith('.cmd') && !finalPath.toLowerCase().endsWith('.exe')) {
|
||||||
|
finalPath = finalPath + '.cmd';
|
||||||
|
}
|
||||||
|
this.opencodePath = finalPath;
|
||||||
|
} else {
|
||||||
|
this.opencodePath = null;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.opencodePath = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (!this.enabled) {
|
||||||
|
logger.info('OpenCode service is disabled');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isRunning) {
|
||||||
|
logger.warn('OpenCode service is already running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const password = config.get('security.password');
|
||||||
|
if (!password) {
|
||||||
|
logger.error('OpenCode password not found in config');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.opencodePath) {
|
||||||
|
logger.error('OpenCode command not found in PATH');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.info('Starting OpenCode service', { port: this.port || 'default', opencodePath: this.opencodePath });
|
||||||
|
|
||||||
|
const env = {
|
||||||
|
...process.env,
|
||||||
|
OPENCODE_SERVER_PASSWORD: password
|
||||||
|
};
|
||||||
|
|
||||||
|
const portArg = this.port ? ' --port ' + this.port : '';
|
||||||
|
this.process = spawn('cmd.exe', [
|
||||||
|
'/c',
|
||||||
|
'chcp 65001 >nul && ' + this.opencodePath + ' serve' + portArg
|
||||||
|
], {
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
env,
|
||||||
|
windowsHide: true
|
||||||
|
});
|
||||||
|
|
||||||
|
this.isRunning = true;
|
||||||
|
|
||||||
|
this.process.stdout.on('data', (data) => {
|
||||||
|
const output = data.toString('utf8').trim();
|
||||||
|
if (output) {
|
||||||
|
logger.info(`[OpenCode] ${output}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.process.stderr.on('data', (data) => {
|
||||||
|
const output = data.toString('utf8').trim();
|
||||||
|
if (output) {
|
||||||
|
logger.error(`[OpenCode] ${output}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.process.on('error', (error) => {
|
||||||
|
logger.error('OpenCode process error', { error: error.message });
|
||||||
|
this.isRunning = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.process.on('close', (code) => {
|
||||||
|
logger.info('OpenCode process closed', { code });
|
||||||
|
this.isRunning = false;
|
||||||
|
this.process = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('OpenCode service started successfully');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to start OpenCode service', { error: error.message });
|
||||||
|
this.isRunning = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
if (!this.isRunning || !this.process) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Stopping OpenCode service');
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.process.kill();
|
||||||
|
this.process = null;
|
||||||
|
this.isRunning = false;
|
||||||
|
logger.info('OpenCode service stopped');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to stop OpenCode service', { error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatus() {
|
||||||
|
return {
|
||||||
|
running: this.isRunning,
|
||||||
|
port: this.port || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = OpenCodeService;
|
||||||
149
remote/src/services/opencode/XCOpenCodeWebService.js
Normal file
149
remote/src/services/opencode/XCOpenCodeWebService.js
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
const { spawn } = require('child_process');
|
||||||
|
const path = require('path');
|
||||||
|
const logger = require('../../utils/logger');
|
||||||
|
|
||||||
|
class XCOpenCodeWebService {
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.process = null;
|
||||||
|
this.isRunning = false;
|
||||||
|
this.port = options.port || 9999;
|
||||||
|
this.enabled = options.enabled !== false;
|
||||||
|
this.healthCheckInterval = 10000;
|
||||||
|
this.healthCheckTimeout = 2000;
|
||||||
|
this.healthCheckTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getExePath() {
|
||||||
|
const exeName = 'XCOpenCodeWeb.exe';
|
||||||
|
const basePath = path.join(__dirname, '../../../xcopencodeweb');
|
||||||
|
return path.join(basePath, exeName);
|
||||||
|
}
|
||||||
|
|
||||||
|
getExeArgs() {
|
||||||
|
return ['--port', this.port.toString()];
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkHealth() {
|
||||||
|
try {
|
||||||
|
const timeoutMs = this.healthCheckTimeout;
|
||||||
|
|
||||||
|
const fetchPromise = fetch(`http://127.0.0.1:${this.port}`);
|
||||||
|
const timeoutPromise = new Promise((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error('Timeout')), timeoutMs)
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await Promise.race([fetchPromise, timeoutPromise]);
|
||||||
|
return response.ok || response.status === 401;
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('[XCOpenCodeWebService] Health check failed:', error.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startHealthCheck() {
|
||||||
|
if (this.healthCheckTimer) {
|
||||||
|
clearInterval(this.healthCheckTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.healthCheckTimer = setInterval(async () => {
|
||||||
|
const isHealthy = await this.checkHealth();
|
||||||
|
if (!isHealthy && this.isRunning) {
|
||||||
|
logger.warn('[XCOpenCodeWebService] Service not responding');
|
||||||
|
}
|
||||||
|
}, this.healthCheckInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
stopHealthCheck() {
|
||||||
|
if (this.healthCheckTimer) {
|
||||||
|
clearInterval(this.healthCheckTimer);
|
||||||
|
this.healthCheckTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (!this.enabled) {
|
||||||
|
logger.info('XCOpenCodeWeb service is disabled');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isRunning && this.process) {
|
||||||
|
logger.warn('XCOpenCodeWeb service is already running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const exePath = this.getExePath();
|
||||||
|
const exeArgs = this.getExeArgs();
|
||||||
|
logger.info(`[XCOpenCodeWebService] Starting from: ${exePath} with args: ${exeArgs.join(' ')}`);
|
||||||
|
|
||||||
|
this.process = spawn(exePath, exeArgs, {
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
detached: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.process.stdout.on('data', (data) => {
|
||||||
|
const output = data.toString().trim();
|
||||||
|
if (output) {
|
||||||
|
logger.info(`[XCOpenCodeWeb] ${output}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.process.stderr.on('data', (data) => {
|
||||||
|
const output = data.toString().trim();
|
||||||
|
if (output) {
|
||||||
|
logger.error(`[XCOpenCodeWeb error] ${output}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.process.on('error', (error) => {
|
||||||
|
logger.error('[XCOpenCodeWebService] Process error', { error: error.message });
|
||||||
|
this.isRunning = false;
|
||||||
|
this.process = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.process.on('exit', (code) => {
|
||||||
|
logger.info('[XCOpenCodeWebService] Process exited', { code });
|
||||||
|
this.isRunning = false;
|
||||||
|
this.process = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.isRunning = true;
|
||||||
|
this.startHealthCheck();
|
||||||
|
|
||||||
|
logger.info(`[XCOpenCodeWebService] Started successfully on port ${this.port}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[XCOpenCodeWebService] Failed to start', { error: error.message });
|
||||||
|
this.isRunning = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
this.stopHealthCheck();
|
||||||
|
|
||||||
|
if (!this.process) {
|
||||||
|
logger.info('[XCOpenCodeWebService] Not running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.info('[XCOpenCodeWebService] Stopping...');
|
||||||
|
|
||||||
|
this.process.kill();
|
||||||
|
this.process = null;
|
||||||
|
this.isRunning = false;
|
||||||
|
|
||||||
|
logger.info('[XCOpenCodeWebService] Stopped');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[XCOpenCodeWebService] Failed to stop', { error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatus() {
|
||||||
|
return {
|
||||||
|
running: this.isRunning,
|
||||||
|
port: this.port
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = XCOpenCodeWebService;
|
||||||
@@ -5,7 +5,7 @@ function getBasePath() {
|
|||||||
if (process.pkg) {
|
if (process.pkg) {
|
||||||
return path.dirname(process.execPath);
|
return path.dirname(process.execPath);
|
||||||
}
|
}
|
||||||
return path.join(__dirname, '../..');
|
return process.cwd();
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPublicPath() {
|
function getPublicPath() {
|
||||||
|
|||||||
99
remote/xcopencodeweb/README.md
Normal file
99
remote/xcopencodeweb/README.md
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# XCOpenCodeWeb
|
||||||
|
|
||||||
|
XCOpenCodeWeb 是一个基于 Web 的 AI 编程助手界面,用于与 OpenCode 服务器交互。
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 使用单文件 exe(推荐)
|
||||||
|
|
||||||
|
直接下载 `XCOpenCodeWeb.exe`,双击运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 默认端口 3000
|
||||||
|
XCOpenCodeWeb.exe
|
||||||
|
|
||||||
|
# 指定端口
|
||||||
|
XCOpenCodeWeb.exe --port 8080
|
||||||
|
|
||||||
|
# 查看帮助
|
||||||
|
XCOpenCodeWeb.exe --help
|
||||||
|
```
|
||||||
|
|
||||||
|
启动后访问 http://localhost:3000
|
||||||
|
|
||||||
|
### 从源码运行
|
||||||
|
|
||||||
|
需要安装 [Bun](https://bun.sh) 或 Node.js 20+
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装依赖
|
||||||
|
bun install
|
||||||
|
|
||||||
|
# 构建前端
|
||||||
|
bun run build
|
||||||
|
|
||||||
|
# 启动服务器
|
||||||
|
bun server/index.js --port 3000
|
||||||
|
```
|
||||||
|
|
||||||
|
## 构建单文件 exe
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web
|
||||||
|
bun run build:exe
|
||||||
|
```
|
||||||
|
|
||||||
|
输出:`web/XCOpenCodeWeb.exe`(约 320MB)
|
||||||
|
|
||||||
|
详细文档:[docs/single-file-exe-build.md](docs/single-file-exe-build.md)
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
├── ui/ # 前端组件库
|
||||||
|
├── web/
|
||||||
|
│ ├── src/ # 前端源码
|
||||||
|
│ ├── server/ # 后端服务器
|
||||||
|
│ ├── bin/ # CLI 工具
|
||||||
|
│ └── dist/ # 构建输出
|
||||||
|
├── docs/ # 文档
|
||||||
|
└── AGENTS.md # AI Agent 参考文档
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常用命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 开发模式
|
||||||
|
bun run dev # 前端热更新
|
||||||
|
bun run dev:server # 启动开发服务器
|
||||||
|
|
||||||
|
# 构建
|
||||||
|
bun run build # 构建前端
|
||||||
|
bun run build:exe # 构建单文件 exe
|
||||||
|
|
||||||
|
# 代码检查
|
||||||
|
bun run type-check:web # TypeScript 类型检查
|
||||||
|
bun run lint:web # ESLint 检查
|
||||||
|
```
|
||||||
|
|
||||||
|
## 依赖
|
||||||
|
|
||||||
|
- [Bun](https://bun.sh) - 运行时和打包工具
|
||||||
|
- [React](https://react.dev) - 前端框架
|
||||||
|
- [Express](https://expressjs.com) - 后端服务器
|
||||||
|
- [Tailwind CSS](https://tailwindcss.com) - 样式框架
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
|
||||||
|
### 环境变量
|
||||||
|
|
||||||
|
| 变量 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `XCOpenCodeWeb_PORT` | 服务器端口(默认 3000) |
|
||||||
|
| `OPENCODE_HOST` | 外部 OpenCode 服务器地址 |
|
||||||
|
| `OPENCODE_PORT` | 外部 OpenCode 端口 |
|
||||||
|
| `OPENCODE_SKIP_START` | 跳过启动 OpenCode |
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
MIT
|
||||||
99
services/xcopencodeweb/README.md
Normal file
99
services/xcopencodeweb/README.md
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# XCOpenCodeWeb
|
||||||
|
|
||||||
|
XCOpenCodeWeb 是一个基于 Web 的 AI 编程助手界面,用于与 OpenCode 服务器交互。
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 使用单文件 exe(推荐)
|
||||||
|
|
||||||
|
直接下载 `XCOpenCodeWeb.exe`,双击运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 默认端口 3000
|
||||||
|
XCOpenCodeWeb.exe
|
||||||
|
|
||||||
|
# 指定端口
|
||||||
|
XCOpenCodeWeb.exe --port 8080
|
||||||
|
|
||||||
|
# 查看帮助
|
||||||
|
XCOpenCodeWeb.exe --help
|
||||||
|
```
|
||||||
|
|
||||||
|
启动后访问 http://localhost:3000
|
||||||
|
|
||||||
|
### 从源码运行
|
||||||
|
|
||||||
|
需要安装 [Bun](https://bun.sh) 或 Node.js 20+
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装依赖
|
||||||
|
bun install
|
||||||
|
|
||||||
|
# 构建前端
|
||||||
|
bun run build
|
||||||
|
|
||||||
|
# 启动服务器
|
||||||
|
bun server/index.js --port 3000
|
||||||
|
```
|
||||||
|
|
||||||
|
## 构建单文件 exe
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web
|
||||||
|
bun run build:exe
|
||||||
|
```
|
||||||
|
|
||||||
|
输出:`web/XCOpenCodeWeb.exe`(约 320MB)
|
||||||
|
|
||||||
|
详细文档:[docs/single-file-exe-build.md](docs/single-file-exe-build.md)
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
├── ui/ # 前端组件库
|
||||||
|
├── web/
|
||||||
|
│ ├── src/ # 前端源码
|
||||||
|
│ ├── server/ # 后端服务器
|
||||||
|
│ ├── bin/ # CLI 工具
|
||||||
|
│ └── dist/ # 构建输出
|
||||||
|
├── docs/ # 文档
|
||||||
|
└── AGENTS.md # AI Agent 参考文档
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常用命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 开发模式
|
||||||
|
bun run dev # 前端热更新
|
||||||
|
bun run dev:server # 启动开发服务器
|
||||||
|
|
||||||
|
# 构建
|
||||||
|
bun run build # 构建前端
|
||||||
|
bun run build:exe # 构建单文件 exe
|
||||||
|
|
||||||
|
# 代码检查
|
||||||
|
bun run type-check:web # TypeScript 类型检查
|
||||||
|
bun run lint:web # ESLint 检查
|
||||||
|
```
|
||||||
|
|
||||||
|
## 依赖
|
||||||
|
|
||||||
|
- [Bun](https://bun.sh) - 运行时和打包工具
|
||||||
|
- [React](https://react.dev) - 前端框架
|
||||||
|
- [Express](https://expressjs.com) - 后端服务器
|
||||||
|
- [Tailwind CSS](https://tailwindcss.com) - 样式框架
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
|
||||||
|
### 环境变量
|
||||||
|
|
||||||
|
| 变量 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `XCOpenCodeWeb_PORT` | 服务器端口(默认 3000) |
|
||||||
|
| `OPENCODE_HOST` | 外部 OpenCode 服务器地址 |
|
||||||
|
| `OPENCODE_PORT` | 外部 OpenCode 端口 |
|
||||||
|
| `OPENCODE_SKIP_START` | 跳过启动 OpenCode |
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
MIT
|
||||||
BIN
services/xcopencodeweb/XCOpenCodeWeb.exe
Normal file
BIN
services/xcopencodeweb/XCOpenCodeWeb.exe
Normal file
Binary file not shown.
BIN
services/xcsdd/XCSDD.exe
Normal file
BIN
services/xcsdd/XCSDD.exe
Normal file
Binary file not shown.
12
shared/modules/opencode/index.ts
Normal file
12
shared/modules/opencode/index.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { defineModule } from '../types.js'
|
||||||
|
|
||||||
|
export const OPENCODE_MODULE = defineModule({
|
||||||
|
id: 'opencode',
|
||||||
|
name: 'OpenCode',
|
||||||
|
basePath: '/opencode',
|
||||||
|
order: 15,
|
||||||
|
version: '1.0.0',
|
||||||
|
backend: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -4,6 +4,8 @@ export interface RemoteDevice {
|
|||||||
serverHost: string
|
serverHost: string
|
||||||
desktopPort: number
|
desktopPort: number
|
||||||
gitPort: number
|
gitPort: number
|
||||||
|
openCodePort: number
|
||||||
|
fileTransferPort: number
|
||||||
password?: string
|
password?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
12
shared/modules/sdd/index.ts
Normal file
12
shared/modules/sdd/index.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { defineModule } from '../types.js'
|
||||||
|
|
||||||
|
export const SDD_MODULE = defineModule({
|
||||||
|
id: 'sdd',
|
||||||
|
name: 'SDD',
|
||||||
|
basePath: '/sdd',
|
||||||
|
order: 16,
|
||||||
|
version: '1.0.0',
|
||||||
|
backend: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
12
shared/modules/terminal/index.ts
Normal file
12
shared/modules/terminal/index.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { defineModule } from '../types.js'
|
||||||
|
|
||||||
|
export const TERMINAL_MODULE = defineModule({
|
||||||
|
id: 'terminal',
|
||||||
|
name: 'Terminal',
|
||||||
|
basePath: '/terminal',
|
||||||
|
order: 17,
|
||||||
|
version: '1.0.0',
|
||||||
|
backend: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
13
shared/modules/voice/index.ts
Normal file
13
shared/modules/voice/index.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export const VOICE_MODULE = 'voice' as const
|
||||||
|
|
||||||
|
export interface VoiceModule {
|
||||||
|
id: typeof VOICE_MODULE
|
||||||
|
name: '语音'
|
||||||
|
icon: 'mic'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const voiceModule: VoiceModule = {
|
||||||
|
id: VOICE_MODULE,
|
||||||
|
name: '语音',
|
||||||
|
icon: 'mic',
|
||||||
|
}
|
||||||
@@ -1 +1 @@
|
|||||||
export type TabType = 'markdown' | 'todo' | 'settings' | 'search' | 'recycle-bin' | 'weread' | 'time-tracking' | 'pydemos' | 'remote' | 'remote-desktop' | 'remote-git' | 'other'
|
export type TabType = 'markdown' | 'todo' | 'settings' | 'search' | 'recycle-bin' | 'weread' | 'time-tracking' | 'pydemos' | 'remote' | 'remote-desktop' | 'remote-git' | 'file-transfer' | 'other'
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ const KNOWN_MODULE_IDS = [
|
|||||||
export function getTabTypeFromPath(filePath: string | null): TabType {
|
export function getTabTypeFromPath(filePath: string | null): TabType {
|
||||||
if (!filePath) return 'other'
|
if (!filePath) return 'other'
|
||||||
|
|
||||||
|
if (filePath.startsWith('file-transfer-panel')) {
|
||||||
|
return 'file-transfer'
|
||||||
|
}
|
||||||
|
|
||||||
if (filePath.startsWith('remote-git://')) {
|
if (filePath.startsWith('remote-git://')) {
|
||||||
return 'remote-git'
|
return 'remote-git'
|
||||||
}
|
}
|
||||||
@@ -43,6 +47,12 @@ export function getTabTypeFromPath(filePath: string | null): TabType {
|
|||||||
export function getFileNameFromPath(filePath: string | null): string {
|
export function getFileNameFromPath(filePath: string | null): string {
|
||||||
if (!filePath) return '未知'
|
if (!filePath) return '未知'
|
||||||
|
|
||||||
|
if (filePath.startsWith('file-transfer-panel')) {
|
||||||
|
const params = new URLSearchParams(filePath.split('?')[1] || '')
|
||||||
|
const deviceName = params.get('device') || ''
|
||||||
|
return deviceName ? `文件传输 - ${deviceName}` : '文件传输'
|
||||||
|
}
|
||||||
|
|
||||||
for (const moduleId of KNOWN_MODULE_IDS) {
|
for (const moduleId of KNOWN_MODULE_IDS) {
|
||||||
if (filePath === `${moduleId}-tab` || filePath === moduleId) {
|
if (filePath === `${moduleId}-tab` || filePath === moduleId) {
|
||||||
const names: Record<string, string> = {
|
const names: Record<string, string> = {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { BrowserRouter, Routes, Route } from 'react-router-dom'
|
import { BrowserRouter, Routes, Route } from 'react-router-dom'
|
||||||
import { NoteBrowser } from '@/pages/NoteBrowser'
|
import { NoteBrowser } from '@/pages/NoteBrowser'
|
||||||
|
import { PopoutPage } from '@/pages/PopoutPage'
|
||||||
import { SettingsSync } from '@/components/settings/SettingsSync'
|
import { SettingsSync } from '@/components/settings/SettingsSync'
|
||||||
import { ErrorBoundary } from '@/components/common/ErrorBoundary'
|
import { ErrorBoundary } from '@/components/common/ErrorBoundary'
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ function App() {
|
|||||||
<TimeTrackerProvider>
|
<TimeTrackerProvider>
|
||||||
<SettingsSync />
|
<SettingsSync />
|
||||||
<Routes>
|
<Routes>
|
||||||
|
<Route path="/popout" element={<PopoutPage />} />
|
||||||
<Route path="/*" element={<NoteBrowser />} />
|
<Route path="/*" element={<NoteBrowser />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</TimeTrackerProvider>
|
</TimeTrackerProvider>
|
||||||
|
|||||||
290
src/components/chat/Chat.tsx
Normal file
290
src/components/chat/Chat.tsx
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
import { useRef, useState, useCallback, useEffect } from 'react'
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from './ui/AlertDialog'
|
||||||
|
import { Messages } from './Messages'
|
||||||
|
import { MultimodalInput } from './MultimodalInput'
|
||||||
|
import { generateUUID } from '@/lib/utils'
|
||||||
|
import { Eraser } from 'lucide-react'
|
||||||
|
import { useDragStore } from '@/stores/dragStore'
|
||||||
|
import { getSettingsConfig } from '@/lib/api'
|
||||||
|
|
||||||
|
type Status = 'submitted' | 'streaming' | 'ready' | 'error'
|
||||||
|
|
||||||
|
interface Attachment {
|
||||||
|
url: string
|
||||||
|
name: string
|
||||||
|
contentType: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatMessage {
|
||||||
|
id: string
|
||||||
|
role: 'user' | 'assistant' | 'system'
|
||||||
|
content: string
|
||||||
|
parts: Array<{ type: string; text?: string }>
|
||||||
|
createdAt?: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
const OPENCODE_API = '/api/opencode'
|
||||||
|
const INITIAL_MESSAGE_LIMIT = 20
|
||||||
|
|
||||||
|
export function Chat({
|
||||||
|
id,
|
||||||
|
initialMessages,
|
||||||
|
onClear,
|
||||||
|
}: {
|
||||||
|
id: string
|
||||||
|
initialMessages: ChatMessage[]
|
||||||
|
onClear?: () => void
|
||||||
|
}) {
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const [messages, setMessages] = useState<ChatMessage[]>(initialMessages)
|
||||||
|
const [hasMoreMessages, setHasMoreMessages] = useState(false)
|
||||||
|
const [input, setInput] = useState<string>('')
|
||||||
|
const [showCreditCardAlert, setShowCreditCardAlert] = useState(false)
|
||||||
|
const [status, setStatus] = useState<Status>('ready')
|
||||||
|
const [attachments, setAttachments] = useState<Attachment[]>([])
|
||||||
|
const [showInputAtBottom, setShowInputAtBottom] = useState(false)
|
||||||
|
const [notebookRoot, setNotebookRoot] = useState<string>('')
|
||||||
|
const { state: dragState } = useDragStore()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getSettingsConfig().then(config => {
|
||||||
|
// 统一 notebookRoot 的斜杠
|
||||||
|
const normalizedRoot = (config.notebookRoot || '').replace(/\\/g, '/')
|
||||||
|
setNotebookRoot(normalizedRoot)
|
||||||
|
}).catch(e => {
|
||||||
|
console.error('Failed to get notebook root:', e)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
|
||||||
|
// 优先从 dragStore 获取
|
||||||
|
let { draggedPath, draggedType } = dragState
|
||||||
|
|
||||||
|
// 如果 dragStore 为空,尝试从 dataTransfer 获取
|
||||||
|
if (!draggedPath) {
|
||||||
|
draggedPath = e.dataTransfer.getData('text/plain')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (draggedPath) {
|
||||||
|
// 统一路径中的斜杠,并拼接完整路径,用【】包围
|
||||||
|
const normalizedPath = draggedPath.replace(/\\/g, '/')
|
||||||
|
const fullPath = notebookRoot ? `${notebookRoot}/${normalizedPath}` : normalizedPath
|
||||||
|
const pathWithBrackets = `【${fullPath}】`
|
||||||
|
setInput(prev => prev + pathWithBrackets)
|
||||||
|
|
||||||
|
// 延迟聚焦输入框
|
||||||
|
setTimeout(() => {
|
||||||
|
const textarea = document.querySelector('textarea') as HTMLTextAreaElement
|
||||||
|
textarea?.focus()
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
}, [dragState, notebookRoot])
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const scrollToBottom = useCallback(() => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: 'auto' })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadMessages = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(OPENCODE_API, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: 'get-messages',
|
||||||
|
sessionId: id,
|
||||||
|
limit: INITIAL_MESSAGE_LIMIT
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
const newMessages = data.messages || []
|
||||||
|
setMessages(newMessages)
|
||||||
|
setHasMoreMessages(data.hasMore ?? newMessages.length >= INITIAL_MESSAGE_LIMIT)
|
||||||
|
setTimeout(scrollToBottom, 100)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load messages:', error)
|
||||||
|
}
|
||||||
|
}, [id, scrollToBottom])
|
||||||
|
|
||||||
|
const loadMoreMessages = useCallback(async () => {
|
||||||
|
if (messages.length === 0) return
|
||||||
|
|
||||||
|
const currentCount = messages.length
|
||||||
|
const newLimit = currentCount + INITIAL_MESSAGE_LIMIT
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(OPENCODE_API, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: 'get-messages',
|
||||||
|
sessionId: id,
|
||||||
|
limit: newLimit
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
const newMessages = data.messages || []
|
||||||
|
|
||||||
|
setMessages(prev => {
|
||||||
|
const existingIds = new Set(prev.map(m => m.id))
|
||||||
|
const uniqueNew = newMessages.filter((m: ChatMessage) => !existingIds.has(m.id))
|
||||||
|
return [...uniqueNew, ...prev]
|
||||||
|
})
|
||||||
|
|
||||||
|
setHasMoreMessages(data.hasMore ?? newMessages.length >= newLimit)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load more messages:', error)
|
||||||
|
}
|
||||||
|
}, [id, messages])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (id) {
|
||||||
|
loadMessages()
|
||||||
|
}
|
||||||
|
}, [id, loadMessages])
|
||||||
|
|
||||||
|
const handleSendMessage = useCallback(async (text: string, atts: Attachment[]) => {
|
||||||
|
const userMessage: ChatMessage = {
|
||||||
|
id: generateUUID(),
|
||||||
|
role: 'user',
|
||||||
|
content: text,
|
||||||
|
parts: [],
|
||||||
|
createdAt: new Date(),
|
||||||
|
}
|
||||||
|
|
||||||
|
setMessages(prev => [...prev, userMessage])
|
||||||
|
setStatus('submitted')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(OPENCODE_API, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: 'prompt',
|
||||||
|
sessionId: id,
|
||||||
|
text,
|
||||||
|
attachments: atts.map(a => ({
|
||||||
|
url: a.url,
|
||||||
|
name: a.name,
|
||||||
|
mediaType: a.contentType,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(await res.text())
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json()
|
||||||
|
|
||||||
|
if (data.message) {
|
||||||
|
setMessages(prev => [...prev, data.message])
|
||||||
|
setShowInputAtBottom(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus('ready')
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error('Send message error:', error)
|
||||||
|
setStatus('error')
|
||||||
|
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Failed to send message'
|
||||||
|
if (errorMessage.includes('credit card')) {
|
||||||
|
setShowCreditCardAlert(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [id])
|
||||||
|
|
||||||
|
const handleStop = useCallback(() => {
|
||||||
|
setStatus('ready')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="overscroll-behavior-contain flex h-full min-w-0 touch-pan-y flex-col bg-background">
|
||||||
|
{onClear && messages.length > 0 && (
|
||||||
|
<div className="absolute top-4 right-4 z-10">
|
||||||
|
<button
|
||||||
|
onClick={onClear}
|
||||||
|
className="flex items-center rounded-md bg-primary p-2 text-primary-foreground hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
<Eraser className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Messages
|
||||||
|
messages={messages}
|
||||||
|
status={status}
|
||||||
|
hasMoreMessages={hasMoreMessages}
|
||||||
|
loadMoreMessages={loadMoreMessages}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
<div
|
||||||
|
className={`mx-auto flex w-full max-w-4xl gap-2 px-2 pb-3 transition-all duration-300 md:px-4 md:pb-4 ${
|
||||||
|
showInputAtBottom || messages.length > 0
|
||||||
|
? 'sticky bottom-0'
|
||||||
|
: 'absolute bottom-[40%] left-0 right-0'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<MultimodalInput
|
||||||
|
attachments={attachments}
|
||||||
|
chatId={id}
|
||||||
|
input={input}
|
||||||
|
messages={messages}
|
||||||
|
sendMessage={handleSendMessage}
|
||||||
|
setAttachments={setAttachments}
|
||||||
|
setInput={setInput}
|
||||||
|
status={status}
|
||||||
|
stop={handleStop}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AlertDialog
|
||||||
|
onOpenChange={setShowCreditCardAlert}
|
||||||
|
open={showCreditCardAlert}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>API 配置需要</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
请在环境变量中配置您的 AI API 密钥。
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => {
|
||||||
|
setShowCreditCardAlert(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
确定
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
9
src/components/chat/Greeting.tsx
Normal file
9
src/components/chat/Greeting.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export function Greeting() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto mt-60 max-w-2xl px-4 text-center">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">
|
||||||
|
开始今天的工作吧!
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
95
src/components/chat/Message.tsx
Normal file
95
src/components/chat/Message.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { memo, useState } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { ArrowDownIcon, SparklesIcon, ChevronDown, ChevronRight } from 'lucide-react'
|
||||||
|
|
||||||
|
interface MessageProps {
|
||||||
|
message: {
|
||||||
|
id: string
|
||||||
|
role: 'user' | 'assistant' | 'system'
|
||||||
|
content: string
|
||||||
|
parts?: Array<{ type: string; text?: string }>
|
||||||
|
}
|
||||||
|
isLoading?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function PureMessage({ message, isLoading }: MessageProps) {
|
||||||
|
const isUser = message.role === 'user'
|
||||||
|
const [reasoningCollapsed, setReasoningCollapsed] = useState(true)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex w-full gap-4 px-4',
|
||||||
|
isUser ? 'justify-end' : 'justify-start'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={cn(
|
||||||
|
'flex max-w-[80%] flex-col gap-1 rounded-2xl px-4 py-3',
|
||||||
|
isUser ? 'bg-gray-100 dark:bg-gray-800' : 'bg-muted/50'
|
||||||
|
)}>
|
||||||
|
{message.parts?.map((part: { type: string; text?: string }, i: number) => {
|
||||||
|
const safeString = (val: unknown): string => {
|
||||||
|
if (val === null || val === undefined) return ''
|
||||||
|
if (typeof val === 'string') return val
|
||||||
|
if (typeof val === 'object') return JSON.stringify(val, null, 2)
|
||||||
|
return String(val)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (part.type === 'text') {
|
||||||
|
return (
|
||||||
|
<div key={i} className={cn('whitespace-pre-wrap text-sm rounded-lg px-3 py-2 bg-gray-100 dark:bg-gray-800', isUser && 'text-primary-foreground')}>
|
||||||
|
{safeString(part.text)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (part.type === 'reasoning') {
|
||||||
|
return (
|
||||||
|
<div key={i} className="rounded border border-gray-500/30 bg-gray-500/10 p-2 text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1 font-semibold cursor-pointer"
|
||||||
|
onClick={() => setReasoningCollapsed(!reasoningCollapsed)}
|
||||||
|
>
|
||||||
|
{reasoningCollapsed ? <ChevronRight className="size-3" /> : <ChevronDown className="size-3" />}
|
||||||
|
<SparklesIcon className="size-3" />
|
||||||
|
Thinking
|
||||||
|
</div>
|
||||||
|
{!reasoningCollapsed && (
|
||||||
|
<div className="mt-1 whitespace-pre-wrap">{safeString(part.text)}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
})}
|
||||||
|
{(!message.parts || message.parts.length === 0) && message.content && (
|
||||||
|
<div className={cn('whitespace-pre-wrap text-sm', isUser && 'text-primary-foreground')}>{message.content}</div>
|
||||||
|
)}
|
||||||
|
{isLoading && (
|
||||||
|
<div className={cn('flex items-center gap-1 text-xs', isUser ? 'text-primary-foreground/70' : 'text-muted-foreground')}>
|
||||||
|
<span className="animate-pulse">Thinking</span>
|
||||||
|
<ArrowDownIcon className="size-3 animate-bounce" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PreviewMessage = memo(PureMessage)
|
||||||
|
|
||||||
|
export function ThinkingMessage() {
|
||||||
|
return (
|
||||||
|
<div className="flex w-full gap-4 px-4">
|
||||||
|
<div className="flex flex-col gap-1 rounded-lg bg-muted/50 p-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<span className="animate-pulse">Thinking</span>
|
||||||
|
<span className="size-1.5 animate-bounce rounded-full bg-muted-foreground" />
|
||||||
|
<span className="size-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:0.2s]" />
|
||||||
|
<span className="size-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:0.4s]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
103
src/components/chat/Messages.tsx
Normal file
103
src/components/chat/Messages.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { ArrowDownIcon, Loader2Icon } from 'lucide-react'
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useMessages } from '@/hooks/useMessages'
|
||||||
|
import { Greeting } from './Greeting'
|
||||||
|
import { PreviewMessage, ThinkingMessage } from './Message'
|
||||||
|
|
||||||
|
type Status = 'submitted' | 'streaming' | 'ready' | 'error'
|
||||||
|
|
||||||
|
interface ChatMessage {
|
||||||
|
id: string
|
||||||
|
role: 'user' | 'assistant' | 'system'
|
||||||
|
content: string
|
||||||
|
parts: Array<{ type: string; text?: string }>
|
||||||
|
createdAt?: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
type MessagesProps = {
|
||||||
|
messages: ChatMessage[]
|
||||||
|
status: Status
|
||||||
|
hasMoreMessages?: boolean
|
||||||
|
loadMoreMessages?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Messages({ messages, status, hasMoreMessages, loadMoreMessages }: MessagesProps) {
|
||||||
|
const [isLoadingMore, setIsLoadingMore] = useState(false)
|
||||||
|
|
||||||
|
const {
|
||||||
|
containerRef: messagesContainerRef,
|
||||||
|
endRef: messagesEndRef,
|
||||||
|
isAtBottom,
|
||||||
|
scrollToBottom,
|
||||||
|
} = useMessages({ status })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (messages.length > 0) {
|
||||||
|
const container = messagesContainerRef.current
|
||||||
|
if (container) {
|
||||||
|
container.scrollTop = container.scrollHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [messages, messagesContainerRef])
|
||||||
|
|
||||||
|
const handleLoadMore = async () => {
|
||||||
|
if (!loadMoreMessages || isLoadingMore) return
|
||||||
|
setIsLoadingMore(true)
|
||||||
|
await loadMoreMessages()
|
||||||
|
setIsLoadingMore(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex-1 bg-background pt-2 pb-4">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 touch-pan-y overflow-y-auto bg-background"
|
||||||
|
ref={messagesContainerRef}
|
||||||
|
>
|
||||||
|
<div className="mx-auto flex min-w-0 max-w-4xl flex-col gap-4 px-2 py-2 md:gap-6 md:px-4">
|
||||||
|
{hasMoreMessages && (
|
||||||
|
<div className="flex justify-center py-2">
|
||||||
|
<button
|
||||||
|
onClick={handleLoadMore}
|
||||||
|
disabled={isLoadingMore}
|
||||||
|
className="flex items-center gap-2 rounded-full border bg-background px-4 py-2 text-sm text-muted-foreground hover:bg-muted disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoadingMore && <Loader2Icon className="size-4 animate-spin" />}
|
||||||
|
{isLoadingMore ? 'Loading...' : 'Load more messages'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{messages.length === 0 && <Greeting />}
|
||||||
|
|
||||||
|
{messages.map((message, index) => (
|
||||||
|
<PreviewMessage
|
||||||
|
key={message.id}
|
||||||
|
message={message}
|
||||||
|
isLoading={status === 'streaming' && messages.length - 1 === index}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{status === 'submitted' && <ThinkingMessage />}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="min-h-[24px] min-w-[24px] shrink-0"
|
||||||
|
ref={messagesEndRef}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
aria-label="Scroll to bottom"
|
||||||
|
className={`absolute bottom-4 left-1/2 z-10 -translate-x-1/2 rounded-full border bg-background p-2 shadow-lg transition-all hover:bg-muted ${
|
||||||
|
isAtBottom
|
||||||
|
? 'pointer-events-none scale-0 opacity-0'
|
||||||
|
: 'pointer-events-auto scale-100 opacity-100'
|
||||||
|
}`}
|
||||||
|
onClick={() => scrollToBottom('smooth')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<ArrowDownIcon className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
177
src/components/chat/MultimodalInput.tsx
Normal file
177
src/components/chat/MultimodalInput.tsx
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import { useCallback, useEffect, useRef } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { ArrowUpIcon, StopIcon } from './icons'
|
||||||
|
import { Button } from './ui/Button'
|
||||||
|
|
||||||
|
type Status = 'submitted' | 'streaming' | 'ready' | 'error'
|
||||||
|
|
||||||
|
interface Attachment {
|
||||||
|
url: string
|
||||||
|
name: string
|
||||||
|
contentType: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatMessage {
|
||||||
|
id: string
|
||||||
|
role: 'user' | 'assistant' | 'system'
|
||||||
|
content: string
|
||||||
|
parts: unknown[]
|
||||||
|
createdAt?: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MultimodalInputProps {
|
||||||
|
chatId: string
|
||||||
|
input: string
|
||||||
|
setInput: (value: string) => void
|
||||||
|
status: Status
|
||||||
|
stop: () => void
|
||||||
|
attachments: Attachment[]
|
||||||
|
setAttachments: (attachments: Attachment[]) => void
|
||||||
|
messages: ChatMessage[]
|
||||||
|
sendMessage: (text: string, attachments: Attachment[]) => Promise<void>
|
||||||
|
className?: string
|
||||||
|
onDrop?: (e: React.DragEvent) => void
|
||||||
|
onDragOver?: (e: React.DragEvent) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MultimodalInput({
|
||||||
|
chatId,
|
||||||
|
input,
|
||||||
|
setInput,
|
||||||
|
status,
|
||||||
|
stop,
|
||||||
|
attachments,
|
||||||
|
setAttachments,
|
||||||
|
sendMessage,
|
||||||
|
className,
|
||||||
|
onDrop,
|
||||||
|
onDragOver,
|
||||||
|
}: MultimodalInputProps) {
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
|
||||||
|
const adjustHeight = useCallback(() => {
|
||||||
|
if (textareaRef.current) {
|
||||||
|
textareaRef.current.style.height = '44px'
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (textareaRef.current) {
|
||||||
|
adjustHeight()
|
||||||
|
}
|
||||||
|
}, [adjustHeight])
|
||||||
|
|
||||||
|
const resetHeight = useCallback(() => {
|
||||||
|
if (textareaRef.current) {
|
||||||
|
textareaRef.current.style.height = '44px'
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleInput = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
setInput(event.target.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitForm = useCallback(() => {
|
||||||
|
if (!input.trim() && attachments.length === 0) return
|
||||||
|
|
||||||
|
sendMessage(input, attachments)
|
||||||
|
|
||||||
|
setAttachments([])
|
||||||
|
resetHeight()
|
||||||
|
setInput('')
|
||||||
|
|
||||||
|
textareaRef.current?.focus()
|
||||||
|
}, [input, setInput, attachments, sendMessage, setAttachments, resetHeight])
|
||||||
|
|
||||||
|
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (event.key === 'Enter' && !event.shiftKey) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!input.trim() && attachments.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (status !== 'ready') {
|
||||||
|
console.warn('Please wait for the model to finish its response!')
|
||||||
|
} else {
|
||||||
|
submitForm()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (textareaRef.current) {
|
||||||
|
const target = textareaRef.current
|
||||||
|
target.style.height = '44px'
|
||||||
|
const scrollHeight = target.scrollHeight
|
||||||
|
if (scrollHeight > 44) {
|
||||||
|
target.style.height = `${Math.min(scrollHeight, 200)}px`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [input])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('relative flex w-full flex-col gap-4', className)}>
|
||||||
|
<div className="relative flex w-full items-end gap-2 rounded-xl border border-border bg-background p-3 shadow-xs transition-all duration-200 focus-within:border-border hover:border-muted-foreground/50">
|
||||||
|
{attachments.length > 0 && (
|
||||||
|
<div className="flex flex-row items-end gap-2 overflow-x-scroll">
|
||||||
|
{attachments.map((attachment) => (
|
||||||
|
<div
|
||||||
|
key={attachment.url}
|
||||||
|
className="relative flex items-center gap-1 rounded-md bg-muted px-2 py-1 text-xs"
|
||||||
|
>
|
||||||
|
<span className="max-w-[100px] truncate">{attachment.name}</span>
|
||||||
|
<button
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => {
|
||||||
|
setAttachments(attachments.filter((a) => a.url !== attachment.url))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
className="max-h-[200px] min-h-[44px] w-full resize-none border-0 bg-transparent p-2 text-base outline-none placeholder:text-muted-foreground [-ms-overflow-style:none] [scrollbar-width:none]"
|
||||||
|
style={{ height: '44px' }}
|
||||||
|
value={input}
|
||||||
|
onChange={handleInput}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
onDrop?.(e)
|
||||||
|
}}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
onDragOver?.(e)
|
||||||
|
}}
|
||||||
|
placeholder="发送消息..."
|
||||||
|
rows={1}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{status === 'submitted' ? (
|
||||||
|
<Button
|
||||||
|
className="size-8 rounded-full bg-foreground p-1 text-background transition-colors duration-200 hover:bg-foreground/90"
|
||||||
|
onClick={() => {
|
||||||
|
stop()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StopIcon size={14} />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
className="size-8 rounded-full bg-primary text-primary-foreground transition-colors duration-200 hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground"
|
||||||
|
disabled={!input.trim() && attachments.length === 0}
|
||||||
|
onClick={submitForm}
|
||||||
|
>
|
||||||
|
<ArrowUpIcon size={14} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
27
src/components/chat/icons.tsx
Normal file
27
src/components/chat/icons.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import {
|
||||||
|
ArrowUp,
|
||||||
|
ArrowDown,
|
||||||
|
Paperclip,
|
||||||
|
StopCircle,
|
||||||
|
ChevronDown,
|
||||||
|
Check,
|
||||||
|
Plus,
|
||||||
|
Trash,
|
||||||
|
PanelLeftClose,
|
||||||
|
PanelLeft,
|
||||||
|
Mic,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
export {
|
||||||
|
ArrowUp as ArrowUpIcon,
|
||||||
|
ArrowDown as ArrowDownIcon,
|
||||||
|
Paperclip as PaperclipIcon,
|
||||||
|
StopCircle as StopIcon,
|
||||||
|
ChevronDown as ChevronDownIcon,
|
||||||
|
Check as CheckIcon,
|
||||||
|
Plus as PlusIcon,
|
||||||
|
Trash as TrashIcon,
|
||||||
|
PanelLeftClose as PanelLeftCloseIcon,
|
||||||
|
PanelLeft as PanelLeftOpenIcon,
|
||||||
|
Mic as MicIcon,
|
||||||
|
}
|
||||||
132
src/components/chat/ui/AlertDialog.tsx
Normal file
132
src/components/chat/ui/AlertDialog.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { buttonVariants } from './Button'
|
||||||
|
|
||||||
|
const AlertDialog = AlertDialogPrimitive.Root
|
||||||
|
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||||
|
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
||||||
|
|
||||||
|
const AlertDialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const AlertDialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
className={cn(
|
||||||
|
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
))
|
||||||
|
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const AlertDialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn('flex flex-col space-y-2 text-center sm:text-left', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
AlertDialogHeader.displayName = 'AlertDialogHeader'
|
||||||
|
|
||||||
|
const AlertDialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
AlertDialogFooter.displayName = 'AlertDialogFooter'
|
||||||
|
|
||||||
|
const AlertDialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
className={cn('text-lg font-semibold', className)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const AlertDialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
className={cn('text-sm text-muted-foreground', className)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName
|
||||||
|
|
||||||
|
const AlertDialogAction = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Action
|
||||||
|
className={cn(buttonVariants(), className)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||||
|
|
||||||
|
const AlertDialogCancel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({ variant: 'outline' }),
|
||||||
|
'mt-2 sm:mt-0',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
}
|
||||||
52
src/components/chat/ui/Button.tsx
Normal file
52
src/components/chat/ui/Button.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { Slot } from '@radix-ui/react-slot'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
||||||
|
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||||
|
outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||||
|
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||||
|
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||||
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-9 px-4 py-2',
|
||||||
|
sm: 'h-8 rounded-md px-3 text-xs',
|
||||||
|
lg: 'h-10 rounded-md px-8',
|
||||||
|
icon: 'h-9 w-9',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : 'button'
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Button.displayName = 'Button'
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
@@ -229,7 +229,8 @@ const SidebarContent = forwardRef<HTMLDivElement, SidebarProps>(({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="absolute right-0 top-0 bottom-0 w-1 hover:bg-gray-300 dark:hover:bg-gray-600 cursor-col-resize transition-colors z-10"
|
className="absolute right-0 top-0 bottom-0 hover:bg-gray-300 dark:hover:bg-gray-600 cursor-col-resize transition-colors z-10"
|
||||||
|
style={{ right: -2, width: 4 }}
|
||||||
onMouseDown={onResizeStart}
|
onMouseDown={onResizeStart}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,12 +15,13 @@ export interface TabBarProps {
|
|||||||
onTabClose: (file: FileItem, e: React.MouseEvent) => void
|
onTabClose: (file: FileItem, e: React.MouseEvent) => void
|
||||||
onCloseOther?: (file: FileItem) => void
|
onCloseOther?: (file: FileItem) => void
|
||||||
onCloseAll?: () => void
|
onCloseAll?: () => void
|
||||||
|
onPopOut?: (file: FileItem) => void
|
||||||
className?: string
|
className?: string
|
||||||
style?: React.CSSProperties
|
style?: React.CSSProperties
|
||||||
variant?: 'default' | 'titlebar'
|
variant?: 'default' | 'titlebar'
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TabBar = ({ openFiles, activeFile, onTabClick, onTabClose, onCloseOther, onCloseAll, className, style, variant = 'default' }: TabBarProps) => {
|
export const TabBar = ({ openFiles, activeFile, onTabClick, onTabClose, onCloseOther, onCloseAll, onPopOut, className, style, variant = 'default' }: TabBarProps) => {
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||||
const { opacity } = useWallpaper()
|
const { opacity } = useWallpaper()
|
||||||
const [contextMenu, setContextMenu] = useState<{
|
const [contextMenu, setContextMenu] = useState<{
|
||||||
@@ -61,7 +62,16 @@ export const TabBar = ({ openFiles, activeFile, onTabClick, onTabClose, onCloseO
|
|||||||
handleCloseContextMenu()
|
handleCloseContextMenu()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handlePopOut = () => {
|
||||||
|
if (contextMenu.file && onPopOut) {
|
||||||
|
onPopOut(contextMenu.file)
|
||||||
|
}
|
||||||
|
handleCloseContextMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
const isHomeTab = contextMenu.file?.path === HOME_TAB_ID
|
||||||
const contextMenuItems = [
|
const contextMenuItems = [
|
||||||
|
...(!isHomeTab ? [{ label: '在新窗口中打开', onClick: handlePopOut }] : []),
|
||||||
{ label: '关闭其他标签页', onClick: handleCloseOther },
|
{ label: '关闭其他标签页', onClick: handleCloseOther },
|
||||||
{ label: '关闭所有标签页', onClick: handleCloseAll }
|
{ label: '关闭所有标签页', onClick: handleCloseAll }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export interface TitleBarProps {
|
|||||||
onTabClose: (file: FileItem, e: React.MouseEvent) => void
|
onTabClose: (file: FileItem, e: React.MouseEvent) => void
|
||||||
onCloseOther?: (file: FileItem) => void
|
onCloseOther?: (file: FileItem) => void
|
||||||
onCloseAll?: () => void
|
onCloseAll?: () => void
|
||||||
|
onPopOut?: (file: FileItem) => void
|
||||||
opacity: number
|
opacity: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ export const TitleBar = ({
|
|||||||
onTabClose,
|
onTabClose,
|
||||||
onCloseOther,
|
onCloseOther,
|
||||||
onCloseAll,
|
onCloseAll,
|
||||||
|
onPopOut,
|
||||||
opacity
|
opacity
|
||||||
}: TitleBarProps) => (
|
}: TitleBarProps) => (
|
||||||
<div
|
<div
|
||||||
@@ -35,6 +37,7 @@ export const TitleBar = ({
|
|||||||
onTabClose={onTabClose}
|
onTabClose={onTabClose}
|
||||||
onCloseOther={onCloseOther}
|
onCloseOther={onCloseOther}
|
||||||
onCloseAll={onCloseAll}
|
onCloseAll={onCloseAll}
|
||||||
|
onPopOut={onPopOut}
|
||||||
variant="titlebar"
|
variant="titlebar"
|
||||||
className="h-full border-b-0"
|
className="h-full border-b-0"
|
||||||
style={{ backgroundColor: 'transparent' }}
|
style={{ backgroundColor: 'transparent' }}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export const MarkdownTabPage: React.FC<MarkdownTabPageProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="max-w-4xl mx-auto w-full"
|
className="w-full px-10 py-4"
|
||||||
style={{ zoom: zoom / 100 }}
|
style={{ zoom: zoom / 100 }}
|
||||||
>
|
>
|
||||||
<div className="h-full w-full">
|
<div className="h-full w-full">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export { useDialogState, useErrorDialogState } from './useDialogState'
|
|||||||
export { useNoteBrowser, type UseNoteBrowserReturn } from './useNoteBrowser'
|
export { useNoteBrowser, type UseNoteBrowserReturn } from './useNoteBrowser'
|
||||||
export { useNoteContent } from './useNoteContent'
|
export { useNoteContent } from './useNoteContent'
|
||||||
export { useAutoLoadTabContent } from './useAutoLoadTabContent'
|
export { useAutoLoadTabContent } from './useAutoLoadTabContent'
|
||||||
|
export { usePopOutTab } from './usePopOutTab'
|
||||||
|
|
||||||
export { useFileSystemController } from './useFileSystemController'
|
export { useFileSystemController } from './useFileSystemController'
|
||||||
export { useFileTabs } from './useFileTabs'
|
export { useFileTabs } from './useFileTabs'
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ export const useMarkdownLogic = ({
|
|||||||
const readOnlyRef = useRef(readOnly)
|
const readOnlyRef = useRef(readOnly)
|
||||||
const ctxRef = useRef<Ctx | null>(null)
|
const ctxRef = useRef<Ctx | null>(null)
|
||||||
|
|
||||||
|
const lastContentRef = useRef<string>('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onChangeRef.current = onChange
|
onChangeRef.current = onChange
|
||||||
}, [])
|
}, [])
|
||||||
@@ -117,23 +119,28 @@ export const useMarkdownLogic = ({
|
|||||||
}
|
}
|
||||||
}, [readOnly])
|
}, [readOnly])
|
||||||
|
|
||||||
// 在只读模式下动态更新内容
|
// 在只读模式下动态更新内容(仅当 content 真正变化时)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ctxRef.current || !readOnly) return
|
if (!ctxRef.current || !readOnly) return
|
||||||
|
|
||||||
|
// 只有当 content 真正变化时才更新编辑器
|
||||||
|
if (content === lastContentRef.current) return
|
||||||
|
lastContentRef.current = content
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const view = ctxRef.current.get(editorViewCtx)
|
const view = ctxRef.current.get(editorViewCtx)
|
||||||
const parser = ctxRef.current.get(parserCtx)
|
const parser = ctxRef.current.get(parserCtx)
|
||||||
const doc = parser(content)
|
const doc = parser(content)
|
||||||
if (!doc) return
|
if (!doc) return
|
||||||
|
|
||||||
const state = view.state
|
view.dispatch(view.state.tr.replaceWith(0, view.state.doc.content.size, doc))
|
||||||
view.dispatch(state.tr.replaceWith(0, state.doc.content.size, doc))
|
|
||||||
} catch {
|
} catch {
|
||||||
// 编辑器可能尚未就绪
|
// 编辑器可能尚未就绪
|
||||||
}
|
}
|
||||||
}, [content, readOnly])
|
}, [content, readOnly])
|
||||||
|
|
||||||
return useEditor((root) => {
|
return useEditor((root) => {
|
||||||
|
lastContentRef.current = content
|
||||||
return Editor.make()
|
return Editor.make()
|
||||||
.config((ctx) => {
|
.config((ctx) => {
|
||||||
ctxRef.current = ctx
|
ctxRef.current = ctx
|
||||||
|
|||||||
58
src/hooks/domain/usePopOutTab.ts
Normal file
58
src/hooks/domain/usePopOutTab.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useTabStore, TabState } from '@/stores'
|
||||||
|
|
||||||
|
export interface PopOutTabData {
|
||||||
|
file: {
|
||||||
|
name: string
|
||||||
|
path: string
|
||||||
|
type: 'file' | 'dir'
|
||||||
|
size: number
|
||||||
|
modified: string
|
||||||
|
}
|
||||||
|
content: string
|
||||||
|
unsavedContent: string
|
||||||
|
isEditing: boolean
|
||||||
|
loading: boolean
|
||||||
|
loaded: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePopOutTab() {
|
||||||
|
const { selectFile } = useTabStore()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = window.electronAPI?.onTabDataReceived((tabData: PopOutTabData) => {
|
||||||
|
console.log('[PopOut] Received tab data:', tabData)
|
||||||
|
|
||||||
|
if (!tabData?.file?.path) {
|
||||||
|
console.error('[PopOut] Invalid tab data received')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { file, content, unsavedContent, isEditing, loading, loaded } = tabData
|
||||||
|
|
||||||
|
const newTab: TabState = {
|
||||||
|
file: file as any,
|
||||||
|
content: content || '',
|
||||||
|
unsavedContent: unsavedContent || content || '',
|
||||||
|
isEditing: isEditing || false,
|
||||||
|
loading: false,
|
||||||
|
loaded: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
useTabStore.setState((state) => {
|
||||||
|
const newTabs = new Map(state.tabs)
|
||||||
|
newTabs.set(file.path, newTab)
|
||||||
|
return {
|
||||||
|
tabs: newTabs,
|
||||||
|
activeTabId: file.path,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
selectFile(file as any)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe?.()
|
||||||
|
}
|
||||||
|
}, [selectFile])
|
||||||
|
}
|
||||||
@@ -34,5 +34,5 @@ export const useSidebarResize = (initialWidth: number = 250) => {
|
|||||||
}
|
}
|
||||||
}, [isResizing])
|
}, [isResizing])
|
||||||
|
|
||||||
return { sidebarWidth, startResizing }
|
return { sidebarWidth, startResizing, isResizing }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export interface UseSidebarStateReturn {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useSidebarState(): UseSidebarStateReturn {
|
export function useSidebarState(): UseSidebarStateReturn {
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||||
const [refreshKey, setRefreshKey] = useState(0)
|
const [refreshKey, setRefreshKey] = useState(0)
|
||||||
|
|
||||||
const bumpRefresh = useCallback(() => setRefreshKey((prev) => prev + 1), [])
|
const bumpRefresh = useCallback(() => setRefreshKey((prev) => prev + 1), [])
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user