Compare commits
75 Commits
92088e9c8a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9caa43d4a2 | |||
| dff4674c54 | |||
| 19e7a51b61 | |||
| 69bd91797d | |||
| 3b1f99951e | |||
| 2d87c267cf | |||
| d0e286e4bb | |||
| aa5895873b | |||
| c37e6ab4f2 | |||
| f160adbdb1 | |||
| 43828a87f0 | |||
| 2eb5a167b3 | |||
| 8ee86c7b0f | |||
| 9effdcd070 | |||
| 7c7b334f5c | |||
| 8716f6f684 | |||
| 1d33a8e14f | |||
| 7db1f9162b | |||
| d54510a864 | |||
| 28df633b00 | |||
| c83f23c319 | |||
| 90517f2289 | |||
| 308df54a15 | |||
| dcd1fcd709 | |||
| 18c02053da | |||
| 3c353cb701 | |||
| 0d5cd329ca | |||
| e950484af6 | |||
| cbc1af7348 | |||
| 88d42b37a6 | |||
| b5343bcd9d | |||
| 9b22b647f2 | |||
| 50cd1e29c9 | |||
| ba02eb10a7 | |||
| 7c656785c8 | |||
| f692961823 | |||
| 1be470f45b | |||
| 96c709f109 | |||
| fd77455f5b | |||
| 72d79ae214 | |||
| 53c1045406 | |||
| fd2255c83a | |||
| 986ecb2561 | |||
| 8d4a9a3704 | |||
| e6c41491b3 | |||
| cd70b50180 | |||
| 96390df254 | |||
| 371d4ce327 | |||
| cd1b541427 | |||
| 668a1cb473 | |||
| 517592e216 | |||
| 3e360c1807 | |||
| 8bb2e643d8 | |||
| 67a19d486b | |||
| 04fc326a8d | |||
| 1b80fd036d | |||
| 320d2654f5 | |||
| bbd33339a5 | |||
| 1fa17f7c9d | |||
| 7a39fc3bce | |||
| 2503d8be64 | |||
| de4c101b36 | |||
| 433db24688 | |||
| 40f99f0c49 | |||
| 8839ec244a | |||
| 073abafdfd | |||
| 84e455d9a6 | |||
| 6d5520dfa5 | |||
| 788757b785 | |||
| 48fd2f5463 | |||
| 88f265757c | |||
| 4273b3d43b | |||
| 4c18edf74f | |||
| d65b3e7909 | |||
| 49bf8a97d2 |
18
.gitignore
vendored
18
.gitignore
vendored
@@ -26,6 +26,13 @@ dist-ssr
|
||||
|
||||
# Build output
|
||||
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/
|
||||
@@ -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
|
||||
|
||||
50
README.md
50
README.md
@@ -8,12 +8,14 @@
|
||||
[](https://vitejs.dev/)
|
||||
[](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
3
api/.env
Normal file
@@ -0,0 +1,3 @@
|
||||
MINIMAX_API_KEY=sk-api-Ip6OsHSvDJlnbl7L9sRsK277vx1W6VfM8xXfbjjEcdsnT_91kZmciWDiNHzMkDMTEsxirTZdA-shZ-oYS0Qo70m3raWeO7_1Zr8rmM9D5QFWKgkLya60HrA
|
||||
MINIMAX_GROUP_ID=1982508094420165122
|
||||
OPENAI_API_KEY=
|
||||
2
api/.env.example
Normal file
2
api/.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
MINIMAX_API_KEY=your_api_key_here
|
||||
MINIMAX_GROUP_ID=your_group_id_here
|
||||
@@ -23,8 +23,12 @@ import { apiModules } from './modules/index.js'
|
||||
import { validateModuleConsistency } from './infra/moduleValidator.js'
|
||||
import 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()
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -51,4 +51,13 @@ router.post(
|
||||
}),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/config',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
successResponse(res, {
|
||||
notebookRoot: NOTEBOOK_ROOT,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export default router
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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'
|
||||
|
||||
20
api/modules/opencode/index.ts
Normal file
20
api/modules/opencode/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { Router } from 'express'
|
||||
import type { ServiceContainer } from '../../infra/container.js'
|
||||
import { createApiModule } from '../../infra/createModule.js'
|
||||
import { OPENCODE_MODULE } from '../../../shared/modules/opencode/index.js'
|
||||
import { createOpencodeRoutes } from './routes.js'
|
||||
|
||||
export * from './routes.js'
|
||||
|
||||
export const createOpencodeModule = () => {
|
||||
return createApiModule({
|
||||
...OPENCODE_MODULE,
|
||||
version: OPENCODE_MODULE.version || '1.0.0',
|
||||
}, {
|
||||
routes: (_container: ServiceContainer): Router => {
|
||||
return createOpencodeRoutes()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export default createOpencodeModule
|
||||
223
api/modules/opencode/routes.ts
Normal file
223
api/modules/opencode/routes.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { Router } from 'express'
|
||||
import { createOpencodeClient } from '@opencode-ai/sdk'
|
||||
|
||||
const OPENCODE_URL = 'http://localhost:4096'
|
||||
|
||||
async function getClient() {
|
||||
console.log('[OpenCode] Creating client for URL:', OPENCODE_URL)
|
||||
return createOpencodeClient({
|
||||
baseUrl: OPENCODE_URL,
|
||||
})
|
||||
}
|
||||
|
||||
export function createOpencodeRoutes(): Router {
|
||||
const router = Router()
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
const action = req.query.action as string | undefined
|
||||
|
||||
try {
|
||||
const client = await getClient()
|
||||
|
||||
if (action === 'list-sessions') {
|
||||
const response = await client.session.list()
|
||||
const sessions = (response.data || []).map((session: { id: string; title?: string; createdAt?: string }) => ({
|
||||
id: session.id,
|
||||
title: session.title || 'New Chat',
|
||||
createdAt: session.createdAt ? new Date(session.createdAt).getTime() : Date.now(),
|
||||
}))
|
||||
res.json({ sessions })
|
||||
return
|
||||
}
|
||||
|
||||
res.status(400).json({ error: 'Unknown action' })
|
||||
} catch (error: unknown) {
|
||||
console.error('API error:', error)
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
res.status(500).json({ error: errorMessage })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const body = req.body
|
||||
const { action, sessionId, title, text, attachments } = body
|
||||
console.log('[OpenCode] Received action:', action, 'sessionId:', sessionId)
|
||||
|
||||
const client = await getClient()
|
||||
console.log('[OpenCode] Client created successfully')
|
||||
|
||||
if (action === 'create-session') {
|
||||
try {
|
||||
const response = await client.session.create({
|
||||
body: { title: title || 'New Chat' },
|
||||
})
|
||||
|
||||
if (response.error) {
|
||||
res.status(500).json({ error: 'OpenCode error', details: response.error })
|
||||
return
|
||||
}
|
||||
|
||||
const sessionData = response.data as { id: string; title?: string; createdAt?: string } | undefined
|
||||
if (sessionData) {
|
||||
res.json({
|
||||
session: {
|
||||
id: sessionData.id,
|
||||
title: sessionData.title,
|
||||
createdAt: sessionData.createdAt ? new Date(sessionData.createdAt).getTime() : Date.now(),
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
res.status(500).json({ error: 'Failed to create session - no data' })
|
||||
return
|
||||
} catch (e: unknown) {
|
||||
console.error('Create session exception:', e)
|
||||
const errorMessage = e instanceof Error ? e.message : 'Unknown error'
|
||||
res.status(500).json({ error: errorMessage, exception: true })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (action === 'delete-session') {
|
||||
const response = await client.session.delete({
|
||||
path: { id: sessionId },
|
||||
})
|
||||
res.json({ success: response.data })
|
||||
return
|
||||
}
|
||||
|
||||
if (action === 'get-session') {
|
||||
const response = await client.session.get({
|
||||
path: { id: sessionId },
|
||||
})
|
||||
|
||||
if (response.data) {
|
||||
res.json({
|
||||
title: response.data.title || 'New Chat',
|
||||
})
|
||||
return
|
||||
}
|
||||
res.status(404).json({ error: 'Session not found' })
|
||||
return
|
||||
}
|
||||
|
||||
if (action === 'get-messages') {
|
||||
const limit = body.limit || 20
|
||||
|
||||
const response = await client.session.messages({
|
||||
path: { id: sessionId },
|
||||
query: { limit }
|
||||
})
|
||||
|
||||
const messages = (response.data || []).map((item: { info?: { id?: string; role?: string; content?: string; createdAt?: string }; content?: string; parts?: unknown[]; id?: string; role?: string }) => {
|
||||
let content = item.info?.content || item.content || ''
|
||||
|
||||
const parts = item.parts as Array<{ type: string; text?: string }> | undefined
|
||||
if (parts && parts.length > 0) {
|
||||
const textParts = parts
|
||||
.filter((p) => p.type === 'text')
|
||||
.map((p) => p.text)
|
||||
.join('')
|
||||
if (textParts) {
|
||||
content = textParts
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: item.info?.id || item.id,
|
||||
role: item.info?.role || item.role,
|
||||
content: content,
|
||||
parts: item.parts || [],
|
||||
createdAt: item.info?.createdAt,
|
||||
}
|
||||
})
|
||||
|
||||
const hasMore = messages.length >= limit
|
||||
|
||||
res.json({ messages, hasMore })
|
||||
return
|
||||
}
|
||||
|
||||
if (action === 'prompt') {
|
||||
const parts: Array<{ type: 'text'; text: string } | { type: 'file'; url: string; name: string; mime: string }> = []
|
||||
|
||||
if (attachments && attachments.length > 0) {
|
||||
for (const att of attachments as Array<{ url: string; name: string; mediaType: string }>) {
|
||||
parts.push({
|
||||
type: 'file',
|
||||
url: att.url,
|
||||
name: att.name,
|
||||
mime: att.mediaType,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
parts.push({ type: 'text', text })
|
||||
|
||||
try {
|
||||
const response = await client.session.prompt({
|
||||
path: { id: sessionId },
|
||||
body: { parts },
|
||||
})
|
||||
|
||||
if (response.data) {
|
||||
res.json({
|
||||
message: {
|
||||
id: (response.data.info as { id?: string })?.id || crypto.randomUUID(),
|
||||
role: (response.data.info as { role?: string })?.role || 'assistant',
|
||||
content: '',
|
||||
parts: response.data.parts || [],
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
res.status(500).json({ error: 'Failed to get response', details: response.error })
|
||||
return
|
||||
} catch (e: unknown) {
|
||||
console.error('Prompt exception:', e)
|
||||
const errorMessage = e instanceof Error ? e.message : 'Unknown error'
|
||||
res.status(500).json({ error: errorMessage })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (action === 'summarize-session') {
|
||||
try {
|
||||
await client.session.summarize({
|
||||
path: { id: sessionId },
|
||||
})
|
||||
|
||||
const response = await client.session.get({
|
||||
path: { id: sessionId },
|
||||
})
|
||||
|
||||
if (response.data) {
|
||||
res.json({
|
||||
title: response.data.title || 'New Chat',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
res.status(500).json({ error: 'Failed to get session' })
|
||||
return
|
||||
} catch (e: unknown) {
|
||||
console.error('Summarize error:', e)
|
||||
const errorMessage = e instanceof Error ? e.message : 'Unknown error'
|
||||
res.status(500).json({ error: errorMessage })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
res.status(400).json({ error: 'Unknown action' })
|
||||
} catch (error: unknown) {
|
||||
console.error('API error:', error)
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
res.status(500).json({ error: errorMessage })
|
||||
}
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
@@ -73,6 +73,9 @@ export class RemoteService {
|
||||
serverHost: deviceConfig.serverHost || '',
|
||||
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')
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
25
api/modules/voice/index.ts
Normal file
25
api/modules/voice/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { Router } from 'express'
|
||||
import type { ServiceContainer } from '../../infra/container.js'
|
||||
import { createApiModule } from '../../infra/createModule.js'
|
||||
import { voiceModule, VOICE_MODULE } from '../../../shared/modules/voice/index.js'
|
||||
import { createVoiceRoutes } from './routes.js'
|
||||
|
||||
export * from './routes.js'
|
||||
|
||||
export const createVoiceModule = () => {
|
||||
return createApiModule(
|
||||
{
|
||||
...voiceModule,
|
||||
basePath: '/voice',
|
||||
version: '1.0.0',
|
||||
order: 100,
|
||||
},
|
||||
{
|
||||
routes: (_container: ServiceContainer): Router => {
|
||||
return createVoiceRoutes()
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export default createVoiceModule
|
||||
117
api/modules/voice/routes.ts
Normal file
117
api/modules/voice/routes.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { Router } from 'express'
|
||||
import { config } from '../../config/index.js'
|
||||
|
||||
interface AIMLAPISTTCreateResponse {
|
||||
generation_id?: string
|
||||
status?: string
|
||||
error?: {
|
||||
message: string
|
||||
}
|
||||
}
|
||||
|
||||
interface AIMLAPISTTQueryResponse {
|
||||
status?: string
|
||||
result?: {
|
||||
results?: {
|
||||
channels?: Array<{
|
||||
alternatives?: Array<{
|
||||
transcript?: string
|
||||
}>
|
||||
}>
|
||||
}
|
||||
}
|
||||
error?: {
|
||||
message: string
|
||||
}
|
||||
}
|
||||
|
||||
export function createVoiceRoutes(): Router {
|
||||
const router = Router()
|
||||
|
||||
router.post('/stt', async (req, res) => {
|
||||
console.log('[Voice] Request received, body keys:', Object.keys(req.body || {}))
|
||||
try {
|
||||
const apiKey = config.minimaxApiKey
|
||||
|
||||
console.log('[Voice] API Key exists:', !!apiKey)
|
||||
|
||||
if (!apiKey) {
|
||||
res.status(500).json({ error: 'MiniMax API key not configured' })
|
||||
return
|
||||
}
|
||||
|
||||
if (!req.body.audio) {
|
||||
res.status(400).json({ error: 'No audio data provided' })
|
||||
return
|
||||
}
|
||||
|
||||
const audioBuffer = Buffer.from(req.body.audio, 'base64')
|
||||
console.log('[Voice] Audio buffer size:', audioBuffer.length)
|
||||
|
||||
const formData = new FormData()
|
||||
const blob = new Blob([audioBuffer], { type: 'audio/webm' })
|
||||
formData.append('file', blob, 'audio.webm')
|
||||
formData.append('model', '#g1_whisper-large')
|
||||
|
||||
console.log('[Voice] Creating STT job via MiniMax...')
|
||||
|
||||
const createResponse = await fetch('https://api.minimax.chat/v1/stt/create', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
},
|
||||
body: formData,
|
||||
})
|
||||
|
||||
console.log('[Voice] Create response status:', createResponse.status)
|
||||
const createData: AIMLAPISTTCreateResponse = await createResponse.json()
|
||||
console.log('[Voice] Create response:', createData)
|
||||
|
||||
if (!createResponse.ok || !createData.generation_id) {
|
||||
console.error('[Voice] Failed to create STT job:', createData.error?.message)
|
||||
res.status(500).json({ error: createData.error?.message || 'Failed to create STT job' })
|
||||
return
|
||||
}
|
||||
|
||||
const jobId = createData.generation_id
|
||||
console.log('[Voice] Job ID:', jobId)
|
||||
|
||||
console.log('[Voice] Polling for result...')
|
||||
let resultText = ''
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
const queryResponse = await fetch(`https://api.minimax.chat/v1/stt/${jobId}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
},
|
||||
})
|
||||
|
||||
const queryData: AIMLAPISTTQueryResponse = await queryResponse.json()
|
||||
console.log('[Voice] Query response:', queryData)
|
||||
|
||||
if (queryData.status === 'succeeded') {
|
||||
resultText = queryData.result?.results?.channels?.[0]?.alternatives?.[0]?.transcript || ''
|
||||
break
|
||||
} else if (queryData.status === 'failed') {
|
||||
console.error('[Voice] STT job failed:', queryData.error?.message)
|
||||
res.status(500).json({ error: queryData.error?.message || 'STT processing failed' })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!resultText) {
|
||||
res.status(500).json({ error: 'STT processing timeout' })
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[Voice] Final result:', resultText)
|
||||
res.json({ text: resultText })
|
||||
} catch (error) {
|
||||
console.error('[Voice] STT error:', error)
|
||||
res.status(500).json({ error: 'Failed to process audio' })
|
||||
}
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import chokidar, { FSWatcher } from 'chokidar';
|
||||
import 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
6
console.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
Total messages: 5 (Errors: 3, Warnings: 0)
|
||||
Returning 3 messages for level "error"
|
||||
|
||||
[ERROR] Manifest: Line: 1, column: 1, Syntax error. @ http://localhost:3000/site.webmanifest:0
|
||||
[ERROR] Manifest: Line: 1, column: 1, Syntax error. @ http://localhost:3000/site.webmanifest:0
|
||||
The requested module '/node_modules/lru_map/dist/lru.js?v=e2db4b71' does not provide an export named 'default'
|
||||
156
counter.py
Normal file
156
counter.py
Normal file
@@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import sys
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
if sys.platform == "win32":
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
|
||||
|
||||
def count_lines(file_path: Path) -> int:
|
||||
try:
|
||||
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
|
||||
return sum(1 for _ in f)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def should_include(file_path: Path, extensions: list[str]) -> bool:
|
||||
if not extensions:
|
||||
return True
|
||||
return file_path.suffix in extensions
|
||||
|
||||
|
||||
def walk_dir(root_path: Path, extensions: list[str], exclude_dirs: set[str]):
|
||||
results = []
|
||||
total_lines = 0
|
||||
|
||||
for root, dirs, files in os.walk(root_path):
|
||||
dirs[:] = [d for d in dirs if d not in exclude_dirs]
|
||||
|
||||
rel_root = Path(root).relative_to(root_path)
|
||||
indent = len(rel_root.parts) if str(rel_root) != "." else 0
|
||||
prefix = " " * indent
|
||||
|
||||
if indent > 0:
|
||||
print(f"{prefix}└── {rel_root.name}/")
|
||||
|
||||
for i, file in enumerate(files):
|
||||
file_path = Path(root) / file
|
||||
|
||||
if not should_include(file_path, extensions):
|
||||
continue
|
||||
|
||||
lines = count_lines(file_path)
|
||||
total_lines += lines
|
||||
results.append((file_path, lines, indent + 1))
|
||||
|
||||
is_last = (i == len(files) - 1) and not any(
|
||||
should_include(Path(root) / f, extensions) for f in dirs
|
||||
)
|
||||
connector = "└──" if is_last else "├──"
|
||||
|
||||
print(f"{' ' * (indent + 1)}{connector} {file} ({lines} lines)")
|
||||
|
||||
return total_lines
|
||||
|
||||
|
||||
def main_all():
|
||||
directories = {
|
||||
"src": [".ts", ".tsx", ".vue", ".js"],
|
||||
"api": [".ts", ".py"],
|
||||
"electron": [".ts"],
|
||||
"remote": [".js"],
|
||||
"shared": [".ts"],
|
||||
"tools": [".py"],
|
||||
}
|
||||
|
||||
total_all = 0
|
||||
results = []
|
||||
|
||||
for directory, extensions in directories.items():
|
||||
root_path = Path(directory)
|
||||
if not root_path.exists():
|
||||
continue
|
||||
|
||||
exclude_dirs = {
|
||||
".git",
|
||||
"node_modules",
|
||||
"dist",
|
||||
"dist-api",
|
||||
"dist-electron",
|
||||
"__pycache__",
|
||||
".ruff_cache",
|
||||
}
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f"项目文件统计: {root_path}")
|
||||
print(f"后缀过滤: {extensions}")
|
||||
print(f"{'=' * 60}\n")
|
||||
|
||||
total = walk_dir(root_path, extensions, exclude_dirs)
|
||||
results.append((directory, total))
|
||||
total_all += total
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print("汇总统计")
|
||||
print(f"{'=' * 60}")
|
||||
for name, lines in results:
|
||||
print(f"{name:15} {lines:>10,} 行")
|
||||
print(f"{'=' * 60}")
|
||||
print(f"{'总计':15} {total_all:>10,} 行")
|
||||
print(f"{'=' * 60}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="统计项目文件行数")
|
||||
parser.add_argument(
|
||||
"-e", "--extension", action="append", help="指定后缀名,如: .py .ts .js"
|
||||
)
|
||||
parser.add_argument("-d", "--directory", default=".", help="指定子文件夹路径")
|
||||
parser.add_argument(
|
||||
"-x", "--exclude", action="append", default=[], help="排除的文件夹"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--all",
|
||||
action="store_true",
|
||||
help="统计所有源码目录 (src/api/electron/remote/shared/tools)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.all:
|
||||
main_all()
|
||||
else:
|
||||
root_path = Path(args.directory).resolve()
|
||||
extensions = args.extension
|
||||
exclude_dirs = {
|
||||
".git",
|
||||
"node_modules",
|
||||
"dist",
|
||||
"dist-api",
|
||||
"dist-electron",
|
||||
"__pycache__",
|
||||
".ruff_cache",
|
||||
}
|
||||
exclude_dirs.update(args.exclude)
|
||||
|
||||
if not root_path.exists():
|
||||
print(f"错误: 目录 {root_path} 不存在")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f"项目文件统计: {root_path}")
|
||||
if extensions:
|
||||
print(f"后缀过滤: {extensions}")
|
||||
print(f"{'=' * 60}\n")
|
||||
|
||||
total = walk_dir(root_path, extensions, exclude_dirs)
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f"总行数: {total}")
|
||||
print(f"{'=' * 60}\n")
|
||||
3026
dist-api/server.js
3026
dist-api/server.js
File diff suppressed because it is too large
Load Diff
@@ -1,316 +0,0 @@
|
||||
var __defProp = Object.defineProperty;
|
||||
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
|
||||
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
|
||||
|
||||
// electron/main.ts
|
||||
import { app, BrowserWindow as BrowserWindow3, shell, ipcMain, dialog as dialog2, nativeTheme, globalShortcut, clipboard } from "electron";
|
||||
import path2 from "path";
|
||||
import { fileURLToPath, pathToFileURL } from "url";
|
||||
import fs2 from "fs";
|
||||
import log2 from "electron-log";
|
||||
|
||||
// electron/services/pdfGenerator.ts
|
||||
import { BrowserWindow } from "electron";
|
||||
async function generatePdf(htmlContent) {
|
||||
const printWin = new BrowserWindow({
|
||||
show: false,
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
sandbox: false
|
||||
// 与 main.ts 保持一致,确保脚本执行权限
|
||||
}
|
||||
});
|
||||
try {
|
||||
await printWin.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(htmlContent)}`);
|
||||
await printWin.webContents.executeJavaScript(`
|
||||
new Promise(resolve => {
|
||||
const check = () => {
|
||||
if (window.__PRINT_READY__) {
|
||||
resolve();
|
||||
} else {
|
||||
setTimeout(check, 100);
|
||||
}
|
||||
}
|
||||
check();
|
||||
})
|
||||
`);
|
||||
const pdfData = await printWin.webContents.printToPDF({
|
||||
printBackground: true,
|
||||
pageSize: "A4",
|
||||
margins: { top: 0, bottom: 0, left: 0, right: 0 }
|
||||
});
|
||||
return pdfData;
|
||||
} finally {
|
||||
printWin.close();
|
||||
}
|
||||
}
|
||||
|
||||
// electron/services/htmlImport.ts
|
||||
import { dialog } from "electron";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import log from "electron-log";
|
||||
var selectHtmlFile = async (win) => {
|
||||
if (!win) return { success: false, error: "No window found" };
|
||||
try {
|
||||
const { filePaths, canceled } = await dialog.showOpenDialog(win, {
|
||||
title: "\u9009\u62E9 HTML \u6587\u4EF6",
|
||||
filters: [
|
||||
{ name: "HTML Files", extensions: ["html", "htm"] }
|
||||
],
|
||||
properties: ["openFile"]
|
||||
});
|
||||
if (canceled || filePaths.length === 0) {
|
||||
return { success: false, canceled: true };
|
||||
}
|
||||
const htmlPath = filePaths[0];
|
||||
const htmlDir = path.dirname(htmlPath);
|
||||
const htmlFileName = path.basename(htmlPath, path.extname(htmlPath));
|
||||
const assetsDirName = `${htmlFileName}_files`;
|
||||
const assetsDirPath = path.join(htmlDir, assetsDirName);
|
||||
const assetsFiles = [];
|
||||
if (fs.existsSync(assetsDirPath)) {
|
||||
const collectFiles = (dir, baseDir) => {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
collectFiles(fullPath, baseDir);
|
||||
} else {
|
||||
const relPath = path.relative(baseDir, fullPath);
|
||||
assetsFiles.push(relPath);
|
||||
}
|
||||
}
|
||||
};
|
||||
collectFiles(assetsDirPath, assetsDirPath);
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
htmlPath,
|
||||
htmlDir,
|
||||
htmlFileName,
|
||||
assetsDirName,
|
||||
assetsFiles
|
||||
};
|
||||
} catch (error) {
|
||||
log.error("Select HTML file failed:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
};
|
||||
|
||||
// electron/state.ts
|
||||
var ElectronState = class {
|
||||
constructor() {
|
||||
__publicField(this, "state", {
|
||||
mainWindow: null,
|
||||
serverPort: 3001,
|
||||
isDev: false
|
||||
});
|
||||
}
|
||||
getMainWindow() {
|
||||
return this.state.mainWindow;
|
||||
}
|
||||
setMainWindow(window) {
|
||||
this.state.mainWindow = window;
|
||||
}
|
||||
getServerPort() {
|
||||
return this.state.serverPort;
|
||||
}
|
||||
setServerPort(port) {
|
||||
this.state.serverPort = port;
|
||||
}
|
||||
isDevelopment() {
|
||||
return this.state.isDev;
|
||||
}
|
||||
setDevelopment(isDev) {
|
||||
this.state.isDev = isDev;
|
||||
}
|
||||
reset() {
|
||||
this.state = {
|
||||
mainWindow: null,
|
||||
serverPort: 3001,
|
||||
isDev: false
|
||||
};
|
||||
}
|
||||
};
|
||||
var electronState = new ElectronState();
|
||||
|
||||
// electron/main.ts
|
||||
log2.initialize();
|
||||
var __filename = fileURLToPath(import.meta.url);
|
||||
var __dirname = path2.dirname(__filename);
|
||||
process.env.NOTEBOOK_ROOT = path2.join(app.getPath("documents"), "XCNote");
|
||||
if (!fs2.existsSync(process.env.NOTEBOOK_ROOT)) {
|
||||
try {
|
||||
fs2.mkdirSync(process.env.NOTEBOOK_ROOT, { recursive: true });
|
||||
} catch (err) {
|
||||
log2.error("Failed to create notebook directory:", err);
|
||||
}
|
||||
}
|
||||
electronState.setDevelopment(!app.isPackaged);
|
||||
var lastClipboardText = "";
|
||||
function startClipboardWatcher() {
|
||||
lastClipboardText = clipboard.readText();
|
||||
setInterval(() => {
|
||||
try {
|
||||
const currentText = clipboard.readText();
|
||||
if (currentText && currentText !== lastClipboardText) {
|
||||
lastClipboardText = currentText;
|
||||
log2.info("Clipboard changed, syncing to remote");
|
||||
const win = electronState.getMainWindow();
|
||||
if (win) {
|
||||
win.webContents.send("remote-clipboard-auto-sync", currentText);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
}
|
||||
}, 1e3);
|
||||
}
|
||||
async function createWindow() {
|
||||
const initialSymbolColor = nativeTheme.shouldUseDarkColors ? "#ffffff" : "#000000";
|
||||
const mainWindow = new BrowserWindow3({
|
||||
width: 1280,
|
||||
height: 800,
|
||||
minWidth: 1600,
|
||||
minHeight: 900,
|
||||
autoHideMenuBar: true,
|
||||
titleBarStyle: "hidden",
|
||||
titleBarOverlay: {
|
||||
color: "#00000000",
|
||||
symbolColor: initialSymbolColor,
|
||||
height: 32
|
||||
},
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
sandbox: false,
|
||||
webviewTag: true,
|
||||
preload: path2.join(__dirname, "preload.cjs")
|
||||
}
|
||||
});
|
||||
electronState.setMainWindow(mainWindow);
|
||||
mainWindow.setMenu(null);
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (url.startsWith("http:") || url.startsWith("https:")) {
|
||||
shell.openExternal(url);
|
||||
return { action: "deny" };
|
||||
}
|
||||
return { action: "allow" };
|
||||
});
|
||||
if (electronState.isDevelopment()) {
|
||||
log2.info("Loading development URL...");
|
||||
try {
|
||||
await mainWindow.loadURL("http://localhost:5173");
|
||||
} catch (e) {
|
||||
log2.error("Failed to load dev URL. Make sure npm run electron:dev is used.", e);
|
||||
}
|
||||
mainWindow.webContents.openDevTools();
|
||||
} else {
|
||||
log2.info(`Loading production URL with port ${electronState.getServerPort()}...`);
|
||||
await mainWindow.loadURL(`http://localhost:${electronState.getServerPort()}`);
|
||||
}
|
||||
}
|
||||
ipcMain.handle("export-pdf", async (event, title, htmlContent) => {
|
||||
const win = BrowserWindow3.fromWebContents(event.sender);
|
||||
if (!win) return { success: false, error: "No window found" };
|
||||
try {
|
||||
const { filePath } = await dialog2.showSaveDialog(win, {
|
||||
title: "\u5BFC\u51FA PDF",
|
||||
defaultPath: `${title}.pdf`,
|
||||
filters: [{ name: "PDF Files", extensions: ["pdf"] }]
|
||||
});
|
||||
if (!filePath) return { success: false, canceled: true };
|
||||
if (!htmlContent) {
|
||||
throw new Error("No HTML content provided for PDF export");
|
||||
}
|
||||
const pdfData = await generatePdf(htmlContent);
|
||||
fs2.writeFileSync(filePath, pdfData);
|
||||
return { success: true, filePath };
|
||||
} catch (error) {
|
||||
log2.error("Export PDF failed:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
ipcMain.handle("select-html-file", async (event) => {
|
||||
const win = BrowserWindow3.fromWebContents(event.sender);
|
||||
return selectHtmlFile(win);
|
||||
});
|
||||
ipcMain.handle("update-titlebar-buttons", async (event, symbolColor) => {
|
||||
const win = BrowserWindow3.fromWebContents(event.sender);
|
||||
if (win) {
|
||||
win.setTitleBarOverlay({ symbolColor });
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false };
|
||||
});
|
||||
ipcMain.handle("clipboard-read-text", async () => {
|
||||
try {
|
||||
const text = clipboard.readText();
|
||||
return { success: true, text };
|
||||
} catch (error) {
|
||||
log2.error("Clipboard read failed:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
ipcMain.handle("clipboard-write-text", async (event, text) => {
|
||||
try {
|
||||
clipboard.writeText(text);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
log2.error("Clipboard write failed:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
async function startServer() {
|
||||
if (electronState.isDevelopment()) {
|
||||
log2.info("In dev mode, assuming external servers are running.");
|
||||
return;
|
||||
}
|
||||
const serverPath = path2.join(__dirname, "../dist-api/server.js");
|
||||
const serverUrl = pathToFileURL(serverPath).href;
|
||||
log2.info(`Starting internal server from: ${serverPath}`);
|
||||
try {
|
||||
const serverModule = await import(serverUrl);
|
||||
if (serverModule.startServer) {
|
||||
const port = await serverModule.startServer();
|
||||
electronState.setServerPort(port);
|
||||
log2.info(`Internal server started successfully on port ${port}`);
|
||||
} else {
|
||||
log2.warn("startServer function not found in server module, using default port 3001");
|
||||
}
|
||||
} catch (e) {
|
||||
log2.error("Failed to start internal server:", e);
|
||||
}
|
||||
}
|
||||
app.whenReady().then(async () => {
|
||||
await startServer();
|
||||
await createWindow();
|
||||
startClipboardWatcher();
|
||||
globalShortcut.register("CommandOrControl+Shift+C", () => {
|
||||
log2.info("Global shortcut: sync clipboard to remote");
|
||||
const win = electronState.getMainWindow();
|
||||
if (win) {
|
||||
win.webContents.send("remote-clipboard-sync-to-remote");
|
||||
}
|
||||
});
|
||||
globalShortcut.register("CommandOrControl+Shift+V", () => {
|
||||
log2.info("Global shortcut: sync clipboard from remote");
|
||||
const win = electronState.getMainWindow();
|
||||
if (win) {
|
||||
win.webContents.send("remote-clipboard-sync-from-remote");
|
||||
}
|
||||
});
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow3.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
});
|
||||
app.on("window-all-closed", () => {
|
||||
globalShortcut.unregisterAll();
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
//# sourceMappingURL=main.js.map
|
||||
File diff suppressed because one or more lines are too long
@@ -1,24 +0,0 @@
|
||||
// electron/preload.ts
|
||||
var import_electron = require("electron");
|
||||
console.log("--- PRELOAD SCRIPT LOADED SUCCESSFULLY ---");
|
||||
import_electron.contextBridge.exposeInMainWorld("electronAPI", {
|
||||
exportPDF: (title, htmlContent) => import_electron.ipcRenderer.invoke("export-pdf", title, htmlContent),
|
||||
selectHtmlFile: () => import_electron.ipcRenderer.invoke("select-html-file"),
|
||||
updateTitlebarButtons: (symbolColor) => import_electron.ipcRenderer.invoke("update-titlebar-buttons", symbolColor),
|
||||
onRemoteClipboardSyncToRemote: (callback) => {
|
||||
import_electron.ipcRenderer.on("remote-clipboard-sync-to-remote", callback);
|
||||
return () => import_electron.ipcRenderer.removeListener("remote-clipboard-sync-to-remote", callback);
|
||||
},
|
||||
onRemoteClipboardSyncFromRemote: (callback) => {
|
||||
import_electron.ipcRenderer.on("remote-clipboard-sync-from-remote", callback);
|
||||
return () => import_electron.ipcRenderer.removeListener("remote-clipboard-sync-from-remote", callback);
|
||||
},
|
||||
onRemoteClipboardAutoSync: (callback) => {
|
||||
const handler = (_event, text) => callback(text);
|
||||
import_electron.ipcRenderer.on("remote-clipboard-auto-sync", handler);
|
||||
return () => import_electron.ipcRenderer.removeListener("remote-clipboard-auto-sync", handler);
|
||||
},
|
||||
clipboardReadText: () => import_electron.ipcRenderer.invoke("clipboard-read-text"),
|
||||
clipboardWriteText: (text) => import_electron.ipcRenderer.invoke("clipboard-write-text", text)
|
||||
});
|
||||
//# sourceMappingURL=preload.cjs.map
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"sources":["../electron/preload.ts"],"sourcesContent":["import { contextBridge, ipcRenderer } from 'electron'\n\nconsole.log('--- PRELOAD SCRIPT LOADED SUCCESSFULLY ---')\n\ncontextBridge.exposeInMainWorld('electronAPI', {\n exportPDF: (title: string, htmlContent?: string) => ipcRenderer.invoke('export-pdf', title, htmlContent),\n selectHtmlFile: () => ipcRenderer.invoke('select-html-file'),\n updateTitlebarButtons: (symbolColor: string) => ipcRenderer.invoke('update-titlebar-buttons', symbolColor),\n onRemoteClipboardSyncToRemote: (callback: () => void) => {\n ipcRenderer.on('remote-clipboard-sync-to-remote', callback);\n return () => ipcRenderer.removeListener('remote-clipboard-sync-to-remote', callback);\n },\n onRemoteClipboardSyncFromRemote: (callback: () => void) => {\n ipcRenderer.on('remote-clipboard-sync-from-remote', callback);\n return () => ipcRenderer.removeListener('remote-clipboard-sync-from-remote', callback);\n },\n onRemoteClipboardAutoSync: (callback: (text: string) => void) => {\n const handler = (_event: Electron.IpcRendererEvent, text: string) => callback(text);\n ipcRenderer.on('remote-clipboard-auto-sync', handler);\n return () => ipcRenderer.removeListener('remote-clipboard-auto-sync', handler);\n },\n clipboardReadText: () => ipcRenderer.invoke('clipboard-read-text'),\n clipboardWriteText: (text: string) => ipcRenderer.invoke('clipboard-write-text', text),\n})\n"],"mappings":";AAAA,sBAA2C;AAE3C,QAAQ,IAAI,4CAA4C;AAExD,8BAAc,kBAAkB,eAAe;AAAA,EAC7C,WAAW,CAAC,OAAe,gBAAyB,4BAAY,OAAO,cAAc,OAAO,WAAW;AAAA,EACvG,gBAAgB,MAAM,4BAAY,OAAO,kBAAkB;AAAA,EAC3D,uBAAuB,CAAC,gBAAwB,4BAAY,OAAO,2BAA2B,WAAW;AAAA,EACzG,+BAA+B,CAAC,aAAyB;AACvD,gCAAY,GAAG,mCAAmC,QAAQ;AAC1D,WAAO,MAAM,4BAAY,eAAe,mCAAmC,QAAQ;AAAA,EACrF;AAAA,EACA,iCAAiC,CAAC,aAAyB;AACzD,gCAAY,GAAG,qCAAqC,QAAQ;AAC5D,WAAO,MAAM,4BAAY,eAAe,qCAAqC,QAAQ;AAAA,EACvF;AAAA,EACA,2BAA2B,CAAC,aAAqC;AAC/D,UAAM,UAAU,CAAC,QAAmC,SAAiB,SAAS,IAAI;AAClF,gCAAY,GAAG,8BAA8B,OAAO;AACpD,WAAO,MAAM,4BAAY,eAAe,8BAA8B,OAAO;AAAA,EAC/E;AAAA,EACA,mBAAmB,MAAM,4BAAY,OAAO,qBAAqB;AAAA,EACjE,oBAAoB,CAAC,SAAiB,4BAAY,OAAO,wBAAwB,IAAI;AACvF,CAAC;","names":[]}
|
||||
2114
docs/vercel/VERCEL_GUIDE.md
Normal file
2114
docs/vercel/VERCEL_GUIDE.md
Normal file
File diff suppressed because it is too large
Load Diff
478
docs/vercel/VERCEL_MARKETPLACE.md
Normal file
478
docs/vercel/VERCEL_MARKETPLACE.md
Normal file
@@ -0,0 +1,478 @@
|
||||
# Vercel Marketplace 集成文档
|
||||
|
||||
> 更新时间:2026年3月12日
|
||||
> 数据来源:https://vercel.com/marketplace
|
||||
|
||||
---
|
||||
|
||||
## 一、定价模式说明
|
||||
|
||||
| 标记 | 含义 | 说明 |
|
||||
|------|------|------|
|
||||
| 🆓 | 免费开源 | 完全免费,开源项目 |
|
||||
| 🆓Free | 免费套餐 | 有免费额度,付费升级 |
|
||||
| 💰 | 付费订阅 | 无免费层或限制大 |
|
||||
| 🏢 | 企业定价 | 需联系销售 |
|
||||
| ❓ | 需查询 | 定价不明确,需查看官网 |
|
||||
|
||||
---
|
||||
|
||||
## 二、分类列表(共22个)
|
||||
|
||||
| 分类 | URL |
|
||||
|------|-----|
|
||||
| AI Agents & Services | /marketplace/category/agents |
|
||||
| AI | /marketplace/category/ai |
|
||||
| Analytics | /marketplace/category/analytics |
|
||||
| Authentication | /marketplace/category/authentication |
|
||||
| CMS | /marketplace/category/cms |
|
||||
| Commerce | /marketplace/category/commerce |
|
||||
| Database | /marketplace/category/database |
|
||||
| DevTools | /marketplace/category/dev-tools |
|
||||
| Experimentation | /marketplace/category/experimentation |
|
||||
| Flags | /marketplace/category/flags |
|
||||
| Logging | /marketplace/category/logging |
|
||||
| Messaging | /marketplace/category/messaging |
|
||||
| Monitoring | /marketplace/category/monitoring |
|
||||
| Observability | /marketplace/category/observability |
|
||||
| Payments | /marketplace/category/payments |
|
||||
| Productivity | /marketplace/category/productivity |
|
||||
| Searching | /marketplace/category/searching |
|
||||
| Security | /marketplace/category/security |
|
||||
| Storage | /marketplace/category/storage |
|
||||
| Testing | /marketplace/category/testing |
|
||||
| Video | /marketplace/category/video |
|
||||
| Workflow | /marketplace/category/workflow |
|
||||
|
||||
---
|
||||
|
||||
## 三、推荐模板(Next.js专用)
|
||||
|
||||
| 模板名称 | 定价 | 描述 | URL |
|
||||
|----------|------|------|-----|
|
||||
| Next.js AI Chatbot | 🆓 | 全功能的可定制 Next.js AI 聊天机器人 | https://vercel.com/templates/ai/nextjs-ai-chatbot |
|
||||
| Statsig + Flags SDK | 🆓Free | 使用 Statsig 和 Flags SDK 在静态页面上运行实验 | https://vercel.com/templates/edge-config/statsig-experimentation-with-flags-sdk |
|
||||
| Next.js + Mux Video | 🆓Free | 使用 Mux 为 Next.js 应用添加视频 | https://vercel.com/templates/next.js/next-video-starter |
|
||||
|
||||
---
|
||||
|
||||
## 四、Native Integrations(原生集成)
|
||||
|
||||
### 4.1 Storage(存储)- 12个
|
||||
|
||||
| 名称 | 定价 | 描述 | URL |
|
||||
|------|------|------|-----|
|
||||
| **Neon** | 🆓Free | Postgres无服务器平台,0.5GB免费 | /marketplace/neon |
|
||||
| **AWS** | 🏢 | 亚马逊云服务 | /marketplace/aws |
|
||||
| **Upstash** | 🆓Free | 无服务器Redis/Vector,10K命令/天免费 | /marketplace/upstash |
|
||||
| **Supabase** | 🆓 | 开源Postgres开发平台 | /marketplace/supabase |
|
||||
| **Redis** | 🆓Free | 无服务器Redis,30MB免费 | /marketplace/redis |
|
||||
| **Nile** | 🆓Free | PostgreSQL for B2B | /marketplace/nile |
|
||||
| **MotherDuck** | ❓ | 分析的无服务器后端 | /marketplace/motherduck |
|
||||
| **Convex** | 🆓 | 开源后端平台 | /marketplace/convex |
|
||||
| **Prisma** | 🆓 | 开源ORM/Postgres | /marketplace/prisma |
|
||||
| **Turso Cloud** | 🆓Free | SQLite,500MB免费 | /marketplace/tursocloud |
|
||||
| **MongoDB Atlas** | 🏢 | MongoDB官方NoSQL数据库 | /marketplace/mongodbatlas |
|
||||
| **Mixedbread** | ❓ | 多模态搜索API | /marketplace/mixedbread |
|
||||
|
||||
### 4.2 AI(人工智能)- 4个
|
||||
|
||||
| 名称 | 定价 | 描述 | URL |
|
||||
|------|------|------|-----|
|
||||
| **xAI (Grok)** | 💰 | xAI的Grok AI | /marketplace/xai |
|
||||
| **Groq** | ❓ | AI快速推理服务 | /marketplace/groq |
|
||||
| **fal** | ❓ | 生成式媒体平台 | /marketplace/fal |
|
||||
| **Deep Infra** | ❓ | Deep Infra AI集成 | /marketplace/deepinfra |
|
||||
|
||||
### 4.3 Observability(可观测性)- 5个
|
||||
|
||||
| 名称 | 定价 | 描述 | URL |
|
||||
|------|------|------|-----|
|
||||
| **Sentry** | 🆓Free | 错误监控,5K错误/月免费 | /marketplace/sentry |
|
||||
| **Dash0** | ❓ | 日志、追踪、指标 | /marketplace/dash0 |
|
||||
| **Braintrust** | ❓ | AI评估监控平台 | /marketplace/braintrust |
|
||||
| **Kubiks** | ❓ | 日志追踪、仪表盘、警报 | /marketplace/kubiks |
|
||||
| **Rollbar** | 🆓Free | 实时崩溃报告,$0/月 | /marketplace/rollbar |
|
||||
|
||||
### 4.4 Monitoring(监控)- 1个
|
||||
|
||||
| 名称 | 定价 | 描述 | URL |
|
||||
|------|------|------|-----|
|
||||
| **Checkly** | 💰 | Playwright监控,$29/月起 | /marketplace/checkly |
|
||||
|
||||
### 4.5 Web Automation(网页自动化)- 2个
|
||||
|
||||
| 名称 | 定价 | 描述 | URL |
|
||||
|------|------|------|-----|
|
||||
| **Browserbase** | ❓ | 无服务器浏览器自动化 | /marketplace/browserbase |
|
||||
| **Kernel** | ❓ | AI代理互联网访问infra | /marketplace/kernel |
|
||||
|
||||
### 4.6 Code Review(代码审查)- 3个
|
||||
|
||||
| 名称 | 定价 | 描述 | URL |
|
||||
|------|------|------|-----|
|
||||
| **cubic** | ❓ | 复杂代码库AI审查 | /marketplace/cubic |
|
||||
| **CodeRabbit** | 🆓Free | 代码审查,500行/月免费,$9/月起 | /marketplace/coderabbit |
|
||||
| **Sourcery** | 🆓Free | AI代码审查,免费版无限 | /marketplace/sourcery |
|
||||
|
||||
### 4.7 Support Agent(客服代理)- 2个
|
||||
|
||||
| 名称 | 定价 | 描述 | URL |
|
||||
|------|------|------|-----|
|
||||
| **AssistLoop** | ❓ | AI客服代理 | /marketplace/assistloop |
|
||||
| **Chatbase** | ❓ | 免费AI客服代理 | /marketplace/chatbase |
|
||||
|
||||
### 4.8 Searching(搜索)- 1个
|
||||
|
||||
| 名称 | 定价 | 描述 | URL |
|
||||
|------|------|------|-----|
|
||||
| **Parallel Web Systems** | ❓ | AI网页搜索 | /marketplace/parallel |
|
||||
|
||||
### 4.9 Code Security(代码安全)- 1个
|
||||
|
||||
| 名称 | 定价 | 描述 | URL |
|
||||
|------|------|------|-----|
|
||||
| **Corridor** | ❓ | AI编码安全审查 | /marketplace/corridor |
|
||||
|
||||
### 4.10 Payments(支付)- 1个
|
||||
|
||||
| 名称 | 定价 | 描述 | URL |
|
||||
|------|------|------|-----|
|
||||
| **Stripe** | 🆓Free | 支付平台,交易手续费 | /marketplace/stripe |
|
||||
|
||||
### 4.11 Testing(测试)- 1个
|
||||
|
||||
| 名称 | 定价 | 描述 | URL |
|
||||
|------|------|------|-----|
|
||||
| **Autonoma AI** | ❓ | AI端到端测试 | /marketplace/autonoma-ai |
|
||||
|
||||
### 4.12 Analytics(分析)- 3个
|
||||
|
||||
| 名称 | 定价 | 描述 | URL |
|
||||
|------|------|------|-----|
|
||||
| **Statsig** | ❓ | 特性标志、实验、分析 | /marketplace/statsig |
|
||||
| **Hypertune** | ❓ | 类型安全特性标志 | /marketplace/hypertune |
|
||||
| **PostHog** | 🆓Free | 分析、会话回放,1M事件/月免费 | /marketplace/posthog |
|
||||
|
||||
### 4.13 Authentication(认证)- 2个
|
||||
|
||||
| 名称 | 定价 | 描述 | URL |
|
||||
|------|------|------|-----|
|
||||
| **Descope** | 🆓Free | 拖放式认证 | /marketplace/descope |
|
||||
| **Clerk** | 🆓Free | 认证服务,10K月活免费 | /marketplace/clerk |
|
||||
|
||||
### 4.14 DevTools(开发工具)- 1个
|
||||
|
||||
| 名称 | 定价 | 描述 | URL |
|
||||
|------|------|------|-----|
|
||||
| **Inngest** | 🆓 | 开源AI工作流平台 | /marketplace/inngest |
|
||||
|
||||
### 4.15 Experimentation(实验)- 1个
|
||||
|
||||
| 名称 | 定价 | 描述 | URL |
|
||||
|------|------|------|-----|
|
||||
| **GrowthBook** | 🆓 | 开源特性标志实验 | /marketplace/growthbook |
|
||||
|
||||
### 4.16 Video(视频)- 1个
|
||||
|
||||
| 名称 | 定价 | 描述 | URL |
|
||||
|------|------|------|-----|
|
||||
| **Mux** | 🆓Free | 视频服务,1K分钟免费 | /marketplace/mux |
|
||||
|
||||
### 4.17 CMS(内容管理)- 1个
|
||||
|
||||
| 名称 | 定价 | 描述 | URL |
|
||||
|------|------|------|-----|
|
||||
| **Sanity** | 🆓Free | 内容平台,10K文档免费 | /marketplace/sanity |
|
||||
|
||||
---
|
||||
|
||||
## 五、External Integrations(外部集成)
|
||||
|
||||
### 5.1 AI(人工智能)- 6个
|
||||
|
||||
| 名称 | 定价 | 描述 | URL |
|
||||
|------|------|------|-----|
|
||||
| **Deep Infra** | ❓ | AI集成 | /marketplace/deepinfra |
|
||||
| **ElevenLabs** | 💰 | AI文本转语音 | /marketplace/elevenlabs |
|
||||
| **LMNT** | ❓ | 文本转语音&语音克隆 | /marketplace/lmnt |
|
||||
| **Perplexity API** | ❓ | Perplexity LLM | /marketplace/pplx-api |
|
||||
| **Replicate** | 🆓Free | AI模型运行,有免费额度 | /marketplace/replicate |
|
||||
| **Together AI** | 💰 | 生成式AI云平台 | /marketplace/together-ai |
|
||||
|
||||
### 5.2 Analytics(分析)- 7个
|
||||
|
||||
| 名称 | 定价 | 描述 | URL |
|
||||
|------|------|------|-----|
|
||||
| **Hypertune** | ❓ | 特性标志 | /marketplace/hypertune |
|
||||
| **LaunchDarkly** | 💰 | 特性标志,$30/月起 | /marketplace/launchdarkly |
|
||||
| **Statsig** | ❓ | 特性标志实验 | /marketplace/statsig |
|
||||
| **Vercel Web Analytics** | 💰 | 第一方分析,$20/月 | /marketplace/vercel-analytics |
|
||||
| **DevCycle** | ❓ | 特性标志 | /marketplace/devcycle |
|
||||
| **Kameleoon** | ❓ | A/B测试 | /marketplace/kameleoon |
|
||||
| **Split** | 💰 | 特性标志,$49/月起 | /marketplace/split |
|
||||
|
||||
### 5.3 Authentication(认证)- 2个
|
||||
|
||||
| 名称 | 定价 | 描述 | URL |
|
||||
|------|------|------|-----|
|
||||
| **Auth0** | 🆓Free | 认证服务,7K月活免费 | /marketplace/auth0 |
|
||||
| **Clerk** | 🆓Free | 认证服务,10K月活免费 | /marketplace/clerk |
|
||||
|
||||
> ⚠️ **页面显示错误**:Neon 和 Supabase 在页面上被错误地归类为 Authentication,它们实际是 Storage 服务(提供 Postgres 数据库)。
|
||||
|
||||
### 5.4 CMS(内容管理)- 10个
|
||||
|
||||
| 名称 | 定价 | 描述 | URL |
|
||||
|------|------|------|-----|
|
||||
| **Agility CMS** | ❓ | Headless CMS | /marketplace/agility-cms |
|
||||
| **Builder.io** | 🏢 | 可视化CMS,企业定价 | https://vercel.com/new/templates?search=builder&cms=builder.io |
|
||||
| **ButterCMS** | ❓ | Headless CMS | /marketplace/buttercms |
|
||||
| **Contentful** | 🆓Free | 内容平台,25K记录免费 | /marketplace/contentful |
|
||||
| **Contentstack** | 🏢 | 全渠道CMS,企业 | https://vercel.com/guides/integrate-vercel-and-contentstack |
|
||||
| **DatoCMS** | ❓ | Headless CMS | /marketplace/datocms |
|
||||
| **Formspree** | ❓ | 表单后端 | /marketplace/formspree |
|
||||
| **Makeswift** | ❓ | Next.js可视化构建 | /marketplace/makeswift |
|
||||
| **Sanity** | 🆓Free | 内容平台,10K文档免费 | /marketplace/sanity |
|
||||
| **Sitecore XM Cloud** | 🏢 | SaaS CMS | https://vercel.com/docs/integrations/sitecore |
|
||||
|
||||
### 5.5 Commerce(商务)- 7个
|
||||
|
||||
| 名称 | 定价 | 描述 | URL |
|
||||
|------|------|------|-----|
|
||||
| **BigCommerce** | 💰 | 电商平台,$29/月起 | https://vercel.com/templates/next.js/nextjs-commerce |
|
||||
| **Saleor** | 🆓 | 开源商务API | https://vercel.com/templates/next.js/nextjs-saleor-commerce |
|
||||
| **Salesforce Commerce Cloud** | 🏢 | 商务平台,企业 | https://vercel.com/templates/next.js/salesforce-commerce-cloud-starter |
|
||||
| **Shopify** | 💰 | Headless电商,$29/月起 | https://vercel.com/docs/integrations/shopify |
|
||||
| **Sitecore OrderCloud** | 🏢 | B2X商务 | /marketplace/ordercloud |
|
||||
| **Swell** | 🆓 | 开源Headless电商 | /marketplace/swell |
|
||||
| **Wix** | 💰 | 业务解决方案 | /marketplace/wix |
|
||||
|
||||
### 5.6 DevTools(开发工具)- 8个
|
||||
|
||||
| 名称 | 定价 | 描述 | URL |
|
||||
|------|------|------|-----|
|
||||
| **Deploy Summary** | 🆓 | Vercel部署摘要 | /marketplace/deploy-summary |
|
||||
| **Doppler** | ❓ | 密钥管理 | /marketplace/doppler |
|
||||
| **Inngest** | 🆓 | 开源工作流平台 | /marketplace/inngest |
|
||||
| **Liveblocks** | 🆓Free | 实时协作,有免费层 | https://vercel.com/templates/next.js/liveblocks-starter-kit |
|
||||
| **Railway** | 🆓Free | 部署平台,$5额度免费 | /marketplace/railway |
|
||||
| **Raycast** | ❓ | Vercel项目集成 | /marketplace/raycast |
|
||||
| **Svix** | ❓ | Webhook服务 | /marketplace/svix |
|
||||
| **Terraform** | 🆓 | 开源IaC工具 | /marketplace/terraform |
|
||||
|
||||
> ⚠️ **注**:AWS 不在 External Integrations 列表中(通过 /partners/aws 跳转)
|
||||
|
||||
### 5.7 Logging(日志)- 8个
|
||||
|
||||
| 名称 | 定价 | 描述 | URL |
|
||||
|------|------|------|-----|
|
||||
| **Axiom** | 🏢 | 日志服务,企业 | /marketplace/axiom |
|
||||
| **Baselime** | 🏢 | 日志查询,企业 | /marketplace/baselime |
|
||||
| **Better Stack** | ❓ | 日志服务 | /marketplace/betterstack |
|
||||
| **GraphJSON** | ❓ | 日志可视化 | /marketplace/graphjson |
|
||||
| **Logalert** | ❓ | 日志警报 | /marketplace/logalert |
|
||||
| **Logflare** | ❓ | 日志服务 | /marketplace/logflare |
|
||||
| **Profound** | ❓ | AI活动监控 | /marketplace/profound |
|
||||
| **Sematext Logs** | ❓ | 日志服务 | /marketplace/sematext-logs |
|
||||
|
||||
### 5.8 Messaging(消息)- 4个
|
||||
|
||||
| 名称 | 定价 | 描述 | URL |
|
||||
|------|------|------|-----|
|
||||
| **Knock** | ❓ | 消息API | /marketplace/knock |
|
||||
| **Novu** | 🆓 | 开源通知基础设施 | /marketplace/novu |
|
||||
| **Resend** | 🆓Free | 邮件服务,3K邮件免费 | /marketplace/resend |
|
||||
| **Slack** | 🆓Free | 消息通知,免费版可用 | /marketplace/slack |
|
||||
|
||||
### 5.9 Monitoring(监控)- 3个
|
||||
|
||||
| 名称 | 定价 | 描述 | URL |
|
||||
|------|------|------|-----|
|
||||
| **Checkly** | 💰 | 监控平台,$29/月起 | /marketplace/checkly |
|
||||
| **DebugBear** | 💰 | 性能监控,$39/月起 | /marketplace/debugbear |
|
||||
| **Zeitgeist** | ❓ | Vercel部署管理 | /marketplace/zeitgeist-app |
|
||||
|
||||
### 5.10 Observability(可观测性)- 8个
|
||||
|
||||
| 名称 | 定价 | 描述 | URL |
|
||||
|------|------|------|-----|
|
||||
| **Dash0** | ❓ | 可观测平台 | /marketplace/dash0 |
|
||||
| **Datadog** | 💰 | 监控平台,$15/月起 | /marketplace/datadog |
|
||||
| **Highlight** | ❓ | 调试平台 | /marketplace/highlight |
|
||||
| **HyperDX** | ❓ | 可观测平台 | /marketplace/hyperdx |
|
||||
| **New Relic** | 💰 | 监控平台,$49/月起 | /marketplace/newrelic |
|
||||
| **Sentry** | 🆓Free | 错误监控,5K错误/月免费 | /marketplace/sentry |
|
||||
| **shipshape** | ❓ | 部署仪表盘 | /marketplace/shipshape |
|
||||
| **Middleware** | ❓ | AI云可观测平台 | /marketplace/middleware |
|
||||
|
||||
### 5.11 Productivity(生产力)- 3个
|
||||
|
||||
| 名称 | 定价 | 描述 | URL |
|
||||
|------|------|------|-----|
|
||||
| **GitHub Issues** | 🆓 | 评论转GitHub Issue | /marketplace/gh-issues |
|
||||
| **Jira** | 🆓 | 评论转Jira | /marketplace/jira |
|
||||
| **Linear** | 🆓 | 评论转Linear | /marketplace/linear |
|
||||
|
||||
### 5.12 Searching(搜索)- 2个
|
||||
|
||||
| 名称 | 定价 | 描述 | URL |
|
||||
|------|------|------|-----|
|
||||
| **Meilisearch Cloud** | 🏢 | 搜索服务,企业 | /marketplace/meilisearch-cloud |
|
||||
| **Upstash** | 🆓Free | Redis搜索,10K命令/天免费 | /marketplace/upstash |
|
||||
|
||||
### 5.13 Security(安全)- 1个
|
||||
|
||||
| 名称 | 定价 | 描述 | URL |
|
||||
|------|------|------|-----|
|
||||
| **Arcjet** | ❓ | 代码化安全 | /marketplace/arcjet |
|
||||
|
||||
### 5.14 Storage(存储)- 13个
|
||||
|
||||
| 名称 | 定价 | 描述 | URL |
|
||||
|------|------|------|-----|
|
||||
| **AWS S3** | 🏢 | 对象存储 | https://vercel.com/templates/next.js/aws-s3-image-upload-nextjs |
|
||||
| **Azure Cosmos DB** | 🏢 | NoSQL数据库 | /marketplace/azurecosmosdb |
|
||||
| **Couchbase Capella** | 🏢 | NoSQL云数据库 | /marketplace/couchbase-capella |
|
||||
| **DataStax Astra DB** | 🏢 | 向量数据库 | /marketplace/datastax-astra-db |
|
||||
| **Hasura** | 🆓 | 开源GraphQL API | /marketplace/hasura |
|
||||
| **Pinecone** | 🏢 | 向量数据库 | /marketplace/pinecone |
|
||||
| **SingleStoreDB Cloud** | 🏢 | 时序数据库 | /marketplace/singlestoredb-cloud |
|
||||
| **StepZen** | 🆓 | 开源GraphQL | /marketplace/stepzen |
|
||||
| **Thin Backend** | 🆓 | 开源Postgres后端 | /marketplace/thin |
|
||||
| **TiDB Cloud** | 🏢 | 向量MySQL | /marketplace/tidb-cloud |
|
||||
| **Tinybird** | 🏢 | 实时分析 | /marketplace/tinybird |
|
||||
| **Xata** | ❓ | 数据库服务 | /marketplace/xata |
|
||||
| **Tigris** | ❓ | 对象存储 | /marketplace/tigris |
|
||||
|
||||
### 5.15 Testing(测试)- 1个
|
||||
|
||||
| 名称 | 定价 | 描述 | URL |
|
||||
|------|------|------|-----|
|
||||
| **Meticulous AI** | ❓ | AI端到端测试 | /marketplace/meticulous |
|
||||
|
||||
---
|
||||
|
||||
## 六、Coming Soon(即将推出)
|
||||
|
||||
| 名称 | 定价 | 描述 |
|
||||
|------|------|------|
|
||||
| **Browser Use** | ❓ | 网页自动化,可订阅通知 |
|
||||
|
||||
---
|
||||
|
||||
## 七、统计汇总
|
||||
|
||||
| 类别 | 数量 |
|
||||
|------|------|
|
||||
| **总分类数** | 22 |
|
||||
| **模板数** | 3 |
|
||||
| **Native Integrations** | 42 |
|
||||
| **External Integrations** | 90 |
|
||||
| 🆓 免费开源 | ~15 |
|
||||
| 🆓Free 免费套餐 | ~25 |
|
||||
| 💰 付费订阅 | ~15 |
|
||||
| 🏢 企业定价 | ~20 |
|
||||
| ❓ 需查询定价 | ~35 |
|
||||
|
||||
---
|
||||
|
||||
## 八、Native Integrations 完整列表(42个)
|
||||
|
||||
### Storage(存储)- 12个
|
||||
Neon, AWS, Upstash, Supabase, Redis, Nile, MotherDuck, Convex, Prisma, Turso Cloud, MongoDB Atlas, Mixedbread
|
||||
|
||||
### AI(人工智能)- 4个
|
||||
xAI (Grok), Groq, fal, Deep Infra
|
||||
|
||||
### Observability(可观测性)- 5个
|
||||
Sentry, Dash0, Braintrust, Kubiks, Rollbar
|
||||
|
||||
### Monitoring(监控)- 1个
|
||||
Checkly
|
||||
|
||||
### Web Automation(网页自动化)- 2个
|
||||
Browserbase, Kernel
|
||||
|
||||
### Code Review(代码审查)- 3个
|
||||
cubic, CodeRabbit, Sourcery
|
||||
|
||||
### Support Agent(客服代理)- 2个
|
||||
AssistLoop, Chatbase
|
||||
|
||||
### Searching(搜索)- 1个
|
||||
Parallel Web Systems
|
||||
|
||||
### Code Security(代码安全)- 1个
|
||||
Corridor
|
||||
|
||||
### Payments(支付)- 1个
|
||||
Stripe
|
||||
|
||||
### Testing(测试)- 1个
|
||||
Autonoma AI
|
||||
|
||||
### Analytics(分析)- 3个
|
||||
Statsig, Hypertune, PostHog
|
||||
|
||||
### Authentication(认证)- 2个
|
||||
Descope, Clerk
|
||||
|
||||
### DevTools(开发工具)- 1个
|
||||
Inngest
|
||||
|
||||
### Experimentation(实验)- 1个
|
||||
GrowthBook
|
||||
|
||||
### Video(视频)- 1个
|
||||
Mux
|
||||
|
||||
### CMS(内容管理)- 1个
|
||||
Sanity
|
||||
|
||||
---
|
||||
|
||||
## 九、External Integrations 分类统计(89个)
|
||||
|
||||
| 类别 | 数量 |
|
||||
|------|------|
|
||||
| AI | 6 |
|
||||
| Analytics | 7 |
|
||||
| Authentication | 2 |
|
||||
| CMS | 10 |
|
||||
| Commerce | 7 |
|
||||
| DevTools | 8 |
|
||||
| Logging | 8 |
|
||||
| Messaging | 4 |
|
||||
| Monitoring | 3 |
|
||||
| Observability | 8 |
|
||||
| Productivity | 3 |
|
||||
| Searching | 2 |
|
||||
| Security | 1 |
|
||||
| Storage | 13 |
|
||||
| Testing | 1 |
|
||||
|
||||
---
|
||||
|
||||
## 十、快速参考
|
||||
|
||||
### ⚠️ Vercel专有(仅6个)
|
||||
这些服务只能在Vercel平台使用:
|
||||
- Vercel Web Analytics
|
||||
- Deploy Summary
|
||||
- Raycast
|
||||
- GitHub Issues
|
||||
- Jira
|
||||
- Linear
|
||||
|
||||
### 🆓 免费开源(完全免费)
|
||||
Supabase, Convex, Inngest, Novu, GrowthBook, Saleor, Swell, Hasura, StepZen, Thin Backend, Terraform, Prisma, Sourcery
|
||||
|
||||
### 🆓Free 免费套餐(适合个人项目)
|
||||
Neon, Upstash, Clerk, Auth0, Sentry, PostHog, Mux, Stripe, Resend, Railway, CodeRabbit, Sanity, Contentful, Turso, Redis, Descope, Replicate, Liveblocks, Rollbar
|
||||
|
||||
### 💰 付费订阅
|
||||
Vercel Analytics($20/月), Checkly($29/月), DebugBear($39/月), Datadog($15/月), New Relic($49/月), LaunchDarkly($30/月), BigCommerce($29/月), Shopify($29/月), ElevenLabs, Together AI
|
||||
|
||||
### 🏢 企业定价
|
||||
AWS, Azure, MongoDB Atlas, Pinecone, DataStax, Couchbase, TiDB, Tinybird, Meilisearch, Contentstack, Builder.io, Sitecore, Salesforce
|
||||
983
docs/vercel/vercel-mvp-system-v2.md
Normal file
983
docs/vercel/vercel-mvp-system-v2.md
Normal file
@@ -0,0 +1,983 @@
|
||||
# Vercel MVP Idea 验证系统 v2.0
|
||||
|
||||
> 精简版设计文档 - 专注核心价值
|
||||
|
||||
---
|
||||
|
||||
## 一、系统定位
|
||||
|
||||
**一句话定义**:帮你分析 idea 可行性,生成网站设计文档,追踪验证数据。
|
||||
|
||||
**核心价值**:
|
||||
1. **AI 深度分析** - 不是简单的评分,而是给出具体的市场分析和建议
|
||||
2. **设计文档生成** - 输出专业的网站设计文档,你用专业工具构建
|
||||
3. **数据追踪** - 部署后追踪访问和转化数据
|
||||
|
||||
---
|
||||
|
||||
## 二、核心流程
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 简化版流程 │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ 1. 输入 │ ──▶ │ 2. AI │ ──▶ │ 3. 生成 │
|
||||
│ Idea │ │ 深度分析 │ │ 设计文档 │
|
||||
└─────────────┘ └─────────────┘ └─────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ 4. 你自己 │
|
||||
│ 构建部署 │
|
||||
└─────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ 5. 追踪 │
|
||||
│ 验证数据 │
|
||||
└─────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、功能模块
|
||||
|
||||
### 3.1 Idea 管理
|
||||
|
||||
#### 数据模型
|
||||
|
||||
```typescript
|
||||
interface Idea {
|
||||
id: string
|
||||
name: string // "AI 写作助手"
|
||||
description: string // 一句话描述
|
||||
category: 'tool' | 'content' | 'saas' | 'ecommerce' | 'other'
|
||||
targetKeywords: string[] // 目标关键词
|
||||
status: 'draft' | 'analyzing' | 'ready' | 'deployed' | 'validated' | 'abandoned'
|
||||
|
||||
// AI 分析结果
|
||||
analysis?: IdeaAnalysis
|
||||
|
||||
// 设计文档
|
||||
designDoc?: DesignDocument
|
||||
|
||||
// 部署信息
|
||||
deployment?: {
|
||||
url: string
|
||||
deployedAt: Date
|
||||
}
|
||||
|
||||
// 验证数据
|
||||
validation?: {
|
||||
visits: number
|
||||
signups: number
|
||||
lastChecked: Date
|
||||
}
|
||||
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
```
|
||||
|
||||
#### 功能
|
||||
- 创建 / 编辑 / 删除 Idea
|
||||
- 状态管理(草稿 → 分析中 → 就绪 → 已部署 → 验证中 → 已验证 / 已放弃)
|
||||
- 列表展示 + 筛选
|
||||
|
||||
---
|
||||
|
||||
### 3.2 AI 深度分析
|
||||
|
||||
#### 分析维度
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ AI 分析输出 │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
1. 市场分析
|
||||
├── 市场规模估算
|
||||
├── 目标用户画像
|
||||
├── 用户痛点分析
|
||||
└── 竞品概览(3-5个主要竞品)
|
||||
|
||||
2. 可行性评估
|
||||
├── 技术难度 (1-5星)
|
||||
├── 资金需求 (低/中/高)
|
||||
├── 时间成本估算
|
||||
└── 总体可行性得分 (0-100)
|
||||
|
||||
3. 差异化建议
|
||||
├── 现有竞品不足之处
|
||||
├── 你的独特切入点
|
||||
└── MVP 功能建议
|
||||
|
||||
4. 变现路径
|
||||
├── 建议定价
|
||||
├── 收入模型
|
||||
└── 预估时间线
|
||||
|
||||
5. 风险提示
|
||||
├── 主要风险点
|
||||
└── 应对建议
|
||||
|
||||
6. 行动建议
|
||||
├── 是否值得继续
|
||||
├── 下一步行动清单
|
||||
└── 推荐验证方式
|
||||
```
|
||||
|
||||
#### 数据模型
|
||||
|
||||
```typescript
|
||||
interface IdeaAnalysis {
|
||||
// 市场分析
|
||||
market: {
|
||||
size: string // "中小型市场,预计月搜索量 10K-50K"
|
||||
targetUsers: string[] // ["内容创作者", "营销人员", "自由职业者"]
|
||||
painPoints: string[] // 用户痛点
|
||||
competitors: Competitor[] // 竞品列表
|
||||
}
|
||||
|
||||
// 可行性
|
||||
feasibility: {
|
||||
technicalDifficulty: 1 | 2 | 3 | 4 | 5
|
||||
fundRequirement: 'low' | 'medium' | 'high'
|
||||
timeEstimate: string // "2-4周可完成 MVP"
|
||||
overallScore: number // 0-100
|
||||
}
|
||||
|
||||
// 差异化
|
||||
differentiation: {
|
||||
competitorGaps: string[] // 竞品不足
|
||||
uniqueAngle: string // 你的切入点
|
||||
mvpFeatures: string[] // 建议 MVP 功能
|
||||
}
|
||||
|
||||
// 变现
|
||||
monetization: {
|
||||
pricingModel: 'subscription' | 'one-time' | 'freemium' | 'usage-based'
|
||||
suggestedPrice: { min: number, max: number, currency: string }
|
||||
revenueModel: string // 收入模型说明
|
||||
timeline: string // 预估盈利时间
|
||||
}
|
||||
|
||||
// 风险
|
||||
risks: {
|
||||
main: string // 主要风险
|
||||
mitigation: string // 应对建议
|
||||
}[]
|
||||
|
||||
// 行动建议
|
||||
recommendation: {
|
||||
shouldProceed: boolean // 是否建议继续
|
||||
confidence: number // 信心指数 0-100
|
||||
nextSteps: string[] // 下一步行动
|
||||
validationMethod: string // 推荐验证方式
|
||||
}
|
||||
|
||||
analyzedAt: Date
|
||||
}
|
||||
|
||||
interface Competitor {
|
||||
name: string
|
||||
url?: string
|
||||
description: string
|
||||
strengths: string[]
|
||||
weaknesses: string[]
|
||||
pricing?: string
|
||||
}
|
||||
```
|
||||
|
||||
#### AI Prompt 设计
|
||||
|
||||
```typescript
|
||||
const ANALYSIS_PROMPT = `
|
||||
你是一位资深的创业顾问和产品经理。请对以下 idea 进行深度分析:
|
||||
|
||||
## Idea 信息
|
||||
- 名称:{name}
|
||||
- 描述:{description}
|
||||
- 分类:{category}
|
||||
- 目标关键词:{keywords}
|
||||
|
||||
## 请输出以下内容:
|
||||
|
||||
### 1. 市场分析
|
||||
- 市场规模(基于你的知识判断)
|
||||
- 目标用户画像(3-5类)
|
||||
- 用户痛点(3-5个)
|
||||
- 主要竞品(3-5个,包括名称、简介、优缺点、定价)
|
||||
|
||||
### 2. 可行性评估
|
||||
- 技术难度(1-5星)
|
||||
- 资金需求(低/中/高)
|
||||
- 时间成本估算
|
||||
- 总体可行性得分(0-100分)
|
||||
|
||||
### 3. 差异化建议
|
||||
- 竞品的不足之处
|
||||
- 建议的独特切入点
|
||||
- MVP 核心功能建议(3-5个)
|
||||
|
||||
### 4. 变现路径
|
||||
- 建议定价模式
|
||||
- 价格区间
|
||||
- 收入模型
|
||||
- 预估盈利时间
|
||||
|
||||
### 5. 风险提示
|
||||
- 主要风险(2-3个)
|
||||
- 应对建议
|
||||
|
||||
### 6. 行动建议
|
||||
- 是否值得继续(是/否)
|
||||
- 信心指数(0-100)
|
||||
- 下一步行动清单
|
||||
- 推荐验证方式
|
||||
|
||||
请以 JSON 格式输出。
|
||||
`
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.3 设计文档生成
|
||||
|
||||
#### 文档结构
|
||||
|
||||
```typescript
|
||||
interface DesignDocument {
|
||||
id: string
|
||||
ideaId: string
|
||||
|
||||
// 基本信息
|
||||
projectName: string
|
||||
projectSlug: string // URL 友好的名称
|
||||
tagline: string // 一句话介绍
|
||||
|
||||
// 网站类型
|
||||
siteType: 'waitlist' | 'landing' | 'pricing' | 'blog' | 'portfolio'
|
||||
|
||||
// 目标
|
||||
primaryGoal: 'collect_emails' | 'show_product' | 'sell' | 'content'
|
||||
|
||||
// 页面结构
|
||||
pages: PageDefinition[]
|
||||
|
||||
// 设计规范
|
||||
design: {
|
||||
colorScheme: ColorScheme
|
||||
typography: Typography
|
||||
components: ComponentSpec[]
|
||||
}
|
||||
|
||||
// 内容
|
||||
content: {
|
||||
hero: HeroSection
|
||||
features: FeatureSection
|
||||
pricing?: PricingSection
|
||||
faq?: FaqSection
|
||||
cta: CtaSection
|
||||
}
|
||||
|
||||
// SEO
|
||||
seo: {
|
||||
title: string
|
||||
description: string
|
||||
keywords: string[]
|
||||
ogImage?: string
|
||||
}
|
||||
|
||||
// 技术建议
|
||||
techStack: {
|
||||
framework: string // 推荐框架
|
||||
hosting: string // 推荐托管
|
||||
analytics: string[] // 分析工具
|
||||
integrations: string[] // 集成建议
|
||||
}
|
||||
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
interface PageDefinition {
|
||||
name: string // "首页", "定价页"
|
||||
path: string // "/", "/pricing"
|
||||
sections: string[] // 页面包含的区块
|
||||
purpose: string // 页面目的
|
||||
}
|
||||
|
||||
interface ColorScheme {
|
||||
primary: string // 主色
|
||||
secondary: string // 辅色
|
||||
accent: string // 强调色
|
||||
background: string // 背景色
|
||||
text: string // 文字色
|
||||
}
|
||||
|
||||
interface Typography {
|
||||
headingFont: string
|
||||
bodyFont: string
|
||||
headingSizes: { h1: string, h2: string, h3: string }
|
||||
bodySize: string
|
||||
}
|
||||
|
||||
interface HeroSection {
|
||||
headline: string
|
||||
subheadline: string
|
||||
cta: {
|
||||
text: string
|
||||
action: string // "signup" | "learn_more" | "get_started"
|
||||
}
|
||||
visual: {
|
||||
type: 'image' | 'video' | 'illustration' | 'screenshot'
|
||||
description: string
|
||||
}
|
||||
}
|
||||
|
||||
interface FeatureSection {
|
||||
title: string
|
||||
subtitle?: string
|
||||
features: {
|
||||
icon: string // 图标建议
|
||||
title: string
|
||||
description: string
|
||||
}[]
|
||||
}
|
||||
|
||||
interface PricingSection {
|
||||
plans: {
|
||||
name: string // "Free", "Pro", "Enterprise"
|
||||
price: string // "$0", "$9.99/mo"
|
||||
features: string[]
|
||||
highlighted: boolean // 是否推荐
|
||||
cta: string // 按钮文字
|
||||
}[]
|
||||
}
|
||||
|
||||
interface FaqSection {
|
||||
items: {
|
||||
question: string
|
||||
answer: string
|
||||
}[]
|
||||
}
|
||||
|
||||
interface CtaSection {
|
||||
headline: string
|
||||
description: string
|
||||
buttonText: string
|
||||
}
|
||||
```
|
||||
|
||||
#### 网站类型模板
|
||||
|
||||
| 类型 | 适用场景 | 核心目标 | 页面结构 |
|
||||
|------|----------|----------|----------|
|
||||
| **Waitlist** | 产品未上线 | 收集邮箱 | Hero + Features + Waitlist Form |
|
||||
| **Landing** | 产品展示 | 展示 + 转化 | Hero + Features + Testimonials + CTA |
|
||||
| **Pricing** | SaaS 产品 | 销售订阅 | Hero + Features + Pricing + FAQ |
|
||||
| **Blog** | 内容/SEO | 流量 + 订阅 | Hero + Articles + Newsletter |
|
||||
| **Portfolio** | 个人/作品 | 展示能力 | Hero + Projects + About + Contact |
|
||||
|
||||
---
|
||||
|
||||
### 3.4 数据追踪
|
||||
|
||||
#### 追踪内容
|
||||
|
||||
```typescript
|
||||
interface ValidationData {
|
||||
ideaId: string
|
||||
|
||||
// 访问数据
|
||||
traffic: {
|
||||
totalVisits: number
|
||||
uniqueVisitors: number
|
||||
avgDuration: string // "2:34"
|
||||
bounceRate: number // 百分比
|
||||
trend: 'up' | 'down' | 'stable'
|
||||
}
|
||||
|
||||
// 转化数据
|
||||
conversion: {
|
||||
emailSignups: number
|
||||
conversionRate: number // 百分比
|
||||
sources: {
|
||||
direct: number
|
||||
organic: number
|
||||
social: number
|
||||
referral: number
|
||||
}
|
||||
}
|
||||
|
||||
// 时间线
|
||||
timeline: {
|
||||
date: string
|
||||
visits: number
|
||||
signups: number
|
||||
}[]
|
||||
|
||||
lastUpdated: Date
|
||||
}
|
||||
```
|
||||
|
||||
#### 数据来源
|
||||
- **Vercel Analytics API** - 访问量、访客数
|
||||
- **自定义追踪** - 表单提交、按钮点击
|
||||
- **UTM 参数** - 流量来源追踪
|
||||
|
||||
---
|
||||
|
||||
## 四、数据流设计
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ 数据流 │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌──────────────────┐
|
||||
│ 用户输入 Idea │
|
||||
│ - 名称 │
|
||||
│ - 描述 │
|
||||
│ - 分类 │
|
||||
│ - 关键词 │
|
||||
└────────┬─────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────┐
|
||||
│ AI 分析引擎 │
|
||||
│ (调用 LLM API) │
|
||||
│ │
|
||||
│ 输出: │
|
||||
│ - 市场分析 │
|
||||
│ - 可行性评估 │
|
||||
│ - 差异化建议 │
|
||||
│ - 变现路径 │
|
||||
│ - 风险提示 │
|
||||
│ - 行动建议 │
|
||||
└──────────────┬───────────────┘
|
||||
│
|
||||
┌──────────────┴───────────────┐
|
||||
│ 用户决定是否继续 │
|
||||
└──────────────┬───────────────┘
|
||||
│ 继续
|
||||
▼
|
||||
┌──────────────────────────────┐
|
||||
│ 选择网站类型 + 目标 │
|
||||
│ - Waitlist / Landing / ... │
|
||||
│ - 收集邮箱 / 展示 / 销售 │
|
||||
└──────────────┬───────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────┐
|
||||
│ 生成设计文档 │
|
||||
│ │
|
||||
│ 包含: │
|
||||
│ - 页面结构 │
|
||||
│ - 设计规范 │
|
||||
│ - 内容文案 │
|
||||
│ - SEO 配置 │
|
||||
│ - 技术建议 │
|
||||
└──────────────┬───────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────┐
|
||||
│ 导出设计文档 │
|
||||
│ - Markdown 格式 │
|
||||
│ - JSON 格式 │
|
||||
│ - 复制到剪贴板 │
|
||||
└──────────────┬───────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────┐
|
||||
│ 你自己构建和部署 │
|
||||
│ (使用 v0 / Cursor / 等) │
|
||||
└──────────────┬───────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────┐
|
||||
│ 录入部署 URL │
|
||||
│ 系统开始追踪数据 │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、技术架构
|
||||
|
||||
### 5.1 技术栈
|
||||
|
||||
| 层级 | 技术 | 说明 |
|
||||
|------|------|------|
|
||||
| 桌面端 | Electron | 现有 |
|
||||
| 前端 | React + TypeScript | 现有 |
|
||||
| 样式 | Tailwind CSS | 现有 |
|
||||
| 后端 | Express | 现有 |
|
||||
| 数据库 | SQLite | 本地存储 |
|
||||
| AI | OpenAI API / Anthropic API | LLM 调用 |
|
||||
| 数据 | Vercel Analytics API | 访问数据 |
|
||||
|
||||
### 5.2 模块结构
|
||||
|
||||
```
|
||||
api/modules/idea-validator/
|
||||
├── index.ts # 模块导出
|
||||
├── routes.ts # API 路由
|
||||
├── service.ts # 业务逻辑
|
||||
├── analyzer.ts # AI 分析逻辑
|
||||
├── generator.ts # 设计文档生成
|
||||
├── tracker.ts # 数据追踪
|
||||
├── prompts.ts # AI Prompt 模板
|
||||
├── types.ts # 类型定义
|
||||
└── db/
|
||||
├── ideas.ts # Idea 数据操作
|
||||
├── analysis.ts # 分析结果存储
|
||||
└── documents.ts # 设计文档存储
|
||||
|
||||
src/modules/idea-validator/
|
||||
├── index.ts
|
||||
├── pages/
|
||||
│ └── IdeaValidator.tsx # 主面板
|
||||
├── components/
|
||||
│ ├── IdeaList.tsx # Idea 列表
|
||||
│ ├── IdeaCard.tsx # Idea 卡片
|
||||
│ ├── IdeaForm.tsx # 创建/编辑表单
|
||||
│ ├── AnalysisView.tsx # 分析结果展示
|
||||
│ ├── DesignDocView.tsx # 设计文档预览
|
||||
│ ├── ExportPanel.tsx # 导出面板
|
||||
│ ├── ValidationChart.tsx # 验证数据图表
|
||||
│ └── Recommendation.tsx # AI 建议展示
|
||||
└── stores/
|
||||
└── ideaStore.ts # 状态管理
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、API 设计
|
||||
|
||||
### Idea 管理
|
||||
|
||||
```
|
||||
GET /api/ideas # 获取所有 Idea
|
||||
POST /api/ideas # 创建 Idea
|
||||
GET /api/ideas/:id # 获取单个 Idea
|
||||
PUT /api/ideas/:id # 更新 Idea
|
||||
DELETE /api/ideas/:id # 删除 Idea
|
||||
```
|
||||
|
||||
### AI 分析
|
||||
|
||||
```
|
||||
POST /api/ideas/:id/analyze # 执行 AI 分析
|
||||
GET /api/ideas/:id/analysis # 获取分析结果
|
||||
```
|
||||
|
||||
### 设计文档
|
||||
|
||||
```
|
||||
POST /api/ideas/:id/design # 生成设计文档
|
||||
GET /api/ideas/:id/design # 获取设计文档
|
||||
PUT /api/ideas/:id/design # 更新设计文档
|
||||
GET /api/ideas/:id/design/export # 导出文档 (markdown/json)
|
||||
```
|
||||
|
||||
### 数据追踪
|
||||
|
||||
```
|
||||
POST /api/ideas/:id/deployment # 录入部署信息
|
||||
GET /api/ideas/:id/validation # 获取验证数据
|
||||
POST /api/ideas/:id/sync-analytics # 同步 Vercel 数据
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、页面设计
|
||||
|
||||
### 7.1 主面板
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Idea Validator [+ 新建 Idea] │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌───────────┐ │
|
||||
│ │ 💡 总 Idea │ │ ✅ 已验证 │ │ 🚀 已部署 │ │ 📧 邮箱 │ │
|
||||
│ │ 12 │ │ 3 │ │ 5 │ │ 1,234 │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────┘ └───────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 我的 Ideas 筛选 ▾ │ │
|
||||
│ │ ───────────────────────────────────────────────────────── │ │
|
||||
│ │ │ │
|
||||
│ │ 💡 AI 写作助手 │ │
|
||||
│ │ 可行性: 78/100 │ 状态: 🟢 已验证 │ 邮箱: 234 │ │
|
||||
│ │ [查看分析] [设计文档] [验证数据] │ │
|
||||
│ │ │ │
|
||||
│ │ 💡 PDF 转换工具 │ │
|
||||
│ │ 可行性: 65/100 │ 状态: 🚀 已部署 │ 邮箱: 89 │ │
|
||||
│ │ [查看分析] [设计文档] [验证数据] │ │
|
||||
│ │ │ │
|
||||
│ │ 💡 时间追踪 App │ │
|
||||
│ │ 可行性: 45/100 │ 状态: 📝 草稿 │ │ │
|
||||
│ │ [继续分析] [删除] │ │
|
||||
│ │ │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 7.2 创建 Idea 流程
|
||||
|
||||
```
|
||||
Step 1: 基本信息
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ 创建新 Idea Step 1/3 │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Idea 名称 * │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ AI 写作助手 │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 一句话描述 * │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 帮助内容创作者快速生成高质量文章的 AI 工具 │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 分类 │
|
||||
│ ○ 工具类 ● SaaS ○ 内容类 ○ 电商 ○ 其他 │
|
||||
│ │
|
||||
│ 目标关键词(按回车添加) │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ AI writing ✕ content generator ✕ 写作工具 ✕ │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [取消] [下一步 →] │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Step 2: AI 分析中
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ AI 正在分析... │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ 分析市场中... │ │
|
||||
│ │ │ │
|
||||
│ └─────────────────┘ │
|
||||
│ │
|
||||
│ ████████░░░░░░░░ 45% │
|
||||
│ │
|
||||
│ 正在分析竞品... │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Step 3: 分析结果
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ 分析结果 [重新分析] │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 🎯 可行性评分 │ │
|
||||
│ │ │ │
|
||||
│ │ ████████████████████████████░░░░░░░░░░ 78/100 │ │
|
||||
│ │ │ │
|
||||
│ │ 💡 AI 建议:这个 idea 值得尝试! │ │
|
||||
│ │ 信心指数:72% │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 📊 市场分析 │ │
|
||||
│ │ ───────────────────────────────────────────────────────── │ │
|
||||
│ │ 市场规模:中小型,月搜索量约 20K-50K │ │
|
||||
│ │ 目标用户:内容创作者、营销人员、自由职业者 │ │
|
||||
│ │ 用户痛点:写作效率低、灵感枯竭、SEO优化困难 │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 🏆 竞品分析 │ │
|
||||
│ │ ───────────────────────────────────────────────────────── │ │
|
||||
│ │ 1. Jasper AI - 功能强大但价格贵 ($49/月起) │ │
|
||||
│ │ 2. Copy.ai - 简单易用但专业度不够 │ │
|
||||
│ │ 3. Writesonic - 性价比高但中文支持弱 │ │
|
||||
│ │ │ │
|
||||
│ │ 💡 差异化机会:专注中文市场 + 更低价格 + 更简洁的体验 │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 💰 变现建议 │ │
|
||||
│ │ ───────────────────────────────────────────────────────── │ │
|
||||
│ │ 定价模式:Freemium + 订阅 │ │
|
||||
│ │ 建议价格:免费版 + $9.99/月 Pro版 │ │
|
||||
│ │ 预估盈利:6-12个月 │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ ⚠️ 风险提示 │ │
|
||||
│ │ ───────────────────────────────────────────────────────── │ │
|
||||
│ │ 1. AI 领域竞争激烈,需快速迭代 │ │
|
||||
│ │ 2. 依赖大模型 API,成本需控制 │ │
|
||||
│ │ │ │
|
||||
│ │ 建议:从细分场景切入,如"小红书文案生成" │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 📋 下一步行动 │ │
|
||||
│ │ ───────────────────────────────────────────────────────── │ │
|
||||
│ │ ✅ 选择一个细分场景(如小红书文案) │ │
|
||||
│ │ ✅ 生成设计文档 │ │
|
||||
│ │ ✅ 构建简单落地页,验证需求 │ │
|
||||
│ │ ✅ 收集 100 个邮箱后再开发产品 │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [放弃] [生成设计文档 →] │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 7.3 设计文档页面
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ 设计文档 - AI 写作助手 [导出 ▾] [编辑] │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 基本信息 │ │
|
||||
│ │ ───────────────────────────────────────────────────────── │ │
|
||||
│ │ 项目名称:AIWriter │ │
|
||||
│ │ 项目 Slug:ai-writer │ │
|
||||
│ │ 标语:让写作更简单,让创作更高效 │ │
|
||||
│ │ 网站类型:Waitlist (收集邮箱) │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 页面结构 │ │
|
||||
│ │ ───────────────────────────────────────────────────────── │ │
|
||||
│ │ 首页 (/) │ │
|
||||
│ │ ├── Hero Section (主视觉区) │ │
|
||||
│ │ ├── Features Section (功能特点) │ │
|
||||
│ │ ├── How It Works (使用流程) │ │
|
||||
│ │ ├── Waitlist Form (邮箱收集) │ │
|
||||
│ │ └── Footer (页脚) │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Hero Section │ │
|
||||
│ │ ───────────────────────────────────────────────────────── │ │
|
||||
│ │ 主标题:AI 驱动的智能写作助手 │ │
|
||||
│ │ 副标题:告别写作焦虑,3分钟生成高质量文章 │ │
|
||||
│ │ CTA 按钮:加入等待名单 │ │
|
||||
│ │ 视觉元素:产品截图 / 动态演示 │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Features │ │
|
||||
│ │ ───────────────────────────────────────────────────────── │ │
|
||||
│ │ 1. 🚀 快速生成 - 3秒内生成完整文章 │ │
|
||||
│ │ 2. 🎯 SEO优化 - 自动优化关键词和结构 │ │
|
||||
│ │ 3. 🌍 多语言 - 支持10+种语言 │ │
|
||||
│ │ 4. ✏️ 风格定制 - 适配你的写作风格 │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 设计规范 │ │
|
||||
│ │ ───────────────────────────────────────────────────────── │ │
|
||||
│ │ 主色:#6366F1 (Indigo) │ │
|
||||
│ │ 辅色:#EC4899 (Pink) │ │
|
||||
│ │ 背景:#FFFFFF / #F8FAFC │ │
|
||||
│ │ 字体:Inter (英文) / Noto Sans SC (中文) │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ SEO 配置 │ │
|
||||
│ │ ───────────────────────────────────────────────────────── │ │
|
||||
│ │ Title: AI 写作助手 - 智能内容生成工具 │ │
|
||||
│ │ Description: AI 驱动的写作助手,帮助内容创作者... │ │
|
||||
│ │ Keywords: AI写作, 内容生成, 文案工具, ... │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 技术建议 │ │
|
||||
│ │ ───────────────────────────────────────────────────────── │ │
|
||||
│ │ 框架:Next.js 14 (App Router) │ │
|
||||
│ │ 托管:Vercel │ │
|
||||
│ │ 样式:Tailwind CSS │ │
|
||||
│ │ 分析:Vercel Analytics + 自定义追踪 │ │
|
||||
│ │ 邮箱收集:Resend / ConvertKit │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 7.4 导出面板
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ 导出设计文档 │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 格式选择: │
|
||||
│ ● Markdown ○ JSON ○ PDF │
|
||||
│ │
|
||||
│ 包含内容: │
|
||||
│ ☑ 基本信息 │
|
||||
│ ☑ 页面结构 │
|
||||
│ ☑ 内容文案 │
|
||||
│ ☑ 设计规范 │
|
||||
│ ☑ SEO 配置 │
|
||||
│ ☑ 技术建议 │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 预览 │ │
|
||||
│ │ ───────────────────────────────────────────────────────── │ │
|
||||
│ │ # AI Writer - 设计文档 │ │
|
||||
│ │ │ │
|
||||
│ │ ## 基本信息 │ │
|
||||
│ │ - 项目名称:AIWriter │ │
|
||||
│ │ - 标语:让写作更简单,让创作更高效 │ │
|
||||
│ │ ... │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [复制到剪贴板] [下载文件] [发送到邮箱] │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 7.5 验证数据页面
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ 验证数据 - AI 写作助手 [同步数据] [设置] │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 部署 URL:https://ai-writer.vercel.app [打开] [编辑] │
|
||||
│ 部署时间:2024-03-10 │
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌───────────┐ │
|
||||
│ │ 👁️ 总访问 │ │ 👥 独立访客 │ │ 📧 邮箱 │ │ 📈 转化率 │ │
|
||||
│ │ 1,234 │ │ 892 │ │ 234 │ │ 26.2% │ │
|
||||
│ │ ↑ 12% │ │ ↑ 8% │ │ ↑ 15% │ │ ↑ 2.1% │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────┘ └───────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 访问趋势 (最近7天) │ │
|
||||
│ │ ───────────────────────────────────────────────────────── │ │
|
||||
│ │ │ │
|
||||
│ │ 200 ┤ │ │
|
||||
│ │ 150 ┤ ╭──╮ │ │
|
||||
│ │ 100 ┤ ╭──╯ ╰──╮ │ │
|
||||
│ │ 50 ┤───╯ ╰───╮ │ │
|
||||
│ │ 0 ┼──────────────────╰─── │ │
|
||||
│ │ Mon Tue Wed Thu Fri Sat Sun │ │
|
||||
│ │ │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 流量来源 │ │
|
||||
│ │ ───────────────────────────────────────────────────────── │ │
|
||||
│ │ Direct ████████████████████░░░░░░░░░░░░░░░░░░░░ 45% │ │
|
||||
│ │ Social ██████████████░░░░░░░░░░░░░░░░░░░░░░░░░░ 30% │ │
|
||||
│ │ Organic ████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 20% │ │
|
||||
│ │ Referral ████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 5% │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 📊 验证评估 │ │
|
||||
│ │ ───────────────────────────────────────────────────────── │ │
|
||||
│ │ │ │
|
||||
│ │ 当前数据表明这个 idea 有一定潜力: │ │
|
||||
│ │ ✅ 转化率 26% 高于行业平均 (15-20%) │ │
|
||||
│ │ ✅ 流量稳步增长 │ │
|
||||
│ │ ⚠️ 社交媒体流量占比较高,需关注长期留存 │ │
|
||||
│ │ │ │
|
||||
│ │ 建议: │ │
|
||||
│ │ • 继续收集数据,目标 500+ 邮箱 │ │
|
||||
│ │ • 开始开发 MVP 核心功能 │ │
|
||||
│ │ • 尝试 SEO 获取更多自然流量 │ │
|
||||
│ │ │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、实施计划
|
||||
|
||||
### 第一阶段:核心功能(2周)
|
||||
|
||||
**Week 1**
|
||||
- [ ] Idea CRUD(创建/读取/更新/删除)
|
||||
- [ ] SQLite 数据存储
|
||||
- [ ] 基础 UI 组件
|
||||
|
||||
**Week 2**
|
||||
- [ ] AI 分析功能
|
||||
- [ ] 分析结果展示
|
||||
- [ ] 设计文档生成(基础版)
|
||||
|
||||
### 第二阶段:完善功能(2周)
|
||||
|
||||
**Week 3**
|
||||
- [ ] 设计文档编辑
|
||||
- [ ] 导出功能(Markdown/JSON)
|
||||
- [ ] 网站类型模板优化
|
||||
|
||||
**Week 4**
|
||||
- [ ] Vercel 数据追踪集成
|
||||
- [ ] 验证数据展示
|
||||
- [ ] 数据同步功能
|
||||
|
||||
### 第三阶段:优化迭代(按需)
|
||||
|
||||
- [ ] 更丰富的分析维度
|
||||
- [ ] 设计文档模板库
|
||||
- [ ] 数据可视化增强
|
||||
- [ ] AI 建议优化
|
||||
|
||||
---
|
||||
|
||||
## 九、配置需求
|
||||
|
||||
### AI 服务配置
|
||||
|
||||
```typescript
|
||||
// 配置项
|
||||
interface AIConfig {
|
||||
provider: 'openai' | 'anthropic' | 'azure'
|
||||
apiKey: string
|
||||
model: string // 'gpt-4' | 'claude-3-opus' 等
|
||||
maxTokens: number
|
||||
temperature: number
|
||||
}
|
||||
```
|
||||
|
||||
### Vercel 配置
|
||||
|
||||
```typescript
|
||||
// Vercel API 配置
|
||||
interface VercelConfig {
|
||||
apiToken: string // Vercel API Token
|
||||
teamId?: string // 团队 ID(可选)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十、后续扩展方向
|
||||
|
||||
### 可选功能(按需添加)
|
||||
|
||||
1. **多语言支持** - 分析结果和设计文档多语言
|
||||
2. **设计文档版本** - 保存多个版本,对比选择
|
||||
3. **团队协作** - 分享 idea 和分析结果
|
||||
4. **模板市场** - 用户分享设计文档模板
|
||||
5. **自动化建议** - 基于数据自动给出优化建议
|
||||
6. **竞品监控** - 定期更新竞品信息
|
||||
|
||||
---
|
||||
|
||||
*文档版本:v2.0*
|
||||
*最后更新:2024-03-12*
|
||||
462
electron/main.ts
462
electron/main.ts
@@ -5,6 +5,10 @@ import fs from 'fs';
|
||||
import log from 'electron-log';
|
||||
import { 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');
|
||||
});
|
||||
|
||||
@@ -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'),
|
||||
})
|
||||
|
||||
196
electron/services/opencodeService.ts
Normal file
196
electron/services/opencodeService.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { spawn, exec, ChildProcess } from 'child_process';
|
||||
import log from 'electron-log';
|
||||
|
||||
const OPENCODE_PORT = 4096;
|
||||
const HEALTH_CHECK_INTERVAL = 10000;
|
||||
const MAX_RESTART_ATTEMPTS = 3;
|
||||
const HEALTH_CHECK_TIMEOUT = 2000;
|
||||
|
||||
class OpenCodeService {
|
||||
private process: ChildProcess | null = null;
|
||||
private healthCheckTimer: NodeJS.Timeout | null = null;
|
||||
private restartAttempts = 0;
|
||||
private _isRunning = false;
|
||||
|
||||
get port(): number {
|
||||
return OPENCODE_PORT;
|
||||
}
|
||||
|
||||
isRunning(): boolean {
|
||||
return this._isRunning;
|
||||
}
|
||||
|
||||
getStatus(): { running: boolean; port: number; restartAttempts: number } {
|
||||
return {
|
||||
running: this._isRunning,
|
||||
port: this.port,
|
||||
restartAttempts: this.restartAttempts,
|
||||
};
|
||||
}
|
||||
|
||||
private getAuthHeaders(): Record<string, string> {
|
||||
const password = 'xc_opencode_password';
|
||||
const encoded = Buffer.from(`:${password}`).toString('base64');
|
||||
return {
|
||||
'Authorization': `Basic ${encoded}`,
|
||||
};
|
||||
}
|
||||
|
||||
private async checkHealth(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${this.port}/session`, {
|
||||
method: 'GET',
|
||||
headers: this.getAuthHeaders(),
|
||||
signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT),
|
||||
});
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
log.warn('[OpenCodeService] Health check failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async restart(): Promise<void> {
|
||||
if (this.restartAttempts >= MAX_RESTART_ATTEMPTS) {
|
||||
log.error('[OpenCodeService] Max restart attempts reached, giving up');
|
||||
this._isRunning = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.restartAttempts++;
|
||||
log.info(`[OpenCodeService] Attempting restart (${this.restartAttempts}/${MAX_RESTART_ATTEMPTS})...`);
|
||||
|
||||
await this.stop();
|
||||
await this.start();
|
||||
}
|
||||
|
||||
private startHealthCheck(): void {
|
||||
if (this.healthCheckTimer) {
|
||||
clearInterval(this.healthCheckTimer);
|
||||
}
|
||||
|
||||
this.healthCheckTimer = setInterval(async () => {
|
||||
const isHealthy = await this.checkHealth();
|
||||
if (!isHealthy && this._isRunning) {
|
||||
log.warn('[OpenCodeService] Health check failed, attempting restart...');
|
||||
await this.restart();
|
||||
}
|
||||
}, HEALTH_CHECK_INTERVAL);
|
||||
}
|
||||
|
||||
async start(): Promise<{ success: boolean; error?: string }> {
|
||||
if (this._isRunning && this.process) {
|
||||
log.info('[OpenCodeService] Already running');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
try {
|
||||
log.info('[OpenCodeService] Starting OpenCode server...');
|
||||
|
||||
this.process = spawn('opencode', ['serve'], {
|
||||
stdio: 'pipe',
|
||||
shell: true,
|
||||
detached: false,
|
||||
});
|
||||
|
||||
this.process.stdout?.on('data', (data) => {
|
||||
log.info(`[OpenCode] ${data}`);
|
||||
});
|
||||
|
||||
this.process.stderr?.on('data', (data) => {
|
||||
log.error(`[OpenCode error] ${data}`);
|
||||
});
|
||||
|
||||
this.process.on('error', (err) => {
|
||||
log.error('[OpenCodeService] Process error:', err);
|
||||
this._isRunning = false;
|
||||
this.process = null;
|
||||
});
|
||||
|
||||
this.process.on('exit', (code) => {
|
||||
log.info(`[OpenCodeService] Process exited with code ${code}`);
|
||||
this._isRunning = false;
|
||||
this.process = null;
|
||||
});
|
||||
|
||||
const maxWaitTime = 30000;
|
||||
const checkInterval = 500;
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < maxWaitTime) {
|
||||
const isHealthy = await this.checkHealth();
|
||||
if (isHealthy) {
|
||||
break;
|
||||
}
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, checkInterval));
|
||||
}
|
||||
|
||||
const finalHealth = await this.checkHealth();
|
||||
if (!finalHealth) {
|
||||
log.warn('[OpenCodeService] Health check failed after max wait time');
|
||||
}
|
||||
|
||||
this._isRunning = true;
|
||||
this.restartAttempts = 0;
|
||||
this.startHealthCheck();
|
||||
|
||||
log.info(`[OpenCodeService] Started successfully on port ${this.port}`);
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
log.error('[OpenCodeService] Failed to start:', error);
|
||||
this._isRunning = false;
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<{ success: boolean; error?: string }> {
|
||||
if (this.healthCheckTimer) {
|
||||
clearInterval(this.healthCheckTimer);
|
||||
this.healthCheckTimer = null;
|
||||
}
|
||||
|
||||
if (!this.process) {
|
||||
log.info('[OpenCodeService] Not running');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
try {
|
||||
log.info('[OpenCodeService] Stopping...');
|
||||
|
||||
const pid = this.process?.pid;
|
||||
const processRef = this.process;
|
||||
this.process = null;
|
||||
this._isRunning = false;
|
||||
|
||||
if (pid && process.platform === 'win32') {
|
||||
return new Promise((resolve) => {
|
||||
exec(`taskkill /F /T /PID ${pid}`, (error) => {
|
||||
if (error) {
|
||||
log.warn('[OpenCodeService] taskkill failed, process may already be dead:', error.message);
|
||||
}
|
||||
this.restartAttempts = 0;
|
||||
log.info('[OpenCodeService] Stopped');
|
||||
resolve({ success: true });
|
||||
});
|
||||
});
|
||||
} else if (pid && processRef) {
|
||||
processRef.kill('SIGTERM');
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 1000));
|
||||
if (!processRef.killed) {
|
||||
processRef.kill('SIGKILL');
|
||||
}
|
||||
this.restartAttempts = 0;
|
||||
log.info('[OpenCodeService] Stopped');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
this.restartAttempts = 0;
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
log.error('[OpenCodeService] Failed to stop:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const opencodeService = new OpenCodeService();
|
||||
190
electron/services/sddService.ts
Normal file
190
electron/services/sddService.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { spawn, exec, ChildProcess } from 'child_process';
|
||||
import { app } from 'electron';
|
||||
import path from 'path';
|
||||
import log from 'electron-log';
|
||||
|
||||
const SDD_PORT = 9998;
|
||||
const HEALTH_CHECK_INTERVAL = 10000;
|
||||
const HEALTH_CHECK_TIMEOUT = 2000;
|
||||
|
||||
class SDDService {
|
||||
private process: ChildProcess | null = null;
|
||||
private processPid: number | null = null;
|
||||
private healthCheckTimer: NodeJS.Timeout | null = null;
|
||||
private _isRunning = false;
|
||||
private _isStarting = false;
|
||||
|
||||
get port(): number {
|
||||
return SDD_PORT;
|
||||
}
|
||||
|
||||
isRunning(): boolean {
|
||||
return this._isRunning;
|
||||
}
|
||||
|
||||
getStatus(): { running: boolean; port: number } {
|
||||
return {
|
||||
running: this._isRunning,
|
||||
port: this.port,
|
||||
};
|
||||
}
|
||||
|
||||
private getExePath(): string {
|
||||
const exeName = 'XCSDD.exe';
|
||||
const isPackaged = app.isPackaged;
|
||||
|
||||
let basePath: string;
|
||||
if (isPackaged) {
|
||||
basePath = path.join(process.resourcesPath, 'app.asar.unpacked');
|
||||
} else {
|
||||
basePath = process.cwd();
|
||||
}
|
||||
|
||||
return path.join(basePath, 'services', 'xcsdd', exeName);
|
||||
}
|
||||
|
||||
private getExeArgs(): string[] {
|
||||
return ['--port', this.port.toString(), '--headless'];
|
||||
}
|
||||
|
||||
private async checkHealth(): Promise<boolean> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), HEALTH_CHECK_TIMEOUT);
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${this.port}`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
return response.ok || response.status === 401;
|
||||
} catch (error) {
|
||||
log.warn('[SDDService] Health check failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private startHealthCheck(): void {
|
||||
if (this.healthCheckTimer) {
|
||||
clearInterval(this.healthCheckTimer);
|
||||
}
|
||||
|
||||
this.healthCheckTimer = setInterval(async () => {
|
||||
const isHealthy = await this.checkHealth();
|
||||
if (!isHealthy && this._isRunning) {
|
||||
log.warn('[SDDService] Service not responding');
|
||||
}
|
||||
}, HEALTH_CHECK_INTERVAL);
|
||||
}
|
||||
|
||||
async start(): Promise<{ success: boolean; error?: string }> {
|
||||
if (this._isRunning && this.process) {
|
||||
log.info('[SDDService] Already running');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
if (this._isStarting) {
|
||||
log.info('[SDDService] Already starting');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
this._isStarting = true;
|
||||
|
||||
try {
|
||||
const exePath = this.getExePath();
|
||||
const exeArgs = this.getExeArgs();
|
||||
log.info(`[SDDService] Starting from: ${exePath} with args: ${exeArgs.join(' ')}`);
|
||||
|
||||
this.process = spawn(exePath, exeArgs, {
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
this.processPid = this.process.pid ?? null;
|
||||
|
||||
this.process.stdout?.on('data', (data) => {
|
||||
log.info(`[SDD] ${data}`);
|
||||
});
|
||||
|
||||
this.process.stderr?.on('data', (data) => {
|
||||
log.error(`[SDD error] ${data}`);
|
||||
});
|
||||
|
||||
this.process.on('error', (err) => {
|
||||
log.error('[SDDService] Process error:', err);
|
||||
this._isRunning = false;
|
||||
this._isStarting = false;
|
||||
this.process = null;
|
||||
this.processPid = null;
|
||||
});
|
||||
|
||||
this.process.on('exit', (code) => {
|
||||
log.info(`[SDDService] Process exited with code ${code}`);
|
||||
this._isRunning = false;
|
||||
this._isStarting = false;
|
||||
this.process = null;
|
||||
this.processPid = null;
|
||||
});
|
||||
|
||||
const maxWaitTime = 30000;
|
||||
const checkInterval = 500;
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < maxWaitTime) {
|
||||
const isHealthy = await this.checkHealth();
|
||||
if (isHealthy) {
|
||||
break;
|
||||
}
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, checkInterval));
|
||||
}
|
||||
|
||||
const finalHealth = await this.checkHealth();
|
||||
if (!finalHealth) {
|
||||
log.warn('[SDDService] Health check failed after max wait time');
|
||||
}
|
||||
|
||||
this._isRunning = true;
|
||||
this._isStarting = false;
|
||||
this.startHealthCheck();
|
||||
|
||||
log.info(`[SDDService] Started successfully on port ${this.port}`);
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
log.error('[SDDService] Failed to start:', error);
|
||||
this._isRunning = false;
|
||||
this._isStarting = false;
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<{ success: boolean; error?: string }> {
|
||||
if (this.healthCheckTimer) {
|
||||
clearInterval(this.healthCheckTimer);
|
||||
this.healthCheckTimer = null;
|
||||
}
|
||||
|
||||
if (!this.processPid) {
|
||||
log.info('[SDDService] Not running');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
log.info(`[SDDService] Stopping process ${this.processPid}...`);
|
||||
|
||||
exec(`taskkill /F /T /PID ${this.processPid}`, (error) => {
|
||||
this.process = null;
|
||||
this.processPid = null;
|
||||
this._isRunning = false;
|
||||
|
||||
if (error) {
|
||||
log.error('[SDDService] Failed to stop:', error);
|
||||
resolve({ success: false, error: error.message });
|
||||
} else {
|
||||
log.info('[SDDService] Stopped');
|
||||
resolve({ success: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const sddService = new SDDService();
|
||||
190
electron/services/terminalService.ts
Normal file
190
electron/services/terminalService.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { spawn, exec, ChildProcess } from 'child_process';
|
||||
import { app } from 'electron';
|
||||
import path from 'path';
|
||||
import log from 'electron-log';
|
||||
|
||||
const TERMINAL_PORT = 9997;
|
||||
const HEALTH_CHECK_INTERVAL = 10000;
|
||||
const HEALTH_CHECK_TIMEOUT = 2000;
|
||||
|
||||
class TerminalService {
|
||||
private process: ChildProcess | null = null;
|
||||
private processPid: number | null = null;
|
||||
private healthCheckTimer: NodeJS.Timeout | null = null;
|
||||
private _isRunning = false;
|
||||
private _isStarting = false;
|
||||
|
||||
get port(): number {
|
||||
return TERMINAL_PORT;
|
||||
}
|
||||
|
||||
isRunning(): boolean {
|
||||
return this._isRunning;
|
||||
}
|
||||
|
||||
getStatus(): { running: boolean; port: number } {
|
||||
return {
|
||||
running: this._isRunning,
|
||||
port: this.port,
|
||||
};
|
||||
}
|
||||
|
||||
private getExePath(): string {
|
||||
const exeName = 'XCTerminal.exe';
|
||||
const isPackaged = app.isPackaged;
|
||||
|
||||
let basePath: string;
|
||||
if (isPackaged) {
|
||||
basePath = path.join(process.resourcesPath, 'app.asar.unpacked');
|
||||
} else {
|
||||
basePath = process.cwd();
|
||||
}
|
||||
|
||||
return path.join(basePath, 'services', 'xcterminal', exeName);
|
||||
}
|
||||
|
||||
private getExeArgs(): string[] {
|
||||
return ['--port', this.port.toString()];
|
||||
}
|
||||
|
||||
private async checkHealth(): Promise<boolean> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), HEALTH_CHECK_TIMEOUT);
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${this.port}`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
return response.ok || response.status === 401;
|
||||
} catch (error) {
|
||||
log.warn('[TerminalService] Health check failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private startHealthCheck(): void {
|
||||
if (this.healthCheckTimer) {
|
||||
clearInterval(this.healthCheckTimer);
|
||||
}
|
||||
|
||||
this.healthCheckTimer = setInterval(async () => {
|
||||
const isHealthy = await this.checkHealth();
|
||||
if (!isHealthy && this._isRunning) {
|
||||
log.warn('[TerminalService] Service not responding');
|
||||
}
|
||||
}, HEALTH_CHECK_INTERVAL);
|
||||
}
|
||||
|
||||
async start(): Promise<{ success: boolean; error?: string }> {
|
||||
if (this._isRunning && this.process) {
|
||||
log.info('[TerminalService] Already running');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
if (this._isStarting) {
|
||||
log.info('[TerminalService] Already starting');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
this._isStarting = true;
|
||||
|
||||
try {
|
||||
const exePath = this.getExePath();
|
||||
const exeArgs = this.getExeArgs();
|
||||
log.info(`[TerminalService] Starting from: ${exePath} with args: ${exeArgs.join(' ')}`);
|
||||
|
||||
this.process = spawn(exePath, exeArgs, {
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
this.processPid = this.process.pid ?? null;
|
||||
|
||||
this.process.stdout?.on('data', (data) => {
|
||||
log.info(`[Terminal] ${data}`);
|
||||
});
|
||||
|
||||
this.process.stderr?.on('data', (data) => {
|
||||
log.error(`[Terminal error] ${data}`);
|
||||
});
|
||||
|
||||
this.process.on('error', (err) => {
|
||||
log.error('[TerminalService] Process error:', err);
|
||||
this._isRunning = false;
|
||||
this._isStarting = false;
|
||||
this.process = null;
|
||||
this.processPid = null;
|
||||
});
|
||||
|
||||
this.process.on('exit', (code) => {
|
||||
log.info(`[TerminalService] Process exited with code ${code}`);
|
||||
this._isRunning = false;
|
||||
this._isStarting = false;
|
||||
this.process = null;
|
||||
this.processPid = null;
|
||||
});
|
||||
|
||||
const maxWaitTime = 30000;
|
||||
const checkInterval = 500;
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < maxWaitTime) {
|
||||
const isHealthy = await this.checkHealth();
|
||||
if (isHealthy) {
|
||||
break;
|
||||
}
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, checkInterval));
|
||||
}
|
||||
|
||||
const finalHealth = await this.checkHealth();
|
||||
if (!finalHealth) {
|
||||
log.warn('[TerminalService] Health check failed after max wait time');
|
||||
}
|
||||
|
||||
this._isRunning = true;
|
||||
this._isStarting = false;
|
||||
this.startHealthCheck();
|
||||
|
||||
log.info(`[TerminalService] Started successfully on port ${this.port}`);
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
log.error('[TerminalService] Failed to start:', error);
|
||||
this._isRunning = false;
|
||||
this._isStarting = false;
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<{ success: boolean; error?: string }> {
|
||||
if (this.healthCheckTimer) {
|
||||
clearInterval(this.healthCheckTimer);
|
||||
this.healthCheckTimer = null;
|
||||
}
|
||||
|
||||
if (!this.processPid) {
|
||||
log.info('[TerminalService] Not running');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
log.info(`[TerminalService] Stopping process ${this.processPid}...`);
|
||||
|
||||
exec(`taskkill /F /T /PID ${this.processPid}`, (error) => {
|
||||
this.process = null;
|
||||
this.processPid = null;
|
||||
this._isRunning = false;
|
||||
|
||||
if (error) {
|
||||
log.error('[TerminalService] Failed to stop:', error);
|
||||
resolve({ success: false, error: error.message });
|
||||
} else {
|
||||
log.info('[TerminalService] Stopped');
|
||||
resolve({ success: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const terminalService = new TerminalService();
|
||||
190
electron/services/xcOpenCodeWebService.ts
Normal file
190
electron/services/xcOpenCodeWebService.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { spawn, exec, ChildProcess } from 'child_process';
|
||||
import { app } from 'electron';
|
||||
import path from 'path';
|
||||
import log from 'electron-log';
|
||||
|
||||
const XCOPENCODEWEB_PORT = 9999;
|
||||
const HEALTH_CHECK_INTERVAL = 10000;
|
||||
const HEALTH_CHECK_TIMEOUT = 2000;
|
||||
|
||||
class XCOpenCodeWebService {
|
||||
private process: ChildProcess | null = null;
|
||||
private processPid: number | null = null;
|
||||
private healthCheckTimer: NodeJS.Timeout | null = null;
|
||||
private _isRunning = false;
|
||||
private _isStarting = false;
|
||||
|
||||
get port(): number {
|
||||
return XCOPENCODEWEB_PORT;
|
||||
}
|
||||
|
||||
isRunning(): boolean {
|
||||
return this._isRunning;
|
||||
}
|
||||
|
||||
getStatus(): { running: boolean; port: number } {
|
||||
return {
|
||||
running: this._isRunning,
|
||||
port: this.port,
|
||||
};
|
||||
}
|
||||
|
||||
private getExePath(): string {
|
||||
const exeName = 'XCOpenCodeWeb.exe';
|
||||
const isPackaged = app.isPackaged;
|
||||
|
||||
let basePath: string;
|
||||
if (isPackaged) {
|
||||
basePath = path.join(process.resourcesPath, 'app.asar.unpacked');
|
||||
} else {
|
||||
basePath = process.cwd();
|
||||
}
|
||||
|
||||
return path.join(basePath, 'services', 'xcopencodeweb', exeName);
|
||||
}
|
||||
|
||||
private getExeArgs(): string[] {
|
||||
return ['--port', this.port.toString()];
|
||||
}
|
||||
|
||||
private async checkHealth(): Promise<boolean> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), HEALTH_CHECK_TIMEOUT);
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${this.port}`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
return response.ok || response.status === 401;
|
||||
} catch (error) {
|
||||
log.warn('[XCOpenCodeWebService] Health check failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private startHealthCheck(): void {
|
||||
if (this.healthCheckTimer) {
|
||||
clearInterval(this.healthCheckTimer);
|
||||
}
|
||||
|
||||
this.healthCheckTimer = setInterval(async () => {
|
||||
const isHealthy = await this.checkHealth();
|
||||
if (!isHealthy && this._isRunning) {
|
||||
log.warn('[XCOpenCodeWebService] Service not responding');
|
||||
}
|
||||
}, HEALTH_CHECK_INTERVAL);
|
||||
}
|
||||
|
||||
async start(): Promise<{ success: boolean; error?: string }> {
|
||||
if (this._isRunning && this.process) {
|
||||
log.info('[XCOpenCodeWebService] Already running');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
if (this._isStarting) {
|
||||
log.info('[XCOpenCodeWebService] Already starting');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
this._isStarting = true;
|
||||
|
||||
try {
|
||||
const exePath = this.getExePath();
|
||||
const exeArgs = this.getExeArgs();
|
||||
log.info(`[XCOpenCodeWebService] Starting from: ${exePath} with args: ${exeArgs.join(' ')}`);
|
||||
|
||||
this.process = spawn(exePath, exeArgs, {
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
this.processPid = this.process.pid ?? null;
|
||||
|
||||
this.process.stdout?.on('data', (data) => {
|
||||
log.info(`[XCOpenCodeWeb] ${data}`);
|
||||
});
|
||||
|
||||
this.process.stderr?.on('data', (data) => {
|
||||
log.error(`[XCOpenCodeWeb error] ${data}`);
|
||||
});
|
||||
|
||||
this.process.on('error', (err) => {
|
||||
log.error('[XCOpenCodeWebService] Process error:', err);
|
||||
this._isRunning = false;
|
||||
this._isStarting = false;
|
||||
this.process = null;
|
||||
this.processPid = null;
|
||||
});
|
||||
|
||||
this.process.on('exit', (code) => {
|
||||
log.info(`[XCOpenCodeWebService] Process exited with code ${code}`);
|
||||
this._isRunning = false;
|
||||
this._isStarting = false;
|
||||
this.process = null;
|
||||
this.processPid = null;
|
||||
});
|
||||
|
||||
const maxWaitTime = 30000;
|
||||
const checkInterval = 500;
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < maxWaitTime) {
|
||||
const isHealthy = await this.checkHealth();
|
||||
if (isHealthy) {
|
||||
break;
|
||||
}
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, checkInterval));
|
||||
}
|
||||
|
||||
const finalHealth = await this.checkHealth();
|
||||
if (!finalHealth) {
|
||||
log.warn('[XCOpenCodeWebService] Health check failed after max wait time');
|
||||
}
|
||||
|
||||
this._isRunning = true;
|
||||
this._isStarting = false;
|
||||
this.startHealthCheck();
|
||||
|
||||
log.info(`[XCOpenCodeWebService] Started successfully on port ${this.port}`);
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
log.error('[XCOpenCodeWebService] Failed to start:', error);
|
||||
this._isRunning = false;
|
||||
this._isStarting = false;
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<{ success: boolean; error?: string }> {
|
||||
if (this.healthCheckTimer) {
|
||||
clearInterval(this.healthCheckTimer);
|
||||
this.healthCheckTimer = null;
|
||||
}
|
||||
|
||||
if (!this.processPid) {
|
||||
log.info('[XCOpenCodeWebService] Not running');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
log.info(`[XCOpenCodeWebService] Stopping process ${this.processPid}...`);
|
||||
|
||||
exec(`taskkill /F /PID ${this.processPid}`, (error) => {
|
||||
this.process = null;
|
||||
this.processPid = null;
|
||||
this._isRunning = false;
|
||||
|
||||
if (error) {
|
||||
log.error('[XCOpenCodeWebService] Failed to stop:', error);
|
||||
resolve({ success: false, error: error.message });
|
||||
} else {
|
||||
log.info('[XCOpenCodeWebService] Stopped');
|
||||
resolve({ success: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const xcOpenCodeWebService = new XCOpenCodeWebService();
|
||||
@@ -13,6 +13,8 @@ class ElectronState {
|
||||
isDev: false,
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -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
591
package-lock.json
generated
@@ -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",
|
||||
|
||||
22
package.json
22
package.json
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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.
@@ -1,23 +0,0 @@
|
||||
[diff]
|
||||
algorithm = histogram
|
||||
[core]
|
||||
logallrefupdates = true
|
||||
quotePath = false
|
||||
commitGraph = true
|
||||
longpaths = true
|
||||
[gc]
|
||||
reflogexpire = 90
|
||||
writeCommitGraph = true
|
||||
[user]
|
||||
name = Gitea
|
||||
email = gitea@fake.local
|
||||
[receive]
|
||||
advertisePushOptions = true
|
||||
procReceiveRefs = refs/for
|
||||
[fetch]
|
||||
writeCommitGraph = true
|
||||
[safe]
|
||||
directory = *
|
||||
[uploadpack]
|
||||
allowfilter = true
|
||||
allowAnySHA1InWant = true
|
||||
@@ -1 +0,0 @@
|
||||
{"storage":"boltdb","index_type":"scorch"}
|
||||
@@ -1 +0,0 @@
|
||||
{"version":5}
|
||||
Binary file not shown.
@@ -1,52 +0,0 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIJQQIBADANBgkqhkiG9w0BAQEFAASCCSswggknAgEAAoICAQDE959xE4ZOnhPD
|
||||
4iDxlW5dVyXkzpbqADw9+N1D8vzCpvG2937zuQsMQ5BL1Qhzb6fQ8+Mo77TnDFLQ
|
||||
zXSW2r/zs1QNHZeCuPN9etrlkNKQT6zLFXobRXDeHf/CDvI2l6gJI1s9sMHgurm2
|
||||
XBRefVFqJUkeR3CLw2vNn09FxnwGQoTMyUejuA+n6ITII/0RDEgXOdZwKDOEXZom
|
||||
N7J25YPhsYZ1BGj38tysb/gWNhsFrxVCHPOGiuDll5x5fGMRmb436PvvWktX0Yfg
|
||||
4NTa6Re5IHpVCSAQR64HD8j0FsGsMDvXhrja5mdRWWjgnbuprtr/f8ei2AEj8CCe
|
||||
mh7OKb6y1pryLq12hins4cw3PdzS/3+I0VSa5uUpdvZRymjMLJr9tD2UdfJWr6lB
|
||||
Jp0U0wQ0vBl08kHsTIBOPGJlFtSZtwzP8+C2eAEw20C15qh7sE/NkbS5oyV0XZfB
|
||||
gtVB0W+FCJNfC5HRuBl3x0Ei0jpFG/QncuHXXripgF/bNcDp+K5iGyJAWMSb9aJp
|
||||
3lmijC99FGPMLiA4j5QkcR82yIWEVlrgYVEnP9dqaBEt4Rs2Gl1618uj0zdNq6Nj
|
||||
DyBB6zihf0PnBjvGSG2uqQKz2236MvhxYmPn8vxDuvq7/xdax0So+OGuptbHXhDR
|
||||
aLNqYCw24Vqg2sXhiuHbdDMrGQUlgQIDAQABAoICAFtlM3y6vJV6UF2Sbgrrddyl
|
||||
9ZVoLyrBlTKEadd/xr1jzcFCsovRD0lPiINHhLZb1xjkMkHqiJy1YTA2RaVCN9OT
|
||||
IKs9UfJ8c6+D9FKVkr8X2WwauSAyZp8KeITJfqbKVFR5LXtAq5XlzwrJS0JVEBQa
|
||||
3QTJzXVs5nbxN01/PkmBUDHeK/nSDKGzCPn9iQ4CDumIEvLUFKOU81RMf+kfssRl
|
||||
JajitPponPD+u7VCweMvTMPyvyVtB9JBOYA9sZKXLmavG0gDM/a56Tz11o8FndZv
|
||||
NZSDuXcUa7InJu3sKU4Gy53Ei743LzXWMQ4Y4t1nn4Ly/eWYKV9NqzUs+qJbSHrO
|
||||
cmFqYr6ypG86TbGk1ji3eDRjmvsUYM/lTNurAh9BPzGz0mTlrUGXc+OPmAU1LD6T
|
||||
EeArMLHVxunON27T6ytHUV4VDbd8oa1KzsdiKEW71/WsK09kFLkOcGVR30OzLni5
|
||||
pQRxUukVX7twcU4hmrheSNB+CMtxACfHRxPalczVCkX/xp2jxwfPGjqr4aSQqQfK
|
||||
+hMDy5/jc6PrhPd7RSadWPVnFYJaYofWXgi/uU+26jrRmNswetNdvPv9zu5nS1Hs
|
||||
VFK8Q5zgqsdG+QLEyNWtnz5fwkNMx49cB154V8AR1ZI6XLgbRUL069gNpvHzLl/N
|
||||
eTb9MKj2d1/b6f3XZxEpAoIBAQDz7tEI0YC1Q5M0EQC0j8zvZy5FLP4NtwE9ZbHt
|
||||
NsjsHC5xIb3ujhtIje66X/5cnwGy0Bh2QbMDeFXWKlHcw+CiFe1/13kLLUoGetMN
|
||||
yHXLPCVx3iYNIopZgG8Db7qM7rYkmgVyE6VWyyMzVrFbO/rvtX8BK9jjrDVNRzsF
|
||||
g1MvGpqSPZu4O3j5BK13U0+eBhTQIvVijvUVCc50ftQHXjhwGfvUsXoggemG0Q89
|
||||
UpcKHvsAZbPOtP16HHcTuWk1SyF4qeVN+z1O+gOqebXW8qdzIBsDE2kUcq9bube5
|
||||
5dJ8CknbKNYc/dTIMEdKu2zXDK57eL8OUZcT3csuI001d7JfAoIBAQDOtgfRub07
|
||||
xNAeH9zK/4/5Smj2D+Ja6C6a37s5fsS1m1KhG0JvD7f4dhABxIf7PY0mvyxSpJt9
|
||||
LzyYPzR8B/DoaM5L3aDxcIhsp7JQ+STj1V2SKsNW8M4kRTiEPZh81jzZYtBCxe1o
|
||||
sLu3lhICusrLtfSSbix2ijMdPDEe1aDMT/P3TVrHUk2u5fMrvZHDB6VqvidXqLyG
|
||||
R4ifF4eblfbWPtlnNIh4V2vCX4+dfH25oyaO/KKTMuV+0h2+h6AjTOcV2ntXlyZK
|
||||
/oUDWRPbM6m019DEBoG9VBawyY6aGTM44RHjKBBz6itj938TlseF2qm4BPctfIF+
|
||||
gVvKqwSM/PQfAoIBAHCEmlts9+ek0gPUS/T919QeThOOm2mMHsBJZnc7LBbtMOby
|
||||
X3/ogOFIxvOlT9k9ZzUqE/6Ic6CII1/0iWpB2B4r6y9rHuRu8ZRnl27mJp+mkMcj
|
||||
Z33rjtGWEp8NLInRmqbrfNOQCFYuwX/u30RsOGXV3E2YAiWSy8tnrevvFbHGncIT
|
||||
NP8YP8btx24hObp0p6kSVwotUxNvQJIv5nG3nmTnN2h2rRTNmACd8l+g9xauD62x
|
||||
O+1/QuTOuIaaodL5YukbxS/hUfhaDtLV7XDG4UKTbqJOk8vg0s6Grh7Lyfl5bXPw
|
||||
NEOPOlVVH61zItNXgCxoGAjszblWN2CC3BxrqBUCggEASt9QMbzvOAjvwRmVZcnv
|
||||
okI7hnT2bisPRnWnYQnzwjwCT+yJwaSV5F8PKTTAdFY1HEW+jiilUVCcyCCMqChQ
|
||||
MD5WCtC6DPnP0FtlkULNA+EyxVDL9F/Aqw6PjAarhvwqiirqeGUsuvDY7YRj/a0e
|
||||
6256qddSL5WbMgmtWRfT6G1FVtwj93JuRN1xmPRPKa9JUUKTCYNK1fBvIgDp04cc
|
||||
IzockO9MRxqTI5JteIOxHl5kBwKm+F5FFgyRTYPekyq1wQqkBnPvINbT4wSO1qT9
|
||||
4U0Shw48TBF7LomzJ0ndbcrIKdlHLFUzZkAtPTEuD+PF+auCxG0GkoXUc7JCMbcl
|
||||
zQKCAQBuyk/G/9G458QPfvp4if0TNMPuX+yzcmjEcOf4x2vgTA1t6me4nPm4xEF2
|
||||
TUO/hECYiNG1DyeN/rRdW6lce9CZMaP9D1Dc1g4fAGfxBBhrBvV7o4h0dffpOTNh
|
||||
wyKx08C8rmImmvlt3aWyH+2sTe43GScbchSkEid8M/0RpaKp4b61zBhGUgYspKNS
|
||||
QL+7dTq9iosBTaR+zWKBv6cfOahxXFdVQYkh1woDCLdWX5FCKjV/gD46UFEtMTYf
|
||||
ABcF+5u2Pbxsf49gUN1wiJBx0o5+81IB34lIscp8wA+3ZRUeDnAbaEPTeoVIP+Of
|
||||
ohu0miobcXoUkKle/P8TFqStcnW6
|
||||
-----END PRIVATE KEY-----
|
||||
Binary file not shown.
@@ -1 +0,0 @@
|
||||
MANIFEST-000050
|
||||
@@ -1 +0,0 @@
|
||||
MANIFEST-000048
|
||||
@@ -1,223 +0,0 @@
|
||||
=============== Mar 4, 2026 (CST) ===============
|
||||
23:25:27.295431 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
||||
23:25:27.296428 db@open opening
|
||||
23:25:27.297425 version@stat F·[] S·0B[] Sc·[]
|
||||
23:25:27.297425 db@janitor F·2 G·0
|
||||
23:25:27.297425 db@open done T·997.1µs
|
||||
=============== Mar 5, 2026 (CST) ===============
|
||||
00:58:48.117008 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
||||
00:58:48.118039 version@stat F·[] S·0B[] Sc·[]
|
||||
00:58:48.118039 db@open opening
|
||||
00:58:48.118039 journal@recovery F·1
|
||||
00:58:48.118039 journal@recovery recovering @1
|
||||
00:58:48.119011 memdb@flush created L0@2 N·28 S·574B "act..igh,v28":"web..low,v19"
|
||||
00:58:48.120008 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
00:58:48.122002 db@janitor F·3 G·0
|
||||
00:58:48.122002 db@open done T·3.9631ms
|
||||
=============== Mar 5, 2026 (CST) ===============
|
||||
16:44:08.552284 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
||||
16:44:08.552284 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
16:44:08.552284 db@open opening
|
||||
16:44:08.552284 journal@recovery F·1
|
||||
16:44:08.552284 journal@recovery recovering @3
|
||||
16:44:08.553282 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
16:44:08.555630 db@janitor F·3 G·0
|
||||
16:44:08.555630 db@open done T·3.3467ms
|
||||
=============== Mar 5, 2026 (CST) ===============
|
||||
16:58:44.704290 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
||||
16:58:44.704290 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
16:58:44.704290 db@open opening
|
||||
16:58:44.704290 journal@recovery F·1
|
||||
16:58:44.704290 journal@recovery recovering @5
|
||||
16:58:44.705289 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
16:58:44.709300 db@janitor F·3 G·0
|
||||
16:58:44.709300 db@open done T·5.0092ms
|
||||
=============== Mar 5, 2026 (CST) ===============
|
||||
16:58:55.013041 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
||||
16:58:55.014038 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
16:58:55.014038 db@open opening
|
||||
16:58:55.014038 journal@recovery F·1
|
||||
16:58:55.014038 journal@recovery recovering @7
|
||||
16:58:55.014038 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
16:58:55.019025 db@janitor F·3 G·0
|
||||
16:58:55.019025 db@open done T·4.9869ms
|
||||
=============== Mar 5, 2026 (CST) ===============
|
||||
17:01:48.341808 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
||||
17:01:48.341808 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
17:01:48.341808 db@open opening
|
||||
17:01:48.341808 journal@recovery F·1
|
||||
17:01:48.341808 journal@recovery recovering @9
|
||||
17:01:48.342805 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
17:01:48.346766 db@janitor F·3 G·0
|
||||
17:01:48.346766 db@open done T·4.9576ms
|
||||
=============== Mar 5, 2026 (CST) ===============
|
||||
17:05:22.244372 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
||||
17:05:22.245370 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
17:05:22.245370 db@open opening
|
||||
17:05:22.245370 journal@recovery F·1
|
||||
17:05:22.245370 journal@recovery recovering @11
|
||||
17:05:22.245370 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
17:05:22.250356 db@janitor F·3 G·0
|
||||
17:05:22.250356 db@open done T·4.9865ms
|
||||
=============== Mar 5, 2026 (CST) ===============
|
||||
17:15:26.696181 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
||||
17:15:26.696181 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
17:15:26.696181 db@open opening
|
||||
17:15:26.696181 journal@recovery F·1
|
||||
17:15:26.696181 journal@recovery recovering @13
|
||||
17:15:26.696181 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
17:15:26.701109 db@janitor F·3 G·0
|
||||
17:15:26.701109 db@open done T·4.9277ms
|
||||
=============== Mar 5, 2026 (CST) ===============
|
||||
17:42:26.549391 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
||||
17:42:26.550343 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
17:42:26.550343 db@open opening
|
||||
17:42:26.550343 journal@recovery F·1
|
||||
17:42:26.550343 journal@recovery recovering @15
|
||||
17:42:26.552338 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
17:42:26.554337 db@janitor F·3 G·0
|
||||
17:42:26.554337 db@open done T·3.9934ms
|
||||
=============== Mar 5, 2026 (CST) ===============
|
||||
17:53:41.022244 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
||||
17:53:41.022244 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
17:53:41.022244 db@open opening
|
||||
17:53:41.022244 journal@recovery F·1
|
||||
17:53:41.022244 journal@recovery recovering @17
|
||||
17:53:41.023231 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
17:53:41.026274 db@janitor F·3 G·0
|
||||
17:53:41.026274 db@open done T·4.0304ms
|
||||
=============== Mar 5, 2026 (CST) ===============
|
||||
18:06:44.931577 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
||||
18:06:44.932090 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
18:06:44.932090 db@open opening
|
||||
18:06:44.932090 journal@recovery F·1
|
||||
18:06:44.932602 journal@recovery recovering @19
|
||||
18:06:44.933113 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
18:06:44.935926 db@janitor F·3 G·0
|
||||
18:06:44.935926 db@open done T·3.8362ms
|
||||
=============== Mar 5, 2026 (CST) ===============
|
||||
18:17:10.357320 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
||||
18:17:10.357320 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
18:17:10.357833 db@open opening
|
||||
18:17:10.357841 journal@recovery F·1
|
||||
18:17:10.357841 journal@recovery recovering @21
|
||||
18:17:10.357841 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
18:17:10.361450 db@janitor F·3 G·0
|
||||
18:17:10.361450 db@open done T·3.6088ms
|
||||
=============== Mar 5, 2026 (CST) ===============
|
||||
18:20:47.461717 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
||||
18:20:47.462247 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
18:20:47.462247 db@open opening
|
||||
18:20:47.462247 journal@recovery F·1
|
||||
18:20:47.462247 journal@recovery recovering @23
|
||||
18:20:47.464218 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
18:20:47.466420 db@janitor F·3 G·0
|
||||
18:20:47.466420 db@open done T·4.1729ms
|
||||
=============== Mar 5, 2026 (CST) ===============
|
||||
18:24:07.472215 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
||||
18:24:07.473212 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
18:24:07.473212 db@open opening
|
||||
18:24:07.473212 journal@recovery F·1
|
||||
18:24:07.473212 journal@recovery recovering @25
|
||||
18:24:07.473212 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
18:24:07.476910 db@janitor F·3 G·0
|
||||
18:24:07.476910 db@open done T·3.6976ms
|
||||
=============== Mar 5, 2026 (CST) ===============
|
||||
18:41:46.328705 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
||||
18:41:46.329704 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
18:41:46.329704 db@open opening
|
||||
18:41:46.329704 journal@recovery F·1
|
||||
18:41:46.329704 journal@recovery recovering @27
|
||||
18:41:46.329704 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
18:41:46.334694 db@janitor F·3 G·0
|
||||
18:41:46.334694 db@open done T·4.9906ms
|
||||
=============== Mar 5, 2026 (CST) ===============
|
||||
20:17:07.605654 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
||||
20:17:07.606653 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
20:17:07.606653 db@open opening
|
||||
20:17:07.606653 journal@recovery F·1
|
||||
20:17:07.606653 journal@recovery recovering @29
|
||||
20:17:07.606653 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
20:17:07.609645 db@janitor F·3 G·0
|
||||
20:17:07.609645 db@open done T·2.9923ms
|
||||
=============== Mar 5, 2026 (CST) ===============
|
||||
20:18:45.285721 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
||||
20:18:45.286227 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
20:18:45.286227 db@open opening
|
||||
20:18:45.286227 journal@recovery F·1
|
||||
20:18:45.286227 journal@recovery recovering @31
|
||||
20:18:45.286227 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
20:18:45.290050 db@janitor F·3 G·0
|
||||
20:18:45.290050 db@open done T·3.823ms
|
||||
=============== Mar 5, 2026 (CST) ===============
|
||||
20:24:14.214040 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
||||
20:24:14.215038 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
20:24:14.215038 db@open opening
|
||||
20:24:14.215038 journal@recovery F·1
|
||||
20:24:14.217038 journal@recovery recovering @33
|
||||
20:24:14.217038 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
20:24:14.221083 db@janitor F·3 G·0
|
||||
20:24:14.221083 db@open done T·6.0448ms
|
||||
=============== Mar 5, 2026 (CST) ===============
|
||||
20:27:37.450756 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
||||
20:27:37.451263 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
20:27:37.451263 db@open opening
|
||||
20:27:37.451263 journal@recovery F·1
|
||||
20:27:37.451263 journal@recovery recovering @35
|
||||
20:27:37.451263 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
20:27:37.454842 db@janitor F·3 G·0
|
||||
20:27:37.454842 db@open done T·3.579ms
|
||||
=============== Mar 5, 2026 (CST) ===============
|
||||
20:30:19.881992 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
||||
20:30:19.881992 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
20:30:19.881992 db@open opening
|
||||
20:30:19.881992 journal@recovery F·1
|
||||
20:30:19.881992 journal@recovery recovering @37
|
||||
20:30:19.882990 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
20:30:19.884984 db@janitor F·3 G·0
|
||||
20:30:19.884984 db@open done T·2.9921ms
|
||||
=============== Mar 5, 2026 (CST) ===============
|
||||
20:33:21.085745 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
||||
20:33:21.086742 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
20:33:21.086742 db@open opening
|
||||
20:33:21.086742 journal@recovery F·1
|
||||
20:33:21.086742 journal@recovery recovering @39
|
||||
20:33:21.088251 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
20:33:21.093539 db@janitor F·3 G·0
|
||||
20:33:21.093539 db@open done T·6.7975ms
|
||||
=============== Mar 5, 2026 (CST) ===============
|
||||
20:35:21.489745 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
||||
20:35:21.490699 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
20:35:21.490699 db@open opening
|
||||
20:35:21.490699 journal@recovery F·1
|
||||
20:35:21.490699 journal@recovery recovering @41
|
||||
20:35:21.491220 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
20:35:21.495194 db@janitor F·3 G·0
|
||||
20:35:21.495194 db@open done T·4.4953ms
|
||||
=============== Mar 5, 2026 (CST) ===============
|
||||
20:42:06.364290 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
||||
20:42:06.364290 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
20:42:06.364290 db@open opening
|
||||
20:42:06.364796 journal@recovery F·1
|
||||
20:42:06.364796 journal@recovery recovering @43
|
||||
20:42:06.364796 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
20:42:06.369434 db@janitor F·3 G·0
|
||||
20:42:06.369434 db@open done T·5.1446ms
|
||||
=============== Mar 5, 2026 (CST) ===============
|
||||
20:46:58.679386 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
||||
20:46:58.679386 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
20:46:58.679386 db@open opening
|
||||
20:46:58.679386 journal@recovery F·1
|
||||
20:46:58.679386 journal@recovery recovering @45
|
||||
20:46:58.680383 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
20:46:58.683452 db@janitor F·3 G·0
|
||||
20:46:58.683452 db@open done T·4.0659ms
|
||||
=============== Mar 5, 2026 (CST) ===============
|
||||
20:49:54.485040 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
||||
20:49:54.485040 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
20:49:54.485040 db@open opening
|
||||
20:49:54.485040 journal@recovery F·1
|
||||
20:49:54.485040 journal@recovery recovering @47
|
||||
20:49:54.486036 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
20:49:54.488644 db@janitor F·3 G·0
|
||||
20:49:54.488644 db@open done T·3.6037ms
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
27
remote/package-lock.json
generated
27
remote/package-lock.json
generated
@@ -13,6 +13,7 @@
|
||||
"bcryptjs": "^2.4.3",
|
||||
"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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 });
|
||||
|
||||
261
remote/src/server/FileHandler.js
Normal file
261
remote/src/server/FileHandler.js
Normal file
@@ -0,0 +1,261 @@
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const logger = require('../utils/logger');
|
||||
const MessageTypes = require('./messageTypes');
|
||||
const { fileService } = require('../services/file');
|
||||
|
||||
const CHUNK_SIZE = 5 * 1024 * 1024;
|
||||
|
||||
class FileHandler {
|
||||
constructor() {
|
||||
this.uploadSessions = new Map();
|
||||
}
|
||||
|
||||
handleMessage(message, ws) {
|
||||
const { type, requestId, ...data } = message;
|
||||
|
||||
switch (type) {
|
||||
case MessageTypes.FILE_LIST_GET:
|
||||
this.handleFileList(ws, requestId);
|
||||
break;
|
||||
case MessageTypes.FILE_BROWSE:
|
||||
this.handleFileBrowse(ws, data, requestId);
|
||||
break;
|
||||
case MessageTypes.FILE_UPLOAD_START:
|
||||
this.handleFileUploadStart(ws, data, requestId);
|
||||
break;
|
||||
case MessageTypes.FILE_UPLOAD_CHUNK:
|
||||
this.handleFileUploadChunk(ws, data, requestId);
|
||||
break;
|
||||
case MessageTypes.FILE_UPLOAD_MERGE:
|
||||
this.handleFileUploadMerge(ws, data, requestId);
|
||||
break;
|
||||
case MessageTypes.FILE_DOWNLOAD_START:
|
||||
this.handleFileDownload(ws, data, requestId);
|
||||
break;
|
||||
case MessageTypes.FILE_DELETE:
|
||||
this.handleFileDelete(ws, data, requestId);
|
||||
break;
|
||||
default:
|
||||
logger.debug('Unknown file message type', { type });
|
||||
}
|
||||
}
|
||||
|
||||
sendResponse(ws, type, requestId, payload) {
|
||||
ws.send(JSON.stringify({
|
||||
type,
|
||||
requestId,
|
||||
...payload
|
||||
}));
|
||||
}
|
||||
|
||||
handleFileList(ws, requestId) {
|
||||
try {
|
||||
const files = fileService.getFileList();
|
||||
this.sendResponse(ws, MessageTypes.FILE_LIST, requestId, { files });
|
||||
} catch (error) {
|
||||
logger.error('Failed to get file list', { error: error.message });
|
||||
this.sendResponse(ws, MessageTypes.FILE_LIST, requestId, { files: [], error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
handleFileBrowse(ws, data, requestId) {
|
||||
try {
|
||||
const { path: dirPath, allowSystem } = data;
|
||||
|
||||
if (allowSystem && !dirPath) {
|
||||
const drives = fileService.getDrives();
|
||||
this.sendResponse(ws, MessageTypes.FILE_BROWSE_RESULT, requestId, {
|
||||
items: drives,
|
||||
currentPath: '',
|
||||
parentPath: null
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = fileService.browseDirectory(dirPath || '', allowSystem === true);
|
||||
this.sendResponse(ws, MessageTypes.FILE_BROWSE_RESULT, requestId, result);
|
||||
} catch (error) {
|
||||
logger.error('Failed to browse directory', { error: error.message });
|
||||
this.sendResponse(ws, MessageTypes.FILE_BROWSE_RESULT, requestId, { items: [], error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
handleFileUploadStart(ws, data, requestId) {
|
||||
try {
|
||||
const { filename, totalChunks, fileSize } = data;
|
||||
const fileId = data.fileId || requestId || crypto.randomBytes(16).toString('hex');
|
||||
|
||||
this.uploadSessions.set(fileId, {
|
||||
filename,
|
||||
totalChunks,
|
||||
fileSize,
|
||||
chunks: new Map(),
|
||||
createdAt: Date.now()
|
||||
});
|
||||
|
||||
this.sendResponse(ws, MessageTypes.FILE_UPLOAD_START, requestId, {
|
||||
fileId,
|
||||
chunkSize: CHUNK_SIZE,
|
||||
message: 'Upload session started'
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to start upload', { error: error.message });
|
||||
this.sendResponse(ws, MessageTypes.FILE_UPLOAD_RESULT, requestId, { success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
handleFileUploadChunk(ws, data, requestId) {
|
||||
try {
|
||||
const { fileId, chunkIndex } = data;
|
||||
const session = this.uploadSessions.get(fileId);
|
||||
|
||||
if (!session) {
|
||||
this.sendResponse(ws, MessageTypes.FILE_UPLOAD_RESULT, requestId, { success: false, error: 'Upload session not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
let chunkData;
|
||||
if (data.data) {
|
||||
chunkData = Buffer.from(data.data, 'base64');
|
||||
} else if (data.buffer) {
|
||||
chunkData = Buffer.from(data.buffer);
|
||||
}
|
||||
|
||||
if (!chunkData) {
|
||||
this.sendResponse(ws, MessageTypes.FILE_UPLOAD_RESULT, requestId, { success: false, error: 'No chunk data provided' });
|
||||
return;
|
||||
}
|
||||
|
||||
session.chunks.set(chunkIndex, chunkData);
|
||||
|
||||
this.sendResponse(ws, MessageTypes.FILE_UPLOAD_CHUNK, requestId, { success: true, chunkIndex });
|
||||
} catch (error) {
|
||||
logger.error('Failed to upload chunk', { error: error.message });
|
||||
this.sendResponse(ws, MessageTypes.FILE_UPLOAD_RESULT, requestId, { success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
handleFileUploadMerge(ws, data, requestId) {
|
||||
try {
|
||||
const { fileId, filename } = data;
|
||||
const session = this.uploadSessions.get(fileId);
|
||||
|
||||
if (!session) {
|
||||
this.sendResponse(ws, MessageTypes.FILE_UPLOAD_RESULT, requestId, { success: false, error: 'Upload session not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const success = fileService.mergeChunks(fileId, session.totalChunks, filename);
|
||||
|
||||
this.uploadSessions.delete(fileId);
|
||||
|
||||
if (success) {
|
||||
fileService.cleanupChunks(fileId);
|
||||
this.sendResponse(ws, MessageTypes.FILE_UPLOAD_RESULT, requestId, { success: true, filename });
|
||||
} else {
|
||||
fileService.cleanupChunks(fileId);
|
||||
this.sendResponse(ws, MessageTypes.FILE_UPLOAD_RESULT, requestId, { success: false, error: 'Failed to merge chunks' });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to merge chunks', { error: error.message });
|
||||
this.sendResponse(ws, MessageTypes.FILE_UPLOAD_RESULT, requestId, { success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
handleFileDownload(ws, data, requestId) {
|
||||
try {
|
||||
const { filename, filePath, allowSystem } = data;
|
||||
|
||||
let fullFilePath;
|
||||
if (allowSystem && filePath && (path.isAbsolute(filePath) || filePath.includes(':') || filePath.startsWith('\\') || filePath.startsWith('/'))) {
|
||||
if (path.isAbsolute(filePath)) {
|
||||
fullFilePath = filePath;
|
||||
} else {
|
||||
fullFilePath = filePath.replace(/\//g, '\\');
|
||||
}
|
||||
} else if (filePath) {
|
||||
fullFilePath = path.join(fileService.uploadDir, filePath);
|
||||
} else {
|
||||
fullFilePath = path.join(fileService.uploadDir, filename);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(fullFilePath)) {
|
||||
this.sendResponse(ws, MessageTypes.FILE_DOWNLOAD_COMPLETE, requestId, { success: false, error: 'File not found: ' + fullFilePath });
|
||||
return;
|
||||
}
|
||||
|
||||
const stat = fs.statSync(fullFilePath);
|
||||
const fileSize = stat.size;
|
||||
const totalChunks = Math.ceil(fileSize / CHUNK_SIZE);
|
||||
|
||||
this.sendResponse(ws, MessageTypes.FILE_DOWNLOAD_START, requestId, {
|
||||
filename,
|
||||
size: fileSize,
|
||||
chunkSize: CHUNK_SIZE,
|
||||
totalChunks
|
||||
});
|
||||
|
||||
const stream = fs.createReadStream(fullFilePath);
|
||||
let chunkIndex = 0;
|
||||
|
||||
stream.on('data', (chunk) => {
|
||||
ws.send(JSON.stringify({
|
||||
type: MessageTypes.FILE_DOWNLOAD_CHUNK,
|
||||
chunkIndex,
|
||||
data: chunk.toString('base64'),
|
||||
progress: Math.round(((chunkIndex + 1) / totalChunks) * 100)
|
||||
}));
|
||||
|
||||
chunkIndex++;
|
||||
});
|
||||
|
||||
stream.on('end', () => {
|
||||
ws.send(JSON.stringify({
|
||||
type: MessageTypes.FILE_DOWNLOAD_COMPLETE,
|
||||
success: true,
|
||||
filename
|
||||
}));
|
||||
});
|
||||
|
||||
stream.on('error', (error) => {
|
||||
logger.error('File download error', { error: error.message });
|
||||
ws.send(JSON.stringify({
|
||||
type: MessageTypes.FILE_DOWNLOAD_COMPLETE,
|
||||
success: false,
|
||||
error: error.message
|
||||
}));
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to start download', { error: error.message });
|
||||
this.sendResponse(ws, MessageTypes.FILE_DOWNLOAD_COMPLETE, requestId, { success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
handleFileDelete(ws, data, requestId) {
|
||||
try {
|
||||
const { filename, filePath } = data;
|
||||
const targetPath = filePath || '';
|
||||
const success = fileService.deleteFile(filename, targetPath);
|
||||
|
||||
this.sendResponse(ws, MessageTypes.FILE_DELETE_RESULT, requestId, { success, filename });
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete file', { error: error.message });
|
||||
this.sendResponse(ws, MessageTypes.FILE_DELETE_RESULT, requestId, { success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
const now = Date.now();
|
||||
const maxAge = 30 * 60 * 1000;
|
||||
|
||||
for (const [fileId, session] of this.uploadSessions) {
|
||||
if (now - session.createdAt > maxAge) {
|
||||
this.uploadSessions.delete(fileId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = FileHandler;
|
||||
78
remote/src/server/FileServer.js
Normal file
78
remote/src/server/FileServer.js
Normal file
@@ -0,0 +1,78 @@
|
||||
const express = require('express');
|
||||
const http = require('http');
|
||||
const WebSocket = require('ws');
|
||||
const logger = require('../utils/logger');
|
||||
const FileHandler = require('./FileHandler');
|
||||
|
||||
class FileServer {
|
||||
constructor(port = 3001) {
|
||||
this.port = port;
|
||||
this.app = express();
|
||||
this.server = http.createServer(this.app);
|
||||
this.wss = null;
|
||||
this.fileHandler = new FileHandler();
|
||||
}
|
||||
|
||||
start() {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.server.listen({ port: this.port, host: '0.0.0.0' }, () => {
|
||||
logger.info('File server started', { port: this.port });
|
||||
this._setupWebSocket();
|
||||
resolve({ port: this.port });
|
||||
});
|
||||
this.server.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
_setupWebSocket() {
|
||||
this.wss = new WebSocket.Server({ server: this.server, path: '/ws' });
|
||||
|
||||
this.wss.on('connection', (ws, req) => {
|
||||
logger.info('File client connected', { ip: req.socket.remoteAddress });
|
||||
|
||||
ws.on('message', (data, isBinary) => {
|
||||
try {
|
||||
if (isBinary) {
|
||||
logger.warn('Received binary data on file WebSocket, ignoring');
|
||||
return;
|
||||
}
|
||||
|
||||
const dataStr = data.toString();
|
||||
logger.info('Raw message received:', dataStr.substring(0, 300));
|
||||
const message = JSON.parse(dataStr);
|
||||
logger.info('File message parsed', { type: message.type, requestId: message.requestId });
|
||||
this.fileHandler.handleMessage(message, ws);
|
||||
} catch (error) {
|
||||
logger.error('Failed to parse file message', { error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
logger.info('File client disconnected');
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
logger.error('File WebSocket error', { error: error.message });
|
||||
});
|
||||
});
|
||||
|
||||
this.wss.on('error', (error) => {
|
||||
logger.error('File WebSocket server error', { error: error.message });
|
||||
});
|
||||
}
|
||||
|
||||
stop() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (this.wss) {
|
||||
this.wss.clients.forEach(client => client.close());
|
||||
this.wss.close();
|
||||
}
|
||||
this.server.close((err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = FileServer;
|
||||
@@ -5,9 +5,11 @@ const path = require('path');
|
||||
class Server {
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
129
remote/src/services/opencode/OpenCodeService.js
Normal file
129
remote/src/services/opencode/OpenCodeService.js
Normal file
@@ -0,0 +1,129 @@
|
||||
const { spawn, execSync } = require('child_process');
|
||||
const path = require('path');
|
||||
const config = require('../../config');
|
||||
const logger = require('../../utils/logger');
|
||||
|
||||
class OpenCodeService {
|
||||
constructor(options = {}) {
|
||||
this.process = null;
|
||||
this.isRunning = false;
|
||||
this.port = options.port;
|
||||
this.enabled = options.enabled !== false;
|
||||
|
||||
try {
|
||||
const result = execSync('powershell -Command "Get-Command opencode -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source"', { encoding: 'utf8', windowsHide: true, maxBuffer: 1024 * 1024 });
|
||||
const opencodePath = result.trim();
|
||||
if (opencodePath && opencodePath.length > 0) {
|
||||
let finalPath = opencodePath;
|
||||
if (!finalPath.toLowerCase().endsWith('.cmd') && !finalPath.toLowerCase().endsWith('.exe')) {
|
||||
finalPath = finalPath + '.cmd';
|
||||
}
|
||||
this.opencodePath = finalPath;
|
||||
} else {
|
||||
this.opencodePath = null;
|
||||
}
|
||||
} catch (e) {
|
||||
this.opencodePath = null;
|
||||
}
|
||||
}
|
||||
|
||||
start() {
|
||||
if (!this.enabled) {
|
||||
logger.info('OpenCode service is disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isRunning) {
|
||||
logger.warn('OpenCode service is already running');
|
||||
return;
|
||||
}
|
||||
|
||||
const password = config.get('security.password');
|
||||
if (!password) {
|
||||
logger.error('OpenCode password not found in config');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.opencodePath) {
|
||||
logger.error('OpenCode command not found in PATH');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info('Starting OpenCode service', { port: this.port || 'default', opencodePath: this.opencodePath });
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
OPENCODE_SERVER_PASSWORD: password
|
||||
};
|
||||
|
||||
const portArg = this.port ? ' --port ' + this.port : '';
|
||||
this.process = spawn('cmd.exe', [
|
||||
'/c',
|
||||
'chcp 65001 >nul && ' + this.opencodePath + ' serve' + portArg
|
||||
], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env,
|
||||
windowsHide: true
|
||||
});
|
||||
|
||||
this.isRunning = true;
|
||||
|
||||
this.process.stdout.on('data', (data) => {
|
||||
const output = data.toString('utf8').trim();
|
||||
if (output) {
|
||||
logger.info(`[OpenCode] ${output}`);
|
||||
}
|
||||
});
|
||||
|
||||
this.process.stderr.on('data', (data) => {
|
||||
const output = data.toString('utf8').trim();
|
||||
if (output) {
|
||||
logger.error(`[OpenCode] ${output}`);
|
||||
}
|
||||
});
|
||||
|
||||
this.process.on('error', (error) => {
|
||||
logger.error('OpenCode process error', { error: error.message });
|
||||
this.isRunning = false;
|
||||
});
|
||||
|
||||
this.process.on('close', (code) => {
|
||||
logger.info('OpenCode process closed', { code });
|
||||
this.isRunning = false;
|
||||
this.process = null;
|
||||
});
|
||||
|
||||
logger.info('OpenCode service started successfully');
|
||||
} catch (error) {
|
||||
logger.error('Failed to start OpenCode service', { error: error.message });
|
||||
this.isRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (!this.isRunning || !this.process) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('Stopping OpenCode service');
|
||||
|
||||
try {
|
||||
this.process.kill();
|
||||
this.process = null;
|
||||
this.isRunning = false;
|
||||
logger.info('OpenCode service stopped');
|
||||
} catch (error) {
|
||||
logger.error('Failed to stop OpenCode service', { error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
getStatus() {
|
||||
return {
|
||||
running: this.isRunning,
|
||||
port: this.port || null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = OpenCodeService;
|
||||
149
remote/src/services/opencode/XCOpenCodeWebService.js
Normal file
149
remote/src/services/opencode/XCOpenCodeWebService.js
Normal file
@@ -0,0 +1,149 @@
|
||||
const { spawn } = require('child_process');
|
||||
const path = require('path');
|
||||
const logger = require('../../utils/logger');
|
||||
|
||||
class XCOpenCodeWebService {
|
||||
constructor(options = {}) {
|
||||
this.process = null;
|
||||
this.isRunning = false;
|
||||
this.port = options.port || 9999;
|
||||
this.enabled = options.enabled !== false;
|
||||
this.healthCheckInterval = 10000;
|
||||
this.healthCheckTimeout = 2000;
|
||||
this.healthCheckTimer = null;
|
||||
}
|
||||
|
||||
getExePath() {
|
||||
const exeName = 'XCOpenCodeWeb.exe';
|
||||
const basePath = path.join(__dirname, '../../../xcopencodeweb');
|
||||
return path.join(basePath, exeName);
|
||||
}
|
||||
|
||||
getExeArgs() {
|
||||
return ['--port', this.port.toString()];
|
||||
}
|
||||
|
||||
async checkHealth() {
|
||||
try {
|
||||
const timeoutMs = this.healthCheckTimeout;
|
||||
|
||||
const fetchPromise = fetch(`http://127.0.0.1:${this.port}`);
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Timeout')), timeoutMs)
|
||||
);
|
||||
|
||||
const response = await Promise.race([fetchPromise, timeoutPromise]);
|
||||
return response.ok || response.status === 401;
|
||||
} catch (error) {
|
||||
logger.warn('[XCOpenCodeWebService] Health check failed:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
startHealthCheck() {
|
||||
if (this.healthCheckTimer) {
|
||||
clearInterval(this.healthCheckTimer);
|
||||
}
|
||||
|
||||
this.healthCheckTimer = setInterval(async () => {
|
||||
const isHealthy = await this.checkHealth();
|
||||
if (!isHealthy && this.isRunning) {
|
||||
logger.warn('[XCOpenCodeWebService] Service not responding');
|
||||
}
|
||||
}, this.healthCheckInterval);
|
||||
}
|
||||
|
||||
stopHealthCheck() {
|
||||
if (this.healthCheckTimer) {
|
||||
clearInterval(this.healthCheckTimer);
|
||||
this.healthCheckTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
start() {
|
||||
if (!this.enabled) {
|
||||
logger.info('XCOpenCodeWeb service is disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isRunning && this.process) {
|
||||
logger.warn('XCOpenCodeWeb service is already running');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const exePath = this.getExePath();
|
||||
const exeArgs = this.getExeArgs();
|
||||
logger.info(`[XCOpenCodeWebService] Starting from: ${exePath} with args: ${exeArgs.join(' ')}`);
|
||||
|
||||
this.process = spawn(exePath, exeArgs, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: false,
|
||||
});
|
||||
|
||||
this.process.stdout.on('data', (data) => {
|
||||
const output = data.toString().trim();
|
||||
if (output) {
|
||||
logger.info(`[XCOpenCodeWeb] ${output}`);
|
||||
}
|
||||
});
|
||||
|
||||
this.process.stderr.on('data', (data) => {
|
||||
const output = data.toString().trim();
|
||||
if (output) {
|
||||
logger.error(`[XCOpenCodeWeb error] ${output}`);
|
||||
}
|
||||
});
|
||||
|
||||
this.process.on('error', (error) => {
|
||||
logger.error('[XCOpenCodeWebService] Process error', { error: error.message });
|
||||
this.isRunning = false;
|
||||
this.process = null;
|
||||
});
|
||||
|
||||
this.process.on('exit', (code) => {
|
||||
logger.info('[XCOpenCodeWebService] Process exited', { code });
|
||||
this.isRunning = false;
|
||||
this.process = null;
|
||||
});
|
||||
|
||||
this.isRunning = true;
|
||||
this.startHealthCheck();
|
||||
|
||||
logger.info(`[XCOpenCodeWebService] Started successfully on port ${this.port}`);
|
||||
} catch (error) {
|
||||
logger.error('[XCOpenCodeWebService] Failed to start', { error: error.message });
|
||||
this.isRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.stopHealthCheck();
|
||||
|
||||
if (!this.process) {
|
||||
logger.info('[XCOpenCodeWebService] Not running');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info('[XCOpenCodeWebService] Stopping...');
|
||||
|
||||
this.process.kill();
|
||||
this.process = null;
|
||||
this.isRunning = false;
|
||||
|
||||
logger.info('[XCOpenCodeWebService] Stopped');
|
||||
} catch (error) {
|
||||
logger.error('[XCOpenCodeWebService] Failed to stop', { error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
getStatus() {
|
||||
return {
|
||||
running: this.isRunning,
|
||||
port: this.port
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = XCOpenCodeWebService;
|
||||
@@ -5,7 +5,7 @@ function getBasePath() {
|
||||
if (process.pkg) {
|
||||
return path.dirname(process.execPath);
|
||||
}
|
||||
return path.join(__dirname, '../..');
|
||||
return process.cwd();
|
||||
}
|
||||
|
||||
function getPublicPath() {
|
||||
|
||||
99
remote/xcopencodeweb/README.md
Normal file
99
remote/xcopencodeweb/README.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# XCOpenCodeWeb
|
||||
|
||||
XCOpenCodeWeb 是一个基于 Web 的 AI 编程助手界面,用于与 OpenCode 服务器交互。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 使用单文件 exe(推荐)
|
||||
|
||||
直接下载 `XCOpenCodeWeb.exe`,双击运行:
|
||||
|
||||
```bash
|
||||
# 默认端口 3000
|
||||
XCOpenCodeWeb.exe
|
||||
|
||||
# 指定端口
|
||||
XCOpenCodeWeb.exe --port 8080
|
||||
|
||||
# 查看帮助
|
||||
XCOpenCodeWeb.exe --help
|
||||
```
|
||||
|
||||
启动后访问 http://localhost:3000
|
||||
|
||||
### 从源码运行
|
||||
|
||||
需要安装 [Bun](https://bun.sh) 或 Node.js 20+
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
bun install
|
||||
|
||||
# 构建前端
|
||||
bun run build
|
||||
|
||||
# 启动服务器
|
||||
bun server/index.js --port 3000
|
||||
```
|
||||
|
||||
## 构建单文件 exe
|
||||
|
||||
```bash
|
||||
cd web
|
||||
bun run build:exe
|
||||
```
|
||||
|
||||
输出:`web/XCOpenCodeWeb.exe`(约 320MB)
|
||||
|
||||
详细文档:[docs/single-file-exe-build.md](docs/single-file-exe-build.md)
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
├── ui/ # 前端组件库
|
||||
├── web/
|
||||
│ ├── src/ # 前端源码
|
||||
│ ├── server/ # 后端服务器
|
||||
│ ├── bin/ # CLI 工具
|
||||
│ └── dist/ # 构建输出
|
||||
├── docs/ # 文档
|
||||
└── AGENTS.md # AI Agent 参考文档
|
||||
```
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
# 开发模式
|
||||
bun run dev # 前端热更新
|
||||
bun run dev:server # 启动开发服务器
|
||||
|
||||
# 构建
|
||||
bun run build # 构建前端
|
||||
bun run build:exe # 构建单文件 exe
|
||||
|
||||
# 代码检查
|
||||
bun run type-check:web # TypeScript 类型检查
|
||||
bun run lint:web # ESLint 检查
|
||||
```
|
||||
|
||||
## 依赖
|
||||
|
||||
- [Bun](https://bun.sh) - 运行时和打包工具
|
||||
- [React](https://react.dev) - 前端框架
|
||||
- [Express](https://expressjs.com) - 后端服务器
|
||||
- [Tailwind CSS](https://tailwindcss.com) - 样式框架
|
||||
|
||||
## 配置
|
||||
|
||||
### 环境变量
|
||||
|
||||
| 变量 | 说明 |
|
||||
|------|------|
|
||||
| `XCOpenCodeWeb_PORT` | 服务器端口(默认 3000) |
|
||||
| `OPENCODE_HOST` | 外部 OpenCode 服务器地址 |
|
||||
| `OPENCODE_PORT` | 外部 OpenCode 端口 |
|
||||
| `OPENCODE_SKIP_START` | 跳过启动 OpenCode |
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT
|
||||
99
services/xcopencodeweb/README.md
Normal file
99
services/xcopencodeweb/README.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# XCOpenCodeWeb
|
||||
|
||||
XCOpenCodeWeb 是一个基于 Web 的 AI 编程助手界面,用于与 OpenCode 服务器交互。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 使用单文件 exe(推荐)
|
||||
|
||||
直接下载 `XCOpenCodeWeb.exe`,双击运行:
|
||||
|
||||
```bash
|
||||
# 默认端口 3000
|
||||
XCOpenCodeWeb.exe
|
||||
|
||||
# 指定端口
|
||||
XCOpenCodeWeb.exe --port 8080
|
||||
|
||||
# 查看帮助
|
||||
XCOpenCodeWeb.exe --help
|
||||
```
|
||||
|
||||
启动后访问 http://localhost:3000
|
||||
|
||||
### 从源码运行
|
||||
|
||||
需要安装 [Bun](https://bun.sh) 或 Node.js 20+
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
bun install
|
||||
|
||||
# 构建前端
|
||||
bun run build
|
||||
|
||||
# 启动服务器
|
||||
bun server/index.js --port 3000
|
||||
```
|
||||
|
||||
## 构建单文件 exe
|
||||
|
||||
```bash
|
||||
cd web
|
||||
bun run build:exe
|
||||
```
|
||||
|
||||
输出:`web/XCOpenCodeWeb.exe`(约 320MB)
|
||||
|
||||
详细文档:[docs/single-file-exe-build.md](docs/single-file-exe-build.md)
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
├── ui/ # 前端组件库
|
||||
├── web/
|
||||
│ ├── src/ # 前端源码
|
||||
│ ├── server/ # 后端服务器
|
||||
│ ├── bin/ # CLI 工具
|
||||
│ └── dist/ # 构建输出
|
||||
├── docs/ # 文档
|
||||
└── AGENTS.md # AI Agent 参考文档
|
||||
```
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
# 开发模式
|
||||
bun run dev # 前端热更新
|
||||
bun run dev:server # 启动开发服务器
|
||||
|
||||
# 构建
|
||||
bun run build # 构建前端
|
||||
bun run build:exe # 构建单文件 exe
|
||||
|
||||
# 代码检查
|
||||
bun run type-check:web # TypeScript 类型检查
|
||||
bun run lint:web # ESLint 检查
|
||||
```
|
||||
|
||||
## 依赖
|
||||
|
||||
- [Bun](https://bun.sh) - 运行时和打包工具
|
||||
- [React](https://react.dev) - 前端框架
|
||||
- [Express](https://expressjs.com) - 后端服务器
|
||||
- [Tailwind CSS](https://tailwindcss.com) - 样式框架
|
||||
|
||||
## 配置
|
||||
|
||||
### 环境变量
|
||||
|
||||
| 变量 | 说明 |
|
||||
|------|------|
|
||||
| `XCOpenCodeWeb_PORT` | 服务器端口(默认 3000) |
|
||||
| `OPENCODE_HOST` | 外部 OpenCode 服务器地址 |
|
||||
| `OPENCODE_PORT` | 外部 OpenCode 端口 |
|
||||
| `OPENCODE_SKIP_START` | 跳过启动 OpenCode |
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT
|
||||
BIN
services/xcopencodeweb/XCOpenCodeWeb.exe
Normal file
BIN
services/xcopencodeweb/XCOpenCodeWeb.exe
Normal file
Binary file not shown.
BIN
services/xcsdd/XCSDD.exe
Normal file
BIN
services/xcsdd/XCSDD.exe
Normal file
Binary file not shown.
12
shared/modules/opencode/index.ts
Normal file
12
shared/modules/opencode/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineModule } from '../types.js'
|
||||
|
||||
export const OPENCODE_MODULE = defineModule({
|
||||
id: 'opencode',
|
||||
name: 'OpenCode',
|
||||
basePath: '/opencode',
|
||||
order: 15,
|
||||
version: '1.0.0',
|
||||
backend: {
|
||||
enabled: true,
|
||||
},
|
||||
})
|
||||
@@ -4,6 +4,8 @@ export interface RemoteDevice {
|
||||
serverHost: string
|
||||
desktopPort: number
|
||||
gitPort: number
|
||||
openCodePort: number
|
||||
fileTransferPort: number
|
||||
password?: string
|
||||
}
|
||||
|
||||
|
||||
12
shared/modules/sdd/index.ts
Normal file
12
shared/modules/sdd/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineModule } from '../types.js'
|
||||
|
||||
export const SDD_MODULE = defineModule({
|
||||
id: 'sdd',
|
||||
name: 'SDD',
|
||||
basePath: '/sdd',
|
||||
order: 16,
|
||||
version: '1.0.0',
|
||||
backend: {
|
||||
enabled: false,
|
||||
},
|
||||
})
|
||||
12
shared/modules/terminal/index.ts
Normal file
12
shared/modules/terminal/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineModule } from '../types.js'
|
||||
|
||||
export const TERMINAL_MODULE = defineModule({
|
||||
id: 'terminal',
|
||||
name: 'Terminal',
|
||||
basePath: '/terminal',
|
||||
order: 17,
|
||||
version: '1.0.0',
|
||||
backend: {
|
||||
enabled: false,
|
||||
},
|
||||
})
|
||||
13
shared/modules/voice/index.ts
Normal file
13
shared/modules/voice/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export const VOICE_MODULE = 'voice' as const
|
||||
|
||||
export interface VoiceModule {
|
||||
id: typeof VOICE_MODULE
|
||||
name: '语音'
|
||||
icon: 'mic'
|
||||
}
|
||||
|
||||
export const voiceModule: VoiceModule = {
|
||||
id: VOICE_MODULE,
|
||||
name: '语音',
|
||||
icon: 'mic',
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
export type TabType = 'markdown' | 'todo' | 'settings' | 'search' | 'recycle-bin' | 'weread' | 'time-tracking' | 'pydemos' | 'remote' | 'remote-desktop' | 'remote-git' | 'other'
|
||||
export type TabType = 'markdown' | 'todo' | 'settings' | 'search' | 'recycle-bin' | 'weread' | 'time-tracking' | 'pydemos' | 'remote' | 'remote-desktop' | 'remote-git' | 'file-transfer' | 'other'
|
||||
|
||||
@@ -8,6 +8,10 @@ const KNOWN_MODULE_IDS = [
|
||||
export function getTabTypeFromPath(filePath: string | null): TabType {
|
||||
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> = {
|
||||
|
||||
@@ -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>
|
||||
|
||||
290
src/components/chat/Chat.tsx
Normal file
290
src/components/chat/Chat.tsx
Normal file
@@ -0,0 +1,290 @@
|
||||
import { useRef, useState, useCallback, useEffect } from 'react'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from './ui/AlertDialog'
|
||||
import { Messages } from './Messages'
|
||||
import { MultimodalInput } from './MultimodalInput'
|
||||
import { generateUUID } from '@/lib/utils'
|
||||
import { Eraser } from 'lucide-react'
|
||||
import { useDragStore } from '@/stores/dragStore'
|
||||
import { getSettingsConfig } from '@/lib/api'
|
||||
|
||||
type Status = 'submitted' | 'streaming' | 'ready' | 'error'
|
||||
|
||||
interface Attachment {
|
||||
url: string
|
||||
name: string
|
||||
contentType: string
|
||||
}
|
||||
|
||||
interface ChatMessage {
|
||||
id: string
|
||||
role: 'user' | 'assistant' | 'system'
|
||||
content: string
|
||||
parts: Array<{ type: string; text?: string }>
|
||||
createdAt?: Date
|
||||
}
|
||||
|
||||
const OPENCODE_API = '/api/opencode'
|
||||
const INITIAL_MESSAGE_LIMIT = 20
|
||||
|
||||
export function Chat({
|
||||
id,
|
||||
initialMessages,
|
||||
onClear,
|
||||
}: {
|
||||
id: string
|
||||
initialMessages: ChatMessage[]
|
||||
onClear?: () => void
|
||||
}) {
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [messages, setMessages] = useState<ChatMessage[]>(initialMessages)
|
||||
const [hasMoreMessages, setHasMoreMessages] = useState(false)
|
||||
const [input, setInput] = useState<string>('')
|
||||
const [showCreditCardAlert, setShowCreditCardAlert] = useState(false)
|
||||
const [status, setStatus] = useState<Status>('ready')
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([])
|
||||
const [showInputAtBottom, setShowInputAtBottom] = useState(false)
|
||||
const [notebookRoot, setNotebookRoot] = useState<string>('')
|
||||
const { state: dragState } = useDragStore()
|
||||
|
||||
useEffect(() => {
|
||||
getSettingsConfig().then(config => {
|
||||
// 统一 notebookRoot 的斜杠
|
||||
const normalizedRoot = (config.notebookRoot || '').replace(/\\/g, '/')
|
||||
setNotebookRoot(normalizedRoot)
|
||||
}).catch(e => {
|
||||
console.error('Failed to get notebook root:', e)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
// 优先从 dragStore 获取
|
||||
let { draggedPath, draggedType } = dragState
|
||||
|
||||
// 如果 dragStore 为空,尝试从 dataTransfer 获取
|
||||
if (!draggedPath) {
|
||||
draggedPath = e.dataTransfer.getData('text/plain')
|
||||
}
|
||||
|
||||
if (draggedPath) {
|
||||
// 统一路径中的斜杠,并拼接完整路径,用【】包围
|
||||
const normalizedPath = draggedPath.replace(/\\/g, '/')
|
||||
const fullPath = notebookRoot ? `${notebookRoot}/${normalizedPath}` : normalizedPath
|
||||
const pathWithBrackets = `【${fullPath}】`
|
||||
setInput(prev => prev + pathWithBrackets)
|
||||
|
||||
// 延迟聚焦输入框
|
||||
setTimeout(() => {
|
||||
const textarea = document.querySelector('textarea') as HTMLTextAreaElement
|
||||
textarea?.focus()
|
||||
}, 0)
|
||||
}
|
||||
}, [dragState, notebookRoot])
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
}, [])
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'auto' })
|
||||
}, [])
|
||||
|
||||
const loadMessages = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(OPENCODE_API, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'get-messages',
|
||||
sessionId: id,
|
||||
limit: INITIAL_MESSAGE_LIMIT
|
||||
}),
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
const newMessages = data.messages || []
|
||||
setMessages(newMessages)
|
||||
setHasMoreMessages(data.hasMore ?? newMessages.length >= INITIAL_MESSAGE_LIMIT)
|
||||
setTimeout(scrollToBottom, 100)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load messages:', error)
|
||||
}
|
||||
}, [id, scrollToBottom])
|
||||
|
||||
const loadMoreMessages = useCallback(async () => {
|
||||
if (messages.length === 0) return
|
||||
|
||||
const currentCount = messages.length
|
||||
const newLimit = currentCount + INITIAL_MESSAGE_LIMIT
|
||||
|
||||
try {
|
||||
const res = await fetch(OPENCODE_API, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'get-messages',
|
||||
sessionId: id,
|
||||
limit: newLimit
|
||||
}),
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
const newMessages = data.messages || []
|
||||
|
||||
setMessages(prev => {
|
||||
const existingIds = new Set(prev.map(m => m.id))
|
||||
const uniqueNew = newMessages.filter((m: ChatMessage) => !existingIds.has(m.id))
|
||||
return [...uniqueNew, ...prev]
|
||||
})
|
||||
|
||||
setHasMoreMessages(data.hasMore ?? newMessages.length >= newLimit)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load more messages:', error)
|
||||
}
|
||||
}, [id, messages])
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
loadMessages()
|
||||
}
|
||||
}, [id, loadMessages])
|
||||
|
||||
const handleSendMessage = useCallback(async (text: string, atts: Attachment[]) => {
|
||||
const userMessage: ChatMessage = {
|
||||
id: generateUUID(),
|
||||
role: 'user',
|
||||
content: text,
|
||||
parts: [],
|
||||
createdAt: new Date(),
|
||||
}
|
||||
|
||||
setMessages(prev => [...prev, userMessage])
|
||||
setStatus('submitted')
|
||||
|
||||
try {
|
||||
const res = await fetch(OPENCODE_API, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'prompt',
|
||||
sessionId: id,
|
||||
text,
|
||||
attachments: atts.map(a => ({
|
||||
url: a.url,
|
||||
name: a.name,
|
||||
mediaType: a.contentType,
|
||||
})),
|
||||
}),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(await res.text())
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (data.message) {
|
||||
setMessages(prev => [...prev, data.message])
|
||||
setShowInputAtBottom(true)
|
||||
}
|
||||
|
||||
setStatus('ready')
|
||||
} catch (error: unknown) {
|
||||
console.error('Send message error:', error)
|
||||
setStatus('error')
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to send message'
|
||||
if (errorMessage.includes('credit card')) {
|
||||
setShowCreditCardAlert(true)
|
||||
}
|
||||
}
|
||||
}, [id])
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
setStatus('ready')
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="overscroll-behavior-contain flex h-full min-w-0 touch-pan-y flex-col bg-background">
|
||||
{onClear && messages.length > 0 && (
|
||||
<div className="absolute top-4 right-4 z-10">
|
||||
<button
|
||||
onClick={onClear}
|
||||
className="flex items-center rounded-md bg-primary p-2 text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
<Eraser className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<Messages
|
||||
messages={messages}
|
||||
status={status}
|
||||
hasMoreMessages={hasMoreMessages}
|
||||
loadMoreMessages={loadMoreMessages}
|
||||
/>
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
<div
|
||||
className={`mx-auto flex w-full max-w-4xl gap-2 px-2 pb-3 transition-all duration-300 md:px-4 md:pb-4 ${
|
||||
showInputAtBottom || messages.length > 0
|
||||
? 'sticky bottom-0'
|
||||
: 'absolute bottom-[40%] left-0 right-0'
|
||||
}`}
|
||||
>
|
||||
<MultimodalInput
|
||||
attachments={attachments}
|
||||
chatId={id}
|
||||
input={input}
|
||||
messages={messages}
|
||||
sendMessage={handleSendMessage}
|
||||
setAttachments={setAttachments}
|
||||
setInput={setInput}
|
||||
status={status}
|
||||
stop={handleStop}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertDialog
|
||||
onOpenChange={setShowCreditCardAlert}
|
||||
open={showCreditCardAlert}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>API 配置需要</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
请在环境变量中配置您的 AI API 密钥。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
setShowCreditCardAlert(false)
|
||||
}}
|
||||
>
|
||||
确定
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
9
src/components/chat/Greeting.tsx
Normal file
9
src/components/chat/Greeting.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
export function Greeting() {
|
||||
return (
|
||||
<div className="mx-auto mt-60 max-w-2xl px-4 text-center">
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
开始今天的工作吧!
|
||||
</h1>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
95
src/components/chat/Message.tsx
Normal file
95
src/components/chat/Message.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { memo, useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ArrowDownIcon, SparklesIcon, ChevronDown, ChevronRight } from 'lucide-react'
|
||||
|
||||
interface MessageProps {
|
||||
message: {
|
||||
id: string
|
||||
role: 'user' | 'assistant' | 'system'
|
||||
content: string
|
||||
parts?: Array<{ type: string; text?: string }>
|
||||
}
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
function PureMessage({ message, isLoading }: MessageProps) {
|
||||
const isUser = message.role === 'user'
|
||||
const [reasoningCollapsed, setReasoningCollapsed] = useState(true)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-full gap-4 px-4',
|
||||
isUser ? 'justify-end' : 'justify-start'
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
'flex max-w-[80%] flex-col gap-1 rounded-2xl px-4 py-3',
|
||||
isUser ? 'bg-gray-100 dark:bg-gray-800' : 'bg-muted/50'
|
||||
)}>
|
||||
{message.parts?.map((part: { type: string; text?: string }, i: number) => {
|
||||
const safeString = (val: unknown): string => {
|
||||
if (val === null || val === undefined) return ''
|
||||
if (typeof val === 'string') return val
|
||||
if (typeof val === 'object') return JSON.stringify(val, null, 2)
|
||||
return String(val)
|
||||
}
|
||||
|
||||
if (part.type === 'text') {
|
||||
return (
|
||||
<div key={i} className={cn('whitespace-pre-wrap text-sm rounded-lg px-3 py-2 bg-gray-100 dark:bg-gray-800', isUser && 'text-primary-foreground')}>
|
||||
{safeString(part.text)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (part.type === 'reasoning') {
|
||||
return (
|
||||
<div key={i} className="rounded border border-gray-500/30 bg-gray-500/10 p-2 text-xs text-gray-600 dark:text-gray-400">
|
||||
<div
|
||||
className="flex items-center gap-1 font-semibold cursor-pointer"
|
||||
onClick={() => setReasoningCollapsed(!reasoningCollapsed)}
|
||||
>
|
||||
{reasoningCollapsed ? <ChevronRight className="size-3" /> : <ChevronDown className="size-3" />}
|
||||
<SparklesIcon className="size-3" />
|
||||
Thinking
|
||||
</div>
|
||||
{!reasoningCollapsed && (
|
||||
<div className="mt-1 whitespace-pre-wrap">{safeString(part.text)}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
})}
|
||||
{(!message.parts || message.parts.length === 0) && message.content && (
|
||||
<div className={cn('whitespace-pre-wrap text-sm', isUser && 'text-primary-foreground')}>{message.content}</div>
|
||||
)}
|
||||
{isLoading && (
|
||||
<div className={cn('flex items-center gap-1 text-xs', isUser ? 'text-primary-foreground/70' : 'text-muted-foreground')}>
|
||||
<span className="animate-pulse">Thinking</span>
|
||||
<ArrowDownIcon className="size-3 animate-bounce" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const PreviewMessage = memo(PureMessage)
|
||||
|
||||
export function ThinkingMessage() {
|
||||
return (
|
||||
<div className="flex w-full gap-4 px-4">
|
||||
<div className="flex flex-col gap-1 rounded-lg bg-muted/50 p-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span className="animate-pulse">Thinking</span>
|
||||
<span className="size-1.5 animate-bounce rounded-full bg-muted-foreground" />
|
||||
<span className="size-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:0.2s]" />
|
||||
<span className="size-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:0.4s]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
103
src/components/chat/Messages.tsx
Normal file
103
src/components/chat/Messages.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { ArrowDownIcon, Loader2Icon } from 'lucide-react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useMessages } from '@/hooks/useMessages'
|
||||
import { Greeting } from './Greeting'
|
||||
import { PreviewMessage, ThinkingMessage } from './Message'
|
||||
|
||||
type Status = 'submitted' | 'streaming' | 'ready' | 'error'
|
||||
|
||||
interface ChatMessage {
|
||||
id: string
|
||||
role: 'user' | 'assistant' | 'system'
|
||||
content: string
|
||||
parts: Array<{ type: string; text?: string }>
|
||||
createdAt?: Date
|
||||
}
|
||||
|
||||
type MessagesProps = {
|
||||
messages: ChatMessage[]
|
||||
status: Status
|
||||
hasMoreMessages?: boolean
|
||||
loadMoreMessages?: () => void
|
||||
}
|
||||
|
||||
export function Messages({ messages, status, hasMoreMessages, loadMoreMessages }: MessagesProps) {
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false)
|
||||
|
||||
const {
|
||||
containerRef: messagesContainerRef,
|
||||
endRef: messagesEndRef,
|
||||
isAtBottom,
|
||||
scrollToBottom,
|
||||
} = useMessages({ status })
|
||||
|
||||
useEffect(() => {
|
||||
if (messages.length > 0) {
|
||||
const container = messagesContainerRef.current
|
||||
if (container) {
|
||||
container.scrollTop = container.scrollHeight
|
||||
}
|
||||
}
|
||||
}, [messages, messagesContainerRef])
|
||||
|
||||
const handleLoadMore = async () => {
|
||||
if (!loadMoreMessages || isLoadingMore) return
|
||||
setIsLoadingMore(true)
|
||||
await loadMoreMessages()
|
||||
setIsLoadingMore(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex-1 bg-background pt-2 pb-4">
|
||||
<div
|
||||
className="absolute inset-0 touch-pan-y overflow-y-auto bg-background"
|
||||
ref={messagesContainerRef}
|
||||
>
|
||||
<div className="mx-auto flex min-w-0 max-w-4xl flex-col gap-4 px-2 py-2 md:gap-6 md:px-4">
|
||||
{hasMoreMessages && (
|
||||
<div className="flex justify-center py-2">
|
||||
<button
|
||||
onClick={handleLoadMore}
|
||||
disabled={isLoadingMore}
|
||||
className="flex items-center gap-2 rounded-full border bg-background px-4 py-2 text-sm text-muted-foreground hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
{isLoadingMore && <Loader2Icon className="size-4 animate-spin" />}
|
||||
{isLoadingMore ? 'Loading...' : 'Load more messages'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.length === 0 && <Greeting />}
|
||||
|
||||
{messages.map((message, index) => (
|
||||
<PreviewMessage
|
||||
key={message.id}
|
||||
message={message}
|
||||
isLoading={status === 'streaming' && messages.length - 1 === index}
|
||||
/>
|
||||
))}
|
||||
|
||||
{status === 'submitted' && <ThinkingMessage />}
|
||||
|
||||
<div
|
||||
className="min-h-[24px] min-w-[24px] shrink-0"
|
||||
ref={messagesEndRef}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
aria-label="Scroll to bottom"
|
||||
className={`absolute bottom-4 left-1/2 z-10 -translate-x-1/2 rounded-full border bg-background p-2 shadow-lg transition-all hover:bg-muted ${
|
||||
isAtBottom
|
||||
? 'pointer-events-none scale-0 opacity-0'
|
||||
: 'pointer-events-auto scale-100 opacity-100'
|
||||
}`}
|
||||
onClick={() => scrollToBottom('smooth')}
|
||||
type="button"
|
||||
>
|
||||
<ArrowDownIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
177
src/components/chat/MultimodalInput.tsx
Normal file
177
src/components/chat/MultimodalInput.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ArrowUpIcon, StopIcon } from './icons'
|
||||
import { Button } from './ui/Button'
|
||||
|
||||
type Status = 'submitted' | 'streaming' | 'ready' | 'error'
|
||||
|
||||
interface Attachment {
|
||||
url: string
|
||||
name: string
|
||||
contentType: string
|
||||
}
|
||||
|
||||
interface ChatMessage {
|
||||
id: string
|
||||
role: 'user' | 'assistant' | 'system'
|
||||
content: string
|
||||
parts: unknown[]
|
||||
createdAt?: Date
|
||||
}
|
||||
|
||||
interface MultimodalInputProps {
|
||||
chatId: string
|
||||
input: string
|
||||
setInput: (value: string) => void
|
||||
status: Status
|
||||
stop: () => void
|
||||
attachments: Attachment[]
|
||||
setAttachments: (attachments: Attachment[]) => void
|
||||
messages: ChatMessage[]
|
||||
sendMessage: (text: string, attachments: Attachment[]) => Promise<void>
|
||||
className?: string
|
||||
onDrop?: (e: React.DragEvent) => void
|
||||
onDragOver?: (e: React.DragEvent) => void
|
||||
}
|
||||
|
||||
export function MultimodalInput({
|
||||
chatId,
|
||||
input,
|
||||
setInput,
|
||||
status,
|
||||
stop,
|
||||
attachments,
|
||||
setAttachments,
|
||||
sendMessage,
|
||||
className,
|
||||
onDrop,
|
||||
onDragOver,
|
||||
}: MultimodalInputProps) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const adjustHeight = useCallback(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = '44px'
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
adjustHeight()
|
||||
}
|
||||
}, [adjustHeight])
|
||||
|
||||
const resetHeight = useCallback(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = '44px'
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleInput = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setInput(event.target.value)
|
||||
}
|
||||
|
||||
const submitForm = useCallback(() => {
|
||||
if (!input.trim() && attachments.length === 0) return
|
||||
|
||||
sendMessage(input, attachments)
|
||||
|
||||
setAttachments([])
|
||||
resetHeight()
|
||||
setInput('')
|
||||
|
||||
textareaRef.current?.focus()
|
||||
}, [input, setInput, attachments, sendMessage, setAttachments, resetHeight])
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
if (!input.trim() && attachments.length === 0) {
|
||||
return
|
||||
}
|
||||
if (status !== 'ready') {
|
||||
console.warn('Please wait for the model to finish its response!')
|
||||
} else {
|
||||
submitForm()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
const target = textareaRef.current
|
||||
target.style.height = '44px'
|
||||
const scrollHeight = target.scrollHeight
|
||||
if (scrollHeight > 44) {
|
||||
target.style.height = `${Math.min(scrollHeight, 200)}px`
|
||||
}
|
||||
}
|
||||
}, [input])
|
||||
|
||||
return (
|
||||
<div className={cn('relative flex w-full flex-col gap-4', className)}>
|
||||
<div className="relative flex w-full items-end gap-2 rounded-xl border border-border bg-background p-3 shadow-xs transition-all duration-200 focus-within:border-border hover:border-muted-foreground/50">
|
||||
{attachments.length > 0 && (
|
||||
<div className="flex flex-row items-end gap-2 overflow-x-scroll">
|
||||
{attachments.map((attachment) => (
|
||||
<div
|
||||
key={attachment.url}
|
||||
className="relative flex items-center gap-1 rounded-md bg-muted px-2 py-1 text-xs"
|
||||
>
|
||||
<span className="max-w-[100px] truncate">{attachment.name}</span>
|
||||
<button
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
setAttachments(attachments.filter((a) => a.url !== attachment.url))
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="max-h-[200px] min-h-[44px] w-full resize-none border-0 bg-transparent p-2 text-base outline-none placeholder:text-muted-foreground [-ms-overflow-style:none] [scrollbar-width:none]"
|
||||
style={{ height: '44px' }}
|
||||
value={input}
|
||||
onChange={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
onDrop?.(e)
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
onDragOver?.(e)
|
||||
}}
|
||||
placeholder="发送消息..."
|
||||
rows={1}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{status === 'submitted' ? (
|
||||
<Button
|
||||
className="size-8 rounded-full bg-foreground p-1 text-background transition-colors duration-200 hover:bg-foreground/90"
|
||||
onClick={() => {
|
||||
stop()
|
||||
}}
|
||||
>
|
||||
<StopIcon size={14} />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="size-8 rounded-full bg-primary text-primary-foreground transition-colors duration-200 hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground"
|
||||
disabled={!input.trim() && attachments.length === 0}
|
||||
onClick={submitForm}
|
||||
>
|
||||
<ArrowUpIcon size={14} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
27
src/components/chat/icons.tsx
Normal file
27
src/components/chat/icons.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import {
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
Paperclip,
|
||||
StopCircle,
|
||||
ChevronDown,
|
||||
Check,
|
||||
Plus,
|
||||
Trash,
|
||||
PanelLeftClose,
|
||||
PanelLeft,
|
||||
Mic,
|
||||
} from 'lucide-react'
|
||||
|
||||
export {
|
||||
ArrowUp as ArrowUpIcon,
|
||||
ArrowDown as ArrowDownIcon,
|
||||
Paperclip as PaperclipIcon,
|
||||
StopCircle as StopIcon,
|
||||
ChevronDown as ChevronDownIcon,
|
||||
Check as CheckIcon,
|
||||
Plus as PlusIcon,
|
||||
Trash as TrashIcon,
|
||||
PanelLeftClose as PanelLeftCloseIcon,
|
||||
PanelLeft as PanelLeftOpenIcon,
|
||||
Mic as MicIcon,
|
||||
}
|
||||
132
src/components/chat/ui/AlertDialog.tsx
Normal file
132
src/components/chat/ui/AlertDialog.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import * as React from 'react'
|
||||
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { buttonVariants } from './Button'
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
))
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||
|
||||
const AlertDialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn('flex flex-col space-y-2 text-center sm:text-left', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogHeader.displayName = 'AlertDialogHeader'
|
||||
|
||||
const AlertDialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogFooter.displayName = 'AlertDialogFooter'
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
className={cn('text-lg font-semibold', className)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action
|
||||
className={cn(buttonVariants(), className)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
className={cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'mt-2 sm:mt-0',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
52
src/components/chat/ui/Button.tsx
Normal file
52
src/components/chat/ui/Button.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import * as React from 'react'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||
outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2',
|
||||
sm: 'h-8 rounded-md px-3 text-xs',
|
||||
lg: 'h-10 rounded-md px-8',
|
||||
icon: 'h-9 w-9',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button'
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = 'Button'
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -229,7 +229,8 @@ const SidebarContent = forwardRef<HTMLDivElement, SidebarProps>(({
|
||||
)}
|
||||
|
||||
<div
|
||||
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>
|
||||
|
||||
@@ -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 }
|
||||
]
|
||||
|
||||
@@ -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' }}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
58
src/hooks/domain/usePopOutTab.ts
Normal file
58
src/hooks/domain/usePopOutTab.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useTabStore, TabState } from '@/stores'
|
||||
|
||||
export interface PopOutTabData {
|
||||
file: {
|
||||
name: string
|
||||
path: string
|
||||
type: 'file' | 'dir'
|
||||
size: number
|
||||
modified: string
|
||||
}
|
||||
content: string
|
||||
unsavedContent: string
|
||||
isEditing: boolean
|
||||
loading: boolean
|
||||
loaded: boolean
|
||||
}
|
||||
|
||||
export function usePopOutTab() {
|
||||
const { selectFile } = useTabStore()
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electronAPI?.onTabDataReceived((tabData: PopOutTabData) => {
|
||||
console.log('[PopOut] Received tab data:', tabData)
|
||||
|
||||
if (!tabData?.file?.path) {
|
||||
console.error('[PopOut] Invalid tab data received')
|
||||
return
|
||||
}
|
||||
|
||||
const { file, content, unsavedContent, isEditing, loading, loaded } = tabData
|
||||
|
||||
const newTab: TabState = {
|
||||
file: file as any,
|
||||
content: content || '',
|
||||
unsavedContent: unsavedContent || content || '',
|
||||
isEditing: isEditing || false,
|
||||
loading: false,
|
||||
loaded: true,
|
||||
}
|
||||
|
||||
useTabStore.setState((state) => {
|
||||
const newTabs = new Map(state.tabs)
|
||||
newTabs.set(file.path, newTab)
|
||||
return {
|
||||
tabs: newTabs,
|
||||
activeTabId: file.path,
|
||||
}
|
||||
})
|
||||
|
||||
selectFile(file as any)
|
||||
})
|
||||
|
||||
return () => {
|
||||
unsubscribe?.()
|
||||
}
|
||||
}, [selectFile])
|
||||
}
|
||||
@@ -34,5 +34,5 @@ export const useSidebarResize = (initialWidth: number = 250) => {
|
||||
}
|
||||
}, [isResizing])
|
||||
|
||||
return { sidebarWidth, startResizing }
|
||||
return { sidebarWidth, startResizing, isResizing }
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user