Compare commits

...

75 Commits

Author SHA1 Message Date
9caa43d4a2 fix(terminal): defer TerminalService start to user interaction 2026-03-22 01:05:06 +08:00
dff4674c54 fix(terminal): change --port=9997 to --port 9997 and remove --headless 2026-03-22 00:30:54 +08:00
19e7a51b61 Revert "fix: prevent multiple initialization and state race condition in PopoutPage"
This reverts commit 69bd91797d.
2026-03-22 00:18:19 +08:00
69bd91797d fix: prevent multiple initialization and state race condition in PopoutPage
- Use useRef to prevent multiple initializations
- Remove selectFile call that may cause state race condition
- Directly update store without selectFile to preserve isEditing state
2026-03-22 00:16:22 +08:00
3b1f99951e fix: pass tab content via URL params instead of IPC
- Pass content, unsavedContent, isEditing via URL query params
- PopoutPage reads directly from URL params on mount
- Eliminates IPC race condition entirely
- Direct store update with loaded:true to prevent auto-reload
2026-03-22 00:09:07 +08:00
2d87c267cf fix: wait for renderer ready before sending tab data
- Wait for did-finish-load event in transfer-tab-data handler
- Await transferTabData before closing file in handlePopOut
- Remove arbitrary 500ms timeout, now uses proper IPC ack flow
2026-03-22 00:06:01 +08:00
d0e286e4bb fix: prevent state loss when popout tab and hide option for home tab
- Force loaded:true and loading:false in usePopOutTab to prevent auto-load overwriting
- Hide '在新窗口中打开' option for home tab in context menu
2026-03-22 00:00:34 +08:00
aa5895873b feat: transfer tab state to popout window and close tab in main window
- Restore transfer-tab-data IPC for transferring tab state
- Create usePopOutTab hook to receive tab data in new window
- Update handlePopOut to transfer data and close tab in main window
- Add PopOutTabData interface for type safety
2026-03-21 23:52:02 +08:00
c37e6ab4f2 feat: add titlebar to popout window with minimize/maximize/close buttons
- Add window-minimize, window-maximize, window-close, window-is-maximized IPC handlers
- Add titlebar with window controls to PopoutPage
- Update preload and types for new window control APIs
2026-03-21 23:46:21 +08:00
f160adbdb1 feat: add 'pop out tab as new window' functionality
- Add createWindow IPC for creating secondary windows
- Add PopoutPage for content-only rendering in new windows
- Add multi-window management to electron state
- Add '在新窗口中打开' context menu to tabs
- Fix: Use standard URL path instead of hash for React Router routing
2026-03-21 23:42:48 +08:00
43828a87f0 chore: remove build artifacts and large binary files (xcsdd service binaries, gitea data) 2026-03-21 20:11:17 +08:00
2eb5a167b3 fix: 使用 taskkill /T 终止进程树,确保子进程也被关闭 2026-03-20 14:13:00 +08:00
8ee86c7b0f fix: 修复服务启动时的竞态条件,等待健康检查通过后再报告就绪 2026-03-20 14:12:00 +08:00
9effdcd070 fix: Terminal 启动参数保持 --port=9997 格式 2026-03-20 14:05:28 +08:00
7c7b334f5c chore: 更新 gitignore 忽略运行时数据,保留 Gitea 配置 2026-03-20 13:48:38 +08:00
8716f6f684 docs: 更新 README 添加 SDD 和 Terminal 模块说明 2026-03-20 13:45:57 +08:00
1d33a8e14f fix: 更新 Terminal 模块 exe 名称为 XCTerminal.exe 2026-03-20 13:33:28 +08:00
7db1f9162b feat: 默认折叠左侧文件列表 2026-03-20 13:10:24 +08:00
d54510a864 feat: 添加 Terminal 模块,启动 XCCMD.exe 服务 2026-03-20 13:08:43 +08:00
28df633b00 feat: 完善 SDD 模块配置,添加 --headless 参数 2026-03-20 13:02:58 +08:00
c83f23c319 feat: 添加 SDD (规范驱动开发) 模块 2026-03-18 16:17:30 +08:00
90517f2289 feat: 添加语音模块支持,优化服务启动方式 2026-03-17 04:03:39 +08:00
308df54a15 fix: 修复关闭软件后 OpenCode 进程泄漏问题
- 使用 taskkill /F /T 强制终止进程树
- 在 before-quit 中 await 等待服务停止完成
- 修复 stop 方法中可能的空指针问题
2026-03-16 13:11:46 +08:00
dcd1fcd709 chore: 添加新依赖包 (@opencode-ai/sdk, @radix-ui, class-variance-authority, tailwind-merge) 2026-03-15 00:02:10 +08:00
18c02053da feat(opencode): 将 iframe 替换为 webview 避免 Tauri 应用在 iframe 中卡死 2026-03-14 23:56:02 +08:00
3c353cb701 fix: 修复打包后 XCOpenCodeWeb.exe 路径问题 2026-03-14 23:11:38 +08:00
0d5cd329ca feat(home): add Thinking fold button, improve message styling 2026-03-14 22:56:42 +08:00
e950484af6 feat(home): add drag file to chat input, add settings config API 2026-03-14 22:22:35 +08:00
cbc1af7348 feat(home): add AI chat interface with OpenCode integration 2026-03-14 21:53:52 +08:00
88d42b37a6 feat(opencode): 改进 XCOpenCodeWeb 服务管理和健康检查 2026-03-14 20:44:15 +08:00
b5343bcd9d Update xcopencodeweb port to 3002 2026-03-14 16:38:25 +08:00
9b22b647f2 feat(remote): 添加 XCOpenCodeWeb 服务管理
- 新增 XCOpenCodeWebService.js 服务模块
- 支持启动/停止/健康检测(每10秒)
- 随 remote 服务启动/退出
- 配置文件添加 xcopencodeweb 配置
- 修复 opencode 默认端口配置
2026-03-14 16:02:05 +08:00
50cd1e29c9 fix(remote): 移除默认端口配置,使用 opencode 默认端口 4096 2026-03-14 15:27:37 +08:00
ba02eb10a7 fix: 端口改为 9999 2026-03-14 15:12:09 +08:00
7c656785c8 fix: XCOpenCodeWeb 启动时传入端口参数 --port 3002 2026-03-13 23:17:52 +08:00
f692961823 refactor: 统一端口配置,通过 IPC 获取而非硬编码 2026-03-13 21:20:31 +08:00
1be470f45b fix: 端口改回 3002 2026-03-13 21:15:26 +08:00
96c709f109 fix: 修正 XCOpenCodeWeb 端口为 3000 2026-03-13 21:14:35 +08:00
fd77455f5b fix: 修改 XCOpenCodeWeb.exe 路径为 services/xcopencodeweb/
- 路径从 bin/ 改为 services/xcopencodeweb/
- 更新 package.json 打包配置
- 更新 .gitignore
2026-03-13 21:04:25 +08:00
72d79ae214 feat: 实现 OpenCode 页面生命周期管理 XCOpenCodeWeb.exe
- 新增 electron/services/xcOpenCodeWebService.ts 服务管理模块
- 标签页打开时启动 XCOpenCodeWeb.exe,关闭时停止
- 使用 iframe 在 OpenCode 页面显示 Web 服务 (端口 3002)
- 添加 bin 目录打包配置
- 添加 TypeScript 类型定义
2026-03-13 20:55:34 +08:00
53c1045406 revert: 回滚之前的错误修改,恢复为命令行模式 2026-03-13 20:42:02 +08:00
fd2255c83a feat: 支持 XCOpenCodeWeb.exe 配置和打包
- 添加 electron/config.ts 配置文件
- 支持 command(命令行) 和 exe 两种模式
- 更新 package.json 打包配置,添加 bin 目录
- 更新 .gitignore 忽略 bin/*.exe
2026-03-13 20:34:07 +08:00
986ecb2561 fix: 修复 ai 模块中 Python 脚本路径问题
使用 PROJECT_ROOT 替代 __dirname 计算,确保打包后能正确找到 tools 文件夹
2026-03-13 20:31:54 +08:00
8d4a9a3704 feat: 将 OpenCode 服务管理抽取为独立模块
- 创建 electron/services/opencodeService.ts 独立服务模块
- 支持健康检测(每10秒)、自动重启(最多3次)
- 随软件生命周期自动启动/停止
2026-03-13 20:30:02 +08:00
e6c41491b3 chore: 移除保存调试日志 2026-03-13 19:00:27 +08:00
cd70b50180 feat: 添加 opencode 模块和相关服务 2026-03-13 18:39:58 +08:00
96390df254 chore: 添加保存调试日志、修复 time-tracking 类型、简化首页 2026-03-13 18:39:08 +08:00
371d4ce327 fix: 修复 markdown 编辑保存后内容丢失的问题
- 在 saveContent 中缓存 unsavedContent,避免 async 期间的竞态条件
- 在 useMarkdownLogic 中添加 lastContentRef 跟踪内容变化,防止不必要的编辑器更新
2026-03-13 18:38:38 +08:00
cd1b541427 chore: 移除 dist-electron 构建产物 2026-03-13 16:10:52 +08:00
668a1cb473 chore: 忽略 dist-electron 构建产物 2026-03-13 16:10:16 +08:00
517592e216 chore: 移除构建产物,保持仓库精简 2026-03-13 16:09:35 +08:00
3e360c1807 chore: 忽略 dist-api 和 XCOpenCodeWeb.exe,说明其来源 2026-03-13 16:07:36 +08:00
8bb2e643d8 chore: 添加远程桌面控制组件、文档和构建产物 2026-03-13 16:04:21 +08:00
67a19d486b docs: 完善 README,补充远程桌面控制和 AI 编程助手说明 2026-03-13 16:03:38 +08:00
04fc326a8d fix: 修复侧边栏拖动条在网页界面区域失效的问题 2026-03-11 22:11:27 +08:00
1b80fd036d feat(remote): 支持文件并行上传下载 2026-03-11 21:07:13 +08:00
320d2654f5 fix: 统一窗口初始分辨率与最小分辨率,调整页面padding一致性 2026-03-11 20:44:55 +08:00
bbd33339a5 fix: 修复打包后模块无法加载的问题,改用静态模块加载 2026-03-11 01:32:06 +08:00
1fa17f7c9d fix(remote): 调整传输队列默认高度,优化按钮夜间模式样式 2026-03-10 19:16:02 +08:00
7a39fc3bce fix(remote): 修复文件传输返回按钮不刷新列表的问题,过滤回收站等系统文件 2026-03-10 19:09:39 +08:00
2503d8be64 refactor(home): 将首页改造成opencode服务入口页面
- 移除ChatGPT风格对话界面相关代码
- 添加在首页自动启动/停止opencode serve的IPC调用
- 首页使用webview加载opencode服务器界面
2026-03-10 16:20:32 +08:00
de4c101b36 feat(remote): 实现文件上传真实进度显示
- 使用分块上传替代一次性上传
- 调用 /upload/start → /upload/chunk → /upload/merge 接口
- 通过 IPC 事件实时推送上传进度到前端
- 修复 merge 时未使用目标路径的问题
2026-03-10 15:36:10 +08:00
433db24688 feat(remote): 实现文件下载真实进度显示
- 下载改用流式读取,计算真实进度百分比
- 通过 IPC 事件实时推送进度到前端
- 支持 Content-Length 计算下载进度
2026-03-10 14:59:11 +08:00
40f99f0c49 feat: 首页改造成ChatGPT风格对话界面,调整各页面padding布局 2026-03-10 10:49:24 +08:00
8839ec244a feat(remote): 下载改成本地面板选择目录 2026-03-10 02:10:21 +08:00
073abafdfd fix(remote): 修复远程下载文件路径问题 2026-03-10 01:50:11 +08:00
84e455d9a6 feat(remote): 完善文件传输功能及WebSocket支持 2026-03-10 01:41:02 +08:00
6d5520dfa5 feat(remote): 文件传输改用Electron IPC通道
- 主进程新增4个IPC handler处理远程文件操作
- 前端通过IPC调用而非浏览器fetch访问远程API
- Remote服务新增3003端口专门处理文件传输
- 上传使用文件路径方案,下载使用保存对话框方案
2026-03-10 00:34:02 +08:00
788757b785 feat(remote): 新增文件传输专用端口配置
- 添加 fileTransferPort 字段到 RemoteDevice 类型
- 新增 frp 穿透配置: 3003 -> 8083
- 文件传输功能改用专用端口,避免与远程桌面端口共用
- 配置对话框新增文件传输端口设置
2026-03-10 00:09:01 +08:00
48fd2f5463 fix(remote): 上传直接写入用户选择的目录而非uploads文件夹 2026-03-09 20:11:56 +08:00
88f265757c fix(remote): 上传路由修复 2026-03-09 20:08:46 +08:00
4273b3d43b fix(remote): 上传下载现在使用文件面板当前选择的路径 2026-03-09 19:48:15 +08:00
4c18edf74f fix(remote): 添加 /api/files/upload 路由支持文件上传 2026-03-09 19:41:42 +08:00
d65b3e7909 feat(remote): 支持浏览系统磁盘目录
- 添加 getDrives() 方法获取磁盘驱动器列表
- 修改 browseDirectory() 支持 allowSystem 参数浏览系统路径
- 添加 /api/files/drives 路由
- 修改前端 RemoteFilePanel 支持显示驱动器和系统目录浏览
2026-03-09 19:21:09 +08:00
49bf8a97d2 feat(remote): 添加 CORS 中间件支持文件跨域访问 2026-03-09 17:27:47 +08:00
135 changed files with 9784 additions and 4091 deletions

18
.gitignore vendored
View File

@@ -26,6 +26,13 @@ dist-ssr
# Build output
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/tongyi/ppt_output/
@@ -35,4 +42,13 @@ tools/mineru/__pycache__/
tools/blog/__pycache__/
# Notebook pydemos backup
notebook/
notebook/
# Gitea data (运行时数据,不需要版本控制)
remote/gitea/data/
# SDD service (来自独立仓库)
services/xcsdd/
# Terminal service
services/xcterminal/

View File

@@ -1,6 +1,6 @@
# 贡献指南
感谢你对 XCNote 项目的兴趣!我们欢迎任何形式的贡献,包括但不限于:
感谢你对 XCDesktop 项目的兴趣!我们欢迎任何形式的贡献,包括但不限于:
- 🐛 报告 Bug
- 💡 提出新功能建议
@@ -19,8 +19,8 @@
### 克隆项目
```bash
git clone https://github.com/your-repo/XCNote.git
cd XCNote
git clone https://github.com/your-repo/XCDesktop.git
cd XCDesktop
```
### 安装依赖
@@ -162,7 +162,7 @@ fix(editor): 修复保存内容丢失问题
## 项目结构概览
```
XCNote/
XCDesktop/
├── src/ # 前端源码
│ ├── components/ # UI 组件
│ ├── contexts/ # React Context

View File

@@ -8,12 +8,14 @@
[![Vite](https://img.shields.io/badge/Vite-6.3.5-646CFF?style=flat-square)](https://vitejs.dev/)
[![Tailwind CSS](https://img.shields.io/badge/Tailwind%20CSS-3.4.17-06B6D4?style=flat-square)](https://tailwindcss.com/)
一站式 AI 工作台 - 集成笔记管理、时间追踪、任务管理、AI 辅助等多种功能
一站式 AI 工作台 - 集成笔记管理、时间追踪、远程桌面控制、AI 辅助等多种功能
</div>
## ✨ 核心特性
- **🖥️ 远程桌面控制** - 屏幕监控与远程控制,支持内网穿透,随时随地访问
- **🤖 AI 编程助手** - Web 版 OpenCode 界面,随时随地编程
- **🤖 AI 集成** - 深度集成通义万相、豆包等 AI 能力支持语音转文字、视频解析、PPT 提取等
- **📝 Markdown 笔记管理** - 基于 Milkdown 编辑器支持数学公式KaTeX、代码高亮Prism、表格、任务列表等
- **⏱️ 时间追踪** - 记录学习和工作时间,生成生产力统计图表
@@ -86,6 +88,9 @@ XCDesktop/
│ │ ├── pydemos/ # Python Demo
│ │ ├── weread/ # 微信读书
│ │ └── remote/ # 远程网页
│ │ │ ├── opencode/ # AI 编程助手
│ │ │ ├── sdd/ # 规范驱动开发
│ │ │ └── terminal/ # 终端
│ ├── stores/ # Zustand 状态管理
│ └── types/ # 类型定义
├── api/ # 后端 API (Express)
@@ -96,9 +101,9 @@ XCDesktop/
│ │ ├── ai/ # AI 集成
│ │ ├── document-parser/ # 文档解析
│ │ ├── pydemos/ # Python Demo
│ │ ├── recycle-bin/ # 回收站
│ │ ├── recycle-bin/ # 回收站
│ │ ├── remote/ # 远程网页
│ │ ├── time-tracking/ # 时间追踪
│ │ ├── time-tracking/ # 时间追踪
│ │ └── todo/ # 任务管理
│ ├── middlewares/ # 中间件
│ ├── schemas/ # 数据验证
@@ -107,8 +112,17 @@ XCDesktop/
│ └── events/ # 事件总线
├── electron/ # Electron 主进程
├── shared/ # 共享类型和配置
├── remote/ # 远程桌面监控系统
│ ├── src/ # 后端服务
│ ├── public/ # 前端页面
│ ├── frp/ # 内网穿透 (FRP)
│ └── gitea/ # 自托管 Git 服务
├── service/ # Web 版 AI 编程助手
│ └── xcopencodeweb/ # OpenCode Web 界面
├── tools/ # 工具脚本
│ └── tongyi/ # 通义万相 AI 工具
├── command/ # 项目任务文档
├── public/ # 静态资源
├── notebook/ # 笔记数据存储(运行时)
└── release/ # 构建输出
```
@@ -129,6 +143,8 @@ XCDesktop/
| 模块 | 功能描述 |
|------|----------|
| 远程桌面控制 | 屏幕监控与远程控制,支持内网穿透,随时随地访问 |
| AI 编程助手 | Web 版 OpenCode 界面,随时随地编程 |
| AI 集成 | 通义万相语音转文字、视频解析、PPT 提取等 |
| 文档解析 | 支持导入博客、PDF 等格式 |
| 时间追踪 | 记录工作/学习时间,统计生产力 |
@@ -137,6 +153,8 @@ XCDesktop/
| Python Demo | Python 脚本管理 |
| 微信读书 | 微信读书网页版集成 |
| 远程网页 | 内置浏览器,访问任意网页 |
| SDD | 规范驱动开发,自动化流程 |
| 终端 | 集成终端界面,管理和执行命令 |
## 🛠️ 技术栈
@@ -164,6 +182,13 @@ XCDesktop/
- **框架**: Electron 40
- **日志**: electron-log
### 远程控制
- **屏幕捕获**: FFmpeg, H.264 编码
- **流媒体**: WebSocket
- **内网穿透**: FRP
- **Git 服务**: Gitea
### AI 工具
- **语音识别**: 通义听悟
@@ -184,6 +209,25 @@ XCDesktop/
└── 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)。

3
api/.env Normal file
View 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
View File

@@ -0,0 +1,2 @@
MINIMAX_API_KEY=your_api_key_here
MINIMAX_GROUP_ID=your_group_id_here

View File

@@ -23,8 +23,12 @@ import { apiModules } from './modules/index.js'
import { validateModuleConsistency } from './infra/moduleValidator.js'
import path from 'path'
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()
export const container = new ServiceContainer()

View File

@@ -20,7 +20,7 @@ export const config = {
},
get tempRoot(): string {
return path.join(os.tmpdir(), 'xcnote_uploads')
return path.join(os.tmpdir(), 'xcdesktop_uploads')
},
get serverPort(): number {
@@ -38,6 +38,18 @@ export const config = {
get isDev(): boolean {
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 = {

View File

@@ -32,6 +32,110 @@ import { logger } from '../../utils/logger.js'
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(
'/',
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) => {
if (a.type === b.type) return a.name.localeCompare(b.name)
return a.type === 'dir' ? -1 : 1

View File

@@ -51,4 +51,13 @@ router.post(
}),
)
router.get(
'/config',
asyncHandler(async (req: Request, res: Response) => {
successResponse(res, {
notebookRoot: NOTEBOOK_ROOT,
})
}),
)
export default router

View File

@@ -1,16 +1,13 @@
import express, { type Request, type Response } from 'express'
import { spawn } from 'child_process'
import path from 'path'
import { fileURLToPath } from 'url'
import fs from 'fs/promises'
import fsSync from 'fs'
import { asyncHandler } from '../../utils/asyncHandler.js'
import { successResponse } from '../../utils/response.js'
import { resolveNotebookPath } from '../../utils/pathSafety.js'
import { ValidationError, NotFoundError, InternalError } from '../../../shared/errors/index.js'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
import { PROJECT_ROOT } from '../../config/paths.js'
const router = express.Router()
@@ -89,8 +86,7 @@ router.post(
const content = await fs.readFile(fullPath, 'utf-8')
const projectRoot = path.resolve(__dirname, '..', '..', '..')
const scriptPath = path.join(projectRoot, 'tools', 'doubao', 'main.py')
const scriptPath = path.join(PROJECT_ROOT, 'tools', 'doubao', 'main.py')
if (!fsSync.existsSync(scriptPath)) {
throw new InternalError(`Python script not found: ${scriptPath}`)

View File

@@ -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'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const moduleFactoryPattern = /^create\w+Module$/
async function discoverModules(): Promise<ApiModule[]> {
return await getStaticModules()
}
async function getStaticModules(): Promise<ApiModule[]> {
const modules: ApiModule[] = []
const entries = readdirSync(__dirname)
for (const entry of entries) {
const entryPath = join(__dirname, entry)
try {
const { createTodoModule } = await import('./todo/index.js')
modules.push(createTodoModule())
} catch (e) {
console.warn('[ModuleLoader] Failed to load todo module:', e)
}
try {
const stats = statSync(entryPath)
if (!stats.isDirectory()) {
continue
}
try {
const { createTimeTrackingModule } = await import('./time-tracking/index.js')
modules.push(createTimeTrackingModule())
} catch (e) {
console.warn('[ModuleLoader] Failed to load time-tracking module:', e)
}
const moduleIndexPath = join(entryPath, 'index.ts')
let moduleIndexStats: ReturnType<typeof statSync>
try {
moduleIndexStats = statSync(moduleIndexPath)
} catch {
continue
}
if (!moduleIndexStats.isFile()) {
continue
}
try {
const { createRecycleBinModule } = await import('./recycle-bin/index.js')
modules.push(createRecycleBinModule())
} catch (e) {
console.warn('[ModuleLoader] Failed to load recycle-bin module:', e)
}
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)) {
if (moduleFactoryPattern.test(exportName)) {
const factory = moduleExports[exportName]
if (typeof factory === 'function') {
const module = factory() as ApiModule
modules.push(module)
}
}
}
} catch (error) {
console.warn(`[ModuleLoader] Failed to load module '${entry}':`, error)
}
try {
const { createDocumentParserModule } = await import('./document-parser/index.js')
modules.push(createDocumentParserModule())
} catch (e) {
console.warn('[ModuleLoader] Failed to load document-parser module:', e)
}
try {
const { createAiModule } = await import('./ai/index.js')
modules.push(createAiModule())
} 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) => {
@@ -66,3 +88,5 @@ export * from './pydemos/index.js'
export * from './document-parser/index.js'
export * from './ai/index.js'
export * from './remote/index.js'
export * from './opencode/index.js'
export * from './voice/index.js'

View 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

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

View File

@@ -73,6 +73,9 @@ export class RemoteService {
serverHost: deviceConfig.serverHost || '',
desktopPort: deviceConfig.desktopPort || 3000,
gitPort: deviceConfig.gitPort || 3001,
openCodePort: deviceConfig.openCodePort || 3002,
fileTransferPort: deviceConfig.fileTransferPort || 3003,
password: deviceConfig.password || '',
}
} catch {
return {
@@ -81,6 +84,9 @@ export class RemoteService {
serverHost: '',
desktopPort: 3000,
gitPort: 3001,
openCodePort: 3002,
fileTransferPort: 3003,
password: '',
}
}
})
@@ -114,6 +120,9 @@ export class RemoteService {
serverHost: device.serverHost,
desktopPort: device.desktopPort,
gitPort: device.gitPort,
openCodePort: device.openCodePort,
fileTransferPort: device.fileTransferPort,
password: device.password || '',
}
await fs.writeFile(deviceConfigPath, JSON.stringify(deviceConfig, null, 2), 'utf-8')
}

View File

@@ -182,7 +182,9 @@ class SessionPersistenceService implements SessionPersistence {
const filePath = getMonthFilePath(year, month)
try {
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) {
return createEmptyMonthData(year, month)
}
@@ -192,7 +194,9 @@ class SessionPersistenceService implements SessionPersistence {
const filePath = getYearFilePath(year)
try {
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) {
return createEmptyYearData(year)
}
@@ -226,7 +230,7 @@ class SessionPersistenceService implements SessionPersistence {
if (existingSessionIndex >= 0) {
const oldDuration = dayData.sessions[existingSessionIndex].duration
dayData.sessions[existingSessionIndex] = realtimeSession
dayData.totalDuration += currentSessionDuration - oldDuration
dayData.totalDuration = dayData.totalDuration - oldDuration + currentSessionDuration
} else {
dayData.sessions.push(realtimeSession)
dayData.totalDuration += currentSessionDuration
@@ -269,8 +273,10 @@ class SessionPersistenceService implements SessionPersistence {
monthData.days[dayStr].totalDuration += duration
monthData.days[dayStr].sessions += 1
monthData.monthlyTotal += duration
monthData.activeDays = Object.keys(monthData.days).length
monthData.averageDaily = Math.floor(monthData.monthlyTotal / monthData.activeDays)
monthData.activeDays = Object.values(monthData.days).filter(d => d.totalDuration > 0).length
monthData.averageDaily = monthData.activeDays > 0
? Math.floor(monthData.monthlyTotal / monthData.activeDays)
: 0
monthData.lastUpdated = new Date().toISOString()
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.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
yearData.averageMonthly = Math.floor(yearData.yearlyTotal / monthCount)
const activeMonthCount = Object.values(yearData.months).filter(m => m.totalDuration > 0).length
yearData.averageMonthly = activeMonthCount > 0
? Math.floor(yearData.yearlyTotal / activeMonthCount)
: 0
yearData.averageDaily = yearData.totalActiveDays > 0
? Math.floor(yearData.yearlyTotal / yearData.totalActiveDays)
: 0
@@ -315,7 +326,7 @@ class SessionPersistenceService implements SessionPersistence {
const oldDayDuration = monthData.days[dayStr].totalDuration
monthData.days[dayStr].totalDuration = 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
? Math.floor(monthData.monthlyTotal / monthData.activeDays)
: 0
@@ -345,10 +356,15 @@ class SessionPersistenceService implements SessionPersistence {
yearData.months[monthStr].totalDuration = monthData.monthlyTotal
yearData.months[monthStr].activeDays = monthData.activeDays
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
yearData.averageMonthly = monthCount > 0 ? Math.floor(yearData.yearlyTotal / monthCount) : 0
const activeMonthCount = Object.values(yearData.months).filter(m => m.totalDuration > 0).length
yearData.averageMonthly = activeMonthCount > 0
? Math.floor(yearData.yearlyTotal / activeMonthCount)
: 0
yearData.averageDaily = yearData.totalActiveDays > 0
? Math.floor(yearData.yearlyTotal / yearData.totalActiveDays)
: 0

View File

@@ -366,23 +366,47 @@ class TimeTrackerService {
if (targetMonth) {
const monthData = await this.persistence.getMonthData(targetYear, targetMonth)
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)) {
if (!longestDay || summary.totalDuration > longestDay.duration) {
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 {
const yearData = await this.persistence.getYearData(targetYear)
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)) {
if (!longestDay || summary.totalDuration > longestDay.duration) {
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 {

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

View File

@@ -1,6 +1,6 @@
import chokidar, { FSWatcher } from 'chokidar';
import path from 'path';
import { NOTEBOOK_ROOT } from '../config/paths.js';
import { config } from '../config/index.js';
import { eventBus } from '../events/eventBus.js';
import { logger } from '../utils/logger.js';
import { toPosixPath } from '../../shared/utils/path.js';
@@ -10,16 +10,17 @@ let watcher: FSWatcher | null = null;
export const startWatcher = (): void => {
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: /(^|[\/\\])\../,
persistent: true,
ignoreInitial: true,
});
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;
logger.info(`File event: ${event} - ${rel}`);
eventBus.broadcast({ event, path: toPosixPath(rel) });

6
console.txt Normal file
View 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
View 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")

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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

View File

@@ -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

File diff suppressed because it is too large Load Diff

View 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/Vector10K命令/天免费 | /marketplace/upstash |
| **Supabase** | 🆓 | 开源Postgres开发平台 | /marketplace/supabase |
| **Redis** | 🆓Free | 无服务器Redis30MB免费 | /marketplace/redis |
| **Nile** | 🆓Free | PostgreSQL for B2B | /marketplace/nile |
| **MotherDuck** | ❓ | 分析的无服务器后端 | /marketplace/motherduck |
| **Convex** | 🆓 | 开源后端平台 | /marketplace/convex |
| **Prisma** | 🆓 | 开源ORM/Postgres | /marketplace/prisma |
| **Turso Cloud** | 🆓Free | SQLite500MB免费 | /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

View 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 │ │
│ │ 项目 Slugai-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 写作助手 [同步数据] [设置] │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 部署 URLhttps://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*

View File

@@ -5,6 +5,10 @@ import fs from 'fs';
import log from 'electron-log';
import { generatePdf } from './services/pdfGenerator';
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';
log.initialize();
@@ -12,24 +16,23 @@ log.initialize();
const __filename = fileURLToPath(import.meta.url);
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);
let lastClipboardText = '';
let clipboardWatcherTimer: NodeJS.Timeout | null = null;
function stopClipboardWatcher() {
if (clipboardWatcherTimer) {
clearInterval(clipboardWatcherTimer);
clipboardWatcherTimer = null;
log.info('[ClipboardWatcher] Stopped');
}
}
function startClipboardWatcher() {
lastClipboardText = clipboard.readText();
setInterval(() => {
clipboardWatcherTimer = setInterval(() => {
try {
const currentText = clipboard.readText();
if (currentText && currentText !== lastClipboardText) {
@@ -50,8 +53,8 @@ async function createWindow() {
const initialSymbolColor = nativeTheme.shouldUseDarkColors ? '#ffffff' : '#000000';
const mainWindow = new BrowserWindow({
width: 1280,
height: 800,
width: 1600,
height: 900,
minWidth: 1600,
minHeight: 900,
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) => {
const win = BrowserWindow.fromWebContents(event.sender);
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() {
if (electronState.isDevelopment()) {
log.info('In dev mode, assuming external servers are running.');
@@ -181,7 +580,20 @@ async function startServer() {
}
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 opencodeService.start();
await createWindow();
startClipboardWatcher();
@@ -211,7 +623,31 @@ app.whenReady().then(async () => {
app.on('window-all-closed', () => {
globalShortcut.unregisterAll();
opencodeService.stop();
xcOpenCodeWebService.stop();
sddService.stop();
terminalService.stop();
stopClipboardWatcher();
if (process.platform !== 'darwin') {
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');
});

View File

@@ -19,6 +19,49 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.on('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'),
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'),
})

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

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

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

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

View File

@@ -13,6 +13,8 @@ class ElectronState {
isDev: false,
}
private windows = new Map<number, BrowserWindow>()
getMainWindow(): BrowserWindow | null {
return this.state.mainWindow
}
@@ -37,7 +39,24 @@ class ElectronState {
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 {
this.windows.clear()
this.state = {
mainWindow: null,
serverPort: 3001,

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>XCNote</title>
<title>XCDesktop</title>
<script type="module">
if (import.meta.hot?.on) {
import.meta.hot.on('vite:error', (error) => {

591
package-lock.json generated
View File

@@ -1,11 +1,11 @@
{
"name": "xcnote",
"name": "xcdesktop",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "xcnote",
"name": "xcdesktop",
"version": "0.0.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@@ -20,11 +20,15 @@
"@milkdown/preset-commonmark": "^7.18.0",
"@milkdown/preset-gfm": "^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/multer": "^2.0.0",
"@types/prismjs": "^1.26.5",
"axios": "^1.13.5",
"chokidar": "^5.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cors": "^2.8.5",
"dotenv": "^17.2.1",
@@ -40,6 +44,7 @@
"react-router-dom": "^7.3.0",
"recharts": "^3.7.0",
"remark-breaks": "^4.0.0",
"tailwind-merge": "^3.5.0",
"zod": "^4.3.6",
"zustand": "^5.0.11"
},
@@ -3305,6 +3310,12 @@
"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": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -3323,6 +3334,419 @@
"dev": true,
"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": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
@@ -4308,7 +4732,7 @@
"version": "18.3.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
@@ -5464,6 +5888,18 @@
"dev": true,
"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": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
@@ -6313,6 +6749,18 @@
"dev": true,
"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": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz",
@@ -7132,6 +7580,12 @@
"license": "MIT",
"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": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
@@ -8602,6 +9056,15 @@
"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": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
@@ -12680,6 +13143,53 @@
"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": {
"version": "7.13.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.0.tgz",
@@ -12731,6 +13241,28 @@
"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": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz",
@@ -13943,6 +14475,16 @@
"dev": true,
"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": {
"version": "3.4.19",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
@@ -14901,6 +15443,49 @@
"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": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",

View File

@@ -1,7 +1,7 @@
{
"name": "xcnote",
"name": "xcdesktop",
"version": "0.0.0",
"description": "一个功能强大的本地 Markdown 笔记管理工具,支持时间追踪、任务管理、AI 集成等高级功能",
"description": "一站式 AI 工作台 - 集成笔记管理、时间追踪、任务管理、AI 辅助等多种功能",
"keywords": [
"markdown",
"note",
@@ -15,7 +15,7 @@
"author": "Your Name",
"repository": {
"type": "git",
"url": "https://github.com/your-username/xcnote.git"
"url": "https://github.com/your-username/xcdesktop.git"
},
"private": true,
"main": "dist-electron/main.js",
@@ -35,7 +35,7 @@
"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\"",
"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"
},
"dependencies": {
@@ -51,16 +51,21 @@
"@milkdown/preset-commonmark": "^7.18.0",
"@milkdown/preset-gfm": "^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/multer": "^2.0.0",
"@types/prismjs": "^1.26.5",
"axios": "^1.13.5",
"chokidar": "^5.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cors": "^2.8.5",
"dotenv": "^17.2.1",
"electron-log": "^5.4.3",
"express": "^4.21.2",
"form-data": "^4.0.5",
"github-slugger": "^2.0.0",
"jszip": "^3.10.1",
"lucide-react": "^0.511.0",
@@ -71,6 +76,7 @@
"react-router-dom": "^7.3.0",
"recharts": "^3.7.0",
"remark-breaks": "^4.0.0",
"tailwind-merge": "^3.5.0",
"zod": "^4.3.6",
"zustand": "^5.0.11"
},
@@ -111,8 +117,8 @@
"wait-on": "^9.0.3"
},
"build": {
"appId": "com.xcnote.app",
"productName": "XCNote",
"appId": "com.xcdesktop.app",
"productName": "XCDesktop",
"directories": {
"output": "release"
},
@@ -122,10 +128,12 @@
"dist-api/**/*",
"shared/**/*",
"tools/**/*",
"services/**/*",
"package.json"
],
"asarUnpack": [
"tools/**/*"
"tools/**/*",
"services/**/*"
],
"win": {
"target": "nsis"

View File

@@ -23,9 +23,14 @@
"tokenExpiry": 3600
},
"frp": {
"enabled": true
},
"opencode": {
"enabled": false
},
"xcopencodeweb": {
"enabled": true,
"frpcPath": "./frp/frpc.exe",
"configPath": "./frp/frpc.toml"
"port": 3002
},
"gitea": {
"enabled": true

View File

@@ -2,22 +2,36 @@ serverAddr = "146.56.248.142"
serverPort = 7000
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.maxDays = 7
[[proxies]]
name = "remote-desktop"
name = "desktop-remote"
type = "tcp"
localIP = "127.0.0.1"
localPort = 3000
remotePort = 8080
[[proxies]]
name = "gitea-web"
name = "gitea-remote"
type = "tcp"
localIP = "127.0.0.1"
localPort = 3001
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

View File

@@ -7,17 +7,31 @@ log.level = "info"
log.maxDays = 7
[[proxies]]
name = "remote-desktop"
name = "desktop-remote"
type = "tcp"
localIP = "127.0.0.1"
localPort = 3000
remotePort = 8080
[[proxies]]
name = "gitea-web"
name = "gitea-remote"
type = "tcp"
localIP = "127.0.0.1"
localPort = 3001
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

View File

@@ -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]
SSH_DOMAIN = localhost
DOMAIN = localhost
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.

View File

@@ -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

View File

@@ -1 +0,0 @@
{"storage":"boltdb","index_type":"scorch"}

View File

@@ -1 +0,0 @@
{"version":5}

View File

@@ -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-----

View File

@@ -1 +0,0 @@
MANIFEST-000050

View File

@@ -1 +0,0 @@
MANIFEST-000048

View File

@@ -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

View File

@@ -13,6 +13,7 @@
"bcryptjs": "^2.4.3",
"config": "^3.3.12",
"cookie-parser": "^1.4.7",
"cors": "^2.8.6",
"dotenv": "^17.3.1",
"express": "^5.2.1",
"h264-live-player": "^1.3.1",
@@ -771,6 +772,23 @@
"dev": true,
"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": {
"version": "4.4.3",
"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": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",

View File

@@ -34,6 +34,7 @@
"bcryptjs": "^2.4.3",
"config": "^3.3.12",
"cookie-parser": "^1.4.7",
"cors": "^2.8.6",
"dotenv": "^17.3.1",
"express": "^5.2.1",
"h264-live-player": "^1.3.1",

View File

@@ -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) => {
const GiteaService = require('../services/network/GiteaService');
const config = c.resolve('config');
@@ -135,6 +145,7 @@ class App {
const serverConfig = config.getSection('server') || {};
return new Server({
port: serverConfig.port || 3000,
fileTransferPort: serverConfig.fileTransferPort || 3003,
host: serverConfig.host || '0.0.0.0'
});
});
@@ -191,6 +202,10 @@ class App {
frpService.start();
logger.info('FRP service started');
const xcOpenCodeWebService = this.container.resolve('xcOpenCodeWebService');
xcOpenCodeWebService.start();
logger.info('XCOpenCodeWeb service started');
const giteaService = this.container.resolve('giteaService');
giteaService.start();
logger.info('Gitea service started');
@@ -223,6 +238,17 @@ class App {
const authMiddleware = require('../middlewares/auth');
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(express.json());
httpServer.use(express.urlencoded({ extended: true }));
@@ -241,6 +267,11 @@ class App {
});
httpServer.use(async (req, res, next) => {
// 放行 CORS 预检请求
if (req.method === 'OPTIONS') {
return next();
}
if (!authService.hasPassword()) {
res.locals.authenticated = true;
return next();
@@ -453,6 +484,10 @@ class App {
frpService.stop();
logger.info('FRP service stopped');
const xcOpenCodeWebService = this.container.resolve('xcOpenCodeWebService');
xcOpenCodeWebService.stop();
logger.info('XCOpenCodeWeb service stopped');
const giteaService = this.container.resolve('giteaService');
giteaService.stop();
logger.info('Gitea service stopped');

View File

@@ -1,6 +1,8 @@
const express = require('express');
const multer = require('multer');
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const { fileService } = require('../services/file');
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) => {
try {
const path = req.query.path || '';
const result = fileService.browseDirectory(path);
const filePath = req.query.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);
} catch (error) {
logger.error('Failed to browse directory', { error: error.message });
res.status(500).json({ error: 'Failed to browse directory' });
logger.error('Failed to browse directory', { error: error.message, stack: error.stack });
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) => {
try {
const { fileId, totalChunks, filename } = req.body;
const { fileId, totalChunks, filename, path: targetPath } = req.body;
if (!fileId || !totalChunks || !filename) {
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) {
res.json({ success: true, filename });

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

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

View File

@@ -5,9 +5,11 @@ const path = require('path');
class Server {
constructor(config = {}) {
this.port = config.port || 3000;
this.fileTransferPort = config.fileTransferPort || 3003;
this.host = config.host || '0.0.0.0';
this.app = express();
this.server = http.createServer(this.app);
this.fileTransferServer = null;
}
use(...args) {
@@ -28,20 +30,33 @@ class Server {
start() {
return new Promise((resolve, reject) => {
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.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() {
return new Promise((resolve, reject) => {
this.server.close((err) => {
if (err) {
reject(err);
} else {
resolve();
}
const closeFileTransfer = this.fileTransferServer
? new Promise((res) => this.fileTransferServer.close(res))
: Promise.resolve();
closeFileTransfer.then(() => {
this.server.close((err) => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
});
}

View File

@@ -44,7 +44,8 @@ class FileService {
}
getFilePath(filename) {
const filePath = path.join(this.uploadDir, path.basename(filename));
if (!filename) return null;
const filePath = path.normalize(filename);
if (!fs.existsSync(filePath)) {
return null;
}
@@ -90,9 +91,19 @@ class FileService {
}
}
mergeChunks(fileId, totalChunks, filename) {
mergeChunks(fileId, totalChunks, filename, targetPath) {
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');
for (let i = 0; i < totalChunks; i++) {
@@ -136,48 +147,81 @@ class FileService {
}
}
browseDirectory(relativePath = '') {
try {
getDrives() {
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 targetDir = path.join(this.uploadDir, safePath);
targetDir = path.join(this.uploadDir, safePath);
currentPath = safePath;
if (!targetDir.startsWith(this.uploadDir)) {
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
};
}
}

View File

@@ -64,9 +64,15 @@ class FRPService {
configPath: runtimeConfigPath
});
this.process = spawn(this.frpcPath, ['-c', runtimeConfigPath], {
this.process = spawn('cmd.exe', [
'/c',
this.frpcPath,
'-c',
runtimeConfigPath
], {
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true
windowsHide: true,
shell: false
});
this.isRunning = true;

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

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

View File

@@ -5,7 +5,7 @@ function getBasePath() {
if (process.pkg) {
return path.dirname(process.execPath);
}
return path.join(__dirname, '../..');
return process.cwd();
}
function getPublicPath() {

View 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

View 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

Binary file not shown.

BIN
services/xcsdd/XCSDD.exe Normal file

Binary file not shown.

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

View File

@@ -4,6 +4,8 @@ export interface RemoteDevice {
serverHost: string
desktopPort: number
gitPort: number
openCodePort: number
fileTransferPort: number
password?: string
}

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

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

View 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',
}

View File

@@ -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'

View File

@@ -8,6 +8,10 @@ const KNOWN_MODULE_IDS = [
export function getTabTypeFromPath(filePath: string | null): TabType {
if (!filePath) return 'other'
if (filePath.startsWith('file-transfer-panel')) {
return 'file-transfer'
}
if (filePath.startsWith('remote-git://')) {
return 'remote-git'
}
@@ -43,6 +47,12 @@ export function getTabTypeFromPath(filePath: string | null): TabType {
export function getFileNameFromPath(filePath: string | null): string {
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) {
if (filePath === `${moduleId}-tab` || filePath === moduleId) {
const names: Record<string, string> = {

View File

@@ -1,5 +1,6 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import { NoteBrowser } from '@/pages/NoteBrowser'
import { PopoutPage } from '@/pages/PopoutPage'
import { SettingsSync } from '@/components/settings/SettingsSync'
import { ErrorBoundary } from '@/components/common/ErrorBoundary'
@@ -14,6 +15,7 @@ function App() {
<TimeTrackerProvider>
<SettingsSync />
<Routes>
<Route path="/popout" element={<PopoutPage />} />
<Route path="/*" element={<NoteBrowser />} />
</Routes>
</TimeTrackerProvider>

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

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

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

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

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

View 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,
}

View 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,
}

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

View File

@@ -229,7 +229,8 @@ const SidebarContent = forwardRef<HTMLDivElement, SidebarProps>(({
)}
<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}
/>
</div>

View File

@@ -15,12 +15,13 @@ export interface TabBarProps {
onTabClose: (file: FileItem, e: React.MouseEvent) => void
onCloseOther?: (file: FileItem) => void
onCloseAll?: () => void
onPopOut?: (file: FileItem) => void
className?: string
style?: React.CSSProperties
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 { opacity } = useWallpaper()
const [contextMenu, setContextMenu] = useState<{
@@ -61,7 +62,16 @@ export const TabBar = ({ openFiles, activeFile, onTabClick, onTabClose, onCloseO
handleCloseContextMenu()
}
const handlePopOut = () => {
if (contextMenu.file && onPopOut) {
onPopOut(contextMenu.file)
}
handleCloseContextMenu()
}
const isHomeTab = contextMenu.file?.path === HOME_TAB_ID
const contextMenuItems = [
...(!isHomeTab ? [{ label: '在新窗口中打开', onClick: handlePopOut }] : []),
{ label: '关闭其他标签页', onClick: handleCloseOther },
{ label: '关闭所有标签页', onClick: handleCloseAll }
]

View File

@@ -8,6 +8,7 @@ export interface TitleBarProps {
onTabClose: (file: FileItem, e: React.MouseEvent) => void
onCloseOther?: (file: FileItem) => void
onCloseAll?: () => void
onPopOut?: (file: FileItem) => void
opacity: number
}
@@ -18,6 +19,7 @@ export const TitleBar = ({
onTabClose,
onCloseOther,
onCloseAll,
onPopOut,
opacity
}: TitleBarProps) => (
<div
@@ -35,6 +37,7 @@ export const TitleBar = ({
onTabClose={onTabClose}
onCloseOther={onCloseOther}
onCloseAll={onCloseAll}
onPopOut={onPopOut}
variant="titlebar"
className="h-full border-b-0"
style={{ backgroundColor: 'transparent' }}

View File

@@ -29,7 +29,7 @@ export const MarkdownTabPage: React.FC<MarkdownTabPageProps> = ({
return (
<div
className="max-w-4xl mx-auto w-full"
className="w-full px-10 py-4"
style={{ zoom: zoom / 100 }}
>
<div className="h-full w-full">

View File

@@ -3,6 +3,7 @@ export { useDialogState, useErrorDialogState } from './useDialogState'
export { useNoteBrowser, type UseNoteBrowserReturn } from './useNoteBrowser'
export { useNoteContent } from './useNoteContent'
export { useAutoLoadTabContent } from './useAutoLoadTabContent'
export { usePopOutTab } from './usePopOutTab'
export { useFileSystemController } from './useFileSystemController'
export { useFileTabs } from './useFileTabs'

View File

@@ -85,6 +85,8 @@ export const useMarkdownLogic = ({
const readOnlyRef = useRef(readOnly)
const ctxRef = useRef<Ctx | null>(null)
const lastContentRef = useRef<string>('')
useEffect(() => {
onChangeRef.current = onChange
}, [])
@@ -117,23 +119,28 @@ export const useMarkdownLogic = ({
}
}, [readOnly])
// 在只读模式下动态更新内容
// 在只读模式下动态更新内容(仅当 content 真正变化时)
useEffect(() => {
if (!ctxRef.current || !readOnly) return
// 只有当 content 真正变化时才更新编辑器
if (content === lastContentRef.current) return
lastContentRef.current = content
try {
const view = ctxRef.current.get(editorViewCtx)
const parser = ctxRef.current.get(parserCtx)
const doc = parser(content)
if (!doc) return
const state = view.state
view.dispatch(state.tr.replaceWith(0, state.doc.content.size, doc))
view.dispatch(view.state.tr.replaceWith(0, view.state.doc.content.size, doc))
} catch {
// 编辑器可能尚未就绪
}
}, [content, readOnly])
return useEditor((root) => {
lastContentRef.current = content
return Editor.make()
.config((ctx) => {
ctxRef.current = ctx

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

View File

@@ -34,5 +34,5 @@ export const useSidebarResize = (initialWidth: number = 250) => {
}
}, [isResizing])
return { sidebarWidth, startResizing }
return { sidebarWidth, startResizing, isResizing }
}

View File

@@ -8,7 +8,7 @@ export interface UseSidebarStateReturn {
}
export function useSidebarState(): UseSidebarStateReturn {
const [sidebarOpen, setSidebarOpen] = useState(true)
const [sidebarOpen, setSidebarOpen] = useState(false)
const [refreshKey, setRefreshKey] = useState(0)
const bumpRefresh = useCallback(() => setRefreshKey((prev) => prev + 1), [])

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