Compare commits

...

48 Commits

Author SHA1 Message Date
9caa43d4a2 fix(terminal): defer TerminalService start to user interaction 2026-03-22 01:05:06 +08:00
dff4674c54 fix(terminal): change --port=9997 to --port 9997 and remove --headless 2026-03-22 00:30:54 +08:00
19e7a51b61 Revert "fix: prevent multiple initialization and state race condition in PopoutPage"
This reverts commit 69bd91797d.
2026-03-22 00:18:19 +08:00
69bd91797d fix: prevent multiple initialization and state race condition in PopoutPage
- Use useRef to prevent multiple initializations
- Remove selectFile call that may cause state race condition
- Directly update store without selectFile to preserve isEditing state
2026-03-22 00:16:22 +08:00
3b1f99951e fix: pass tab content via URL params instead of IPC
- Pass content, unsavedContent, isEditing via URL query params
- PopoutPage reads directly from URL params on mount
- Eliminates IPC race condition entirely
- Direct store update with loaded:true to prevent auto-reload
2026-03-22 00:09:07 +08:00
2d87c267cf fix: wait for renderer ready before sending tab data
- Wait for did-finish-load event in transfer-tab-data handler
- Await transferTabData before closing file in handlePopOut
- Remove arbitrary 500ms timeout, now uses proper IPC ack flow
2026-03-22 00:06:01 +08:00
d0e286e4bb fix: prevent state loss when popout tab and hide option for home tab
- Force loaded:true and loading:false in usePopOutTab to prevent auto-load overwriting
- Hide '在新窗口中打开' option for home tab in context menu
2026-03-22 00:00:34 +08:00
aa5895873b feat: transfer tab state to popout window and close tab in main window
- Restore transfer-tab-data IPC for transferring tab state
- Create usePopOutTab hook to receive tab data in new window
- Update handlePopOut to transfer data and close tab in main window
- Add PopOutTabData interface for type safety
2026-03-21 23:52:02 +08:00
c37e6ab4f2 feat: add titlebar to popout window with minimize/maximize/close buttons
- Add window-minimize, window-maximize, window-close, window-is-maximized IPC handlers
- Add titlebar with window controls to PopoutPage
- Update preload and types for new window control APIs
2026-03-21 23:46:21 +08:00
f160adbdb1 feat: add 'pop out tab as new window' functionality
- Add createWindow IPC for creating secondary windows
- Add PopoutPage for content-only rendering in new windows
- Add multi-window management to electron state
- Add '在新窗口中打开' context menu to tabs
- Fix: Use standard URL path instead of hash for React Router routing
2026-03-21 23:42:48 +08:00
43828a87f0 chore: remove build artifacts and large binary files (xcsdd service binaries, gitea data) 2026-03-21 20:11:17 +08:00
2eb5a167b3 fix: 使用 taskkill /T 终止进程树,确保子进程也被关闭 2026-03-20 14:13:00 +08:00
8ee86c7b0f fix: 修复服务启动时的竞态条件,等待健康检查通过后再报告就绪 2026-03-20 14:12:00 +08:00
9effdcd070 fix: Terminal 启动参数保持 --port=9997 格式 2026-03-20 14:05:28 +08:00
7c7b334f5c chore: 更新 gitignore 忽略运行时数据,保留 Gitea 配置 2026-03-20 13:48:38 +08:00
8716f6f684 docs: 更新 README 添加 SDD 和 Terminal 模块说明 2026-03-20 13:45:57 +08:00
1d33a8e14f fix: 更新 Terminal 模块 exe 名称为 XCTerminal.exe 2026-03-20 13:33:28 +08:00
7db1f9162b feat: 默认折叠左侧文件列表 2026-03-20 13:10:24 +08:00
d54510a864 feat: 添加 Terminal 模块,启动 XCCMD.exe 服务 2026-03-20 13:08:43 +08:00
28df633b00 feat: 完善 SDD 模块配置,添加 --headless 参数 2026-03-20 13:02:58 +08:00
c83f23c319 feat: 添加 SDD (规范驱动开发) 模块 2026-03-18 16:17:30 +08:00
90517f2289 feat: 添加语音模块支持,优化服务启动方式 2026-03-17 04:03:39 +08:00
308df54a15 fix: 修复关闭软件后 OpenCode 进程泄漏问题
- 使用 taskkill /F /T 强制终止进程树
- 在 before-quit 中 await 等待服务停止完成
- 修复 stop 方法中可能的空指针问题
2026-03-16 13:11:46 +08:00
dcd1fcd709 chore: 添加新依赖包 (@opencode-ai/sdk, @radix-ui, class-variance-authority, tailwind-merge) 2026-03-15 00:02:10 +08:00
18c02053da feat(opencode): 将 iframe 替换为 webview 避免 Tauri 应用在 iframe 中卡死 2026-03-14 23:56:02 +08:00
3c353cb701 fix: 修复打包后 XCOpenCodeWeb.exe 路径问题 2026-03-14 23:11:38 +08:00
0d5cd329ca feat(home): add Thinking fold button, improve message styling 2026-03-14 22:56:42 +08:00
e950484af6 feat(home): add drag file to chat input, add settings config API 2026-03-14 22:22:35 +08:00
cbc1af7348 feat(home): add AI chat interface with OpenCode integration 2026-03-14 21:53:52 +08:00
88d42b37a6 feat(opencode): 改进 XCOpenCodeWeb 服务管理和健康检查 2026-03-14 20:44:15 +08:00
b5343bcd9d Update xcopencodeweb port to 3002 2026-03-14 16:38:25 +08:00
9b22b647f2 feat(remote): 添加 XCOpenCodeWeb 服务管理
- 新增 XCOpenCodeWebService.js 服务模块
- 支持启动/停止/健康检测(每10秒)
- 随 remote 服务启动/退出
- 配置文件添加 xcopencodeweb 配置
- 修复 opencode 默认端口配置
2026-03-14 16:02:05 +08:00
50cd1e29c9 fix(remote): 移除默认端口配置,使用 opencode 默认端口 4096 2026-03-14 15:27:37 +08:00
ba02eb10a7 fix: 端口改为 9999 2026-03-14 15:12:09 +08:00
7c656785c8 fix: XCOpenCodeWeb 启动时传入端口参数 --port 3002 2026-03-13 23:17:52 +08:00
f692961823 refactor: 统一端口配置,通过 IPC 获取而非硬编码 2026-03-13 21:20:31 +08:00
1be470f45b fix: 端口改回 3002 2026-03-13 21:15:26 +08:00
96c709f109 fix: 修正 XCOpenCodeWeb 端口为 3000 2026-03-13 21:14:35 +08:00
fd77455f5b fix: 修改 XCOpenCodeWeb.exe 路径为 services/xcopencodeweb/
- 路径从 bin/ 改为 services/xcopencodeweb/
- 更新 package.json 打包配置
- 更新 .gitignore
2026-03-13 21:04:25 +08:00
72d79ae214 feat: 实现 OpenCode 页面生命周期管理 XCOpenCodeWeb.exe
- 新增 electron/services/xcOpenCodeWebService.ts 服务管理模块
- 标签页打开时启动 XCOpenCodeWeb.exe,关闭时停止
- 使用 iframe 在 OpenCode 页面显示 Web 服务 (端口 3002)
- 添加 bin 目录打包配置
- 添加 TypeScript 类型定义
2026-03-13 20:55:34 +08:00
53c1045406 revert: 回滚之前的错误修改,恢复为命令行模式 2026-03-13 20:42:02 +08:00
fd2255c83a feat: 支持 XCOpenCodeWeb.exe 配置和打包
- 添加 electron/config.ts 配置文件
- 支持 command(命令行) 和 exe 两种模式
- 更新 package.json 打包配置,添加 bin 目录
- 更新 .gitignore 忽略 bin/*.exe
2026-03-13 20:34:07 +08:00
986ecb2561 fix: 修复 ai 模块中 Python 脚本路径问题
使用 PROJECT_ROOT 替代 __dirname 计算,确保打包后能正确找到 tools 文件夹
2026-03-13 20:31:54 +08:00
8d4a9a3704 feat: 将 OpenCode 服务管理抽取为独立模块
- 创建 electron/services/opencodeService.ts 独立服务模块
- 支持健康检测(每10秒)、自动重启(最多3次)
- 随软件生命周期自动启动/停止
2026-03-13 20:30:02 +08:00
e6c41491b3 chore: 移除保存调试日志 2026-03-13 19:00:27 +08:00
cd70b50180 feat: 添加 opencode 模块和相关服务 2026-03-13 18:39:58 +08:00
96390df254 chore: 添加保存调试日志、修复 time-tracking 类型、简化首页 2026-03-13 18:39:08 +08:00
371d4ce327 fix: 修复 markdown 编辑保存后内容丢失的问题
- 在 saveContent 中缓存 unsavedContent,避免 async 期间的竞态条件
- 在 useMarkdownLogic 中添加 lastContentRef 跟踪内容变化,防止不必要的编辑器更新
2026-03-13 18:38:38 +08:00
87 changed files with 4103 additions and 524 deletions

12
.gitignore vendored
View File

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

View File

@@ -88,6 +88,9 @@ XCDesktop/
│ │ ├── pydemos/ # Python Demo
│ │ ├── weread/ # 微信读书
│ │ └── remote/ # 远程网页
│ │ │ ├── opencode/ # AI 编程助手
│ │ │ ├── sdd/ # 规范驱动开发
│ │ │ └── terminal/ # 终端
│ ├── stores/ # Zustand 状态管理
│ └── types/ # 类型定义
├── api/ # 后端 API (Express)
@@ -150,6 +153,8 @@ XCDesktop/
| Python Demo | Python 脚本管理 |
| 微信读书 | 微信读书网页版集成 |
| 远程网页 | 内置浏览器,访问任意网页 |
| SDD | 规范驱动开发,自动化流程 |
| 终端 | 集成终端界面,管理和执行命令 |
## 🛠️ 技术栈

3
api/.env Normal file
View File

@@ -0,0 +1,3 @@
MINIMAX_API_KEY=sk-api-Ip6OsHSvDJlnbl7L9sRsK277vx1W6VfM8xXfbjjEcdsnT_91kZmciWDiNHzMkDMTEsxirTZdA-shZ-oYS0Qo70m3raWeO7_1Zr8rmM9D5QFWKgkLya60HrA
MINIMAX_GROUP_ID=1982508094420165122
OPENAI_API_KEY=

2
api/.env.example Normal file
View File

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

View File

@@ -23,8 +23,12 @@ import { apiModules } from './modules/index.js'
import { validateModuleConsistency } from './infra/moduleValidator.js'
import path from 'path'
import fs from 'fs'
import { fileURLToPath } from 'url'
dotenv.config()
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
dotenv.config({ path: path.resolve(__dirname, './.env') })
const app: express.Application = express()
export const container = new ServiceContainer()

View File

@@ -38,6 +38,18 @@ export const config = {
get isDev(): boolean {
return !this.isElectron && !this.isVercel
},
get minimaxApiKey(): string | undefined {
return process.env.MINIMAX_API_KEY
},
get minimaxGroupId(): string | undefined {
return process.env.MINIMAX_GROUP_ID
},
get openaiApiKey(): string | undefined {
return process.env.OPENAI_API_KEY
},
}
export const PATHS = {

View File

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

View File

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

View File

@@ -56,6 +56,20 @@ async function getStaticModules(): Promise<ApiModule[]> {
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) => {
const orderA = a.metadata.order ?? 0
const orderB = b.metadata.order ?? 0
@@ -74,3 +88,5 @@ export * from './pydemos/index.js'
export * from './document-parser/index.js'
export * from './ai/index.js'
export * from './remote/index.js'
export * from './opencode/index.js'
export * from './voice/index.js'

View File

@@ -0,0 +1,20 @@
import type { Router } from 'express'
import type { ServiceContainer } from '../../infra/container.js'
import { createApiModule } from '../../infra/createModule.js'
import { OPENCODE_MODULE } from '../../../shared/modules/opencode/index.js'
import { createOpencodeRoutes } from './routes.js'
export * from './routes.js'
export const createOpencodeModule = () => {
return createApiModule({
...OPENCODE_MODULE,
version: OPENCODE_MODULE.version || '1.0.0',
}, {
routes: (_container: ServiceContainer): Router => {
return createOpencodeRoutes()
},
})
}
export default createOpencodeModule

View File

@@ -0,0 +1,223 @@
import { Router } from 'express'
import { createOpencodeClient } from '@opencode-ai/sdk'
const OPENCODE_URL = 'http://localhost:4096'
async function getClient() {
console.log('[OpenCode] Creating client for URL:', OPENCODE_URL)
return createOpencodeClient({
baseUrl: OPENCODE_URL,
})
}
export function createOpencodeRoutes(): Router {
const router = Router()
router.get('/', async (req, res) => {
const action = req.query.action as string | undefined
try {
const client = await getClient()
if (action === 'list-sessions') {
const response = await client.session.list()
const sessions = (response.data || []).map((session: { id: string; title?: string; createdAt?: string }) => ({
id: session.id,
title: session.title || 'New Chat',
createdAt: session.createdAt ? new Date(session.createdAt).getTime() : Date.now(),
}))
res.json({ sessions })
return
}
res.status(400).json({ error: 'Unknown action' })
} catch (error: unknown) {
console.error('API error:', error)
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
res.status(500).json({ error: errorMessage })
}
})
router.post('/', async (req, res) => {
try {
const body = req.body
const { action, sessionId, title, text, attachments } = body
console.log('[OpenCode] Received action:', action, 'sessionId:', sessionId)
const client = await getClient()
console.log('[OpenCode] Client created successfully')
if (action === 'create-session') {
try {
const response = await client.session.create({
body: { title: title || 'New Chat' },
})
if (response.error) {
res.status(500).json({ error: 'OpenCode error', details: response.error })
return
}
const sessionData = response.data as { id: string; title?: string; createdAt?: string } | undefined
if (sessionData) {
res.json({
session: {
id: sessionData.id,
title: sessionData.title,
createdAt: sessionData.createdAt ? new Date(sessionData.createdAt).getTime() : Date.now(),
},
})
return
}
res.status(500).json({ error: 'Failed to create session - no data' })
return
} catch (e: unknown) {
console.error('Create session exception:', e)
const errorMessage = e instanceof Error ? e.message : 'Unknown error'
res.status(500).json({ error: errorMessage, exception: true })
return
}
}
if (action === 'delete-session') {
const response = await client.session.delete({
path: { id: sessionId },
})
res.json({ success: response.data })
return
}
if (action === 'get-session') {
const response = await client.session.get({
path: { id: sessionId },
})
if (response.data) {
res.json({
title: response.data.title || 'New Chat',
})
return
}
res.status(404).json({ error: 'Session not found' })
return
}
if (action === 'get-messages') {
const limit = body.limit || 20
const response = await client.session.messages({
path: { id: sessionId },
query: { limit }
})
const messages = (response.data || []).map((item: { info?: { id?: string; role?: string; content?: string; createdAt?: string }; content?: string; parts?: unknown[]; id?: string; role?: string }) => {
let content = item.info?.content || item.content || ''
const parts = item.parts as Array<{ type: string; text?: string }> | undefined
if (parts && parts.length > 0) {
const textParts = parts
.filter((p) => p.type === 'text')
.map((p) => p.text)
.join('')
if (textParts) {
content = textParts
}
}
return {
id: item.info?.id || item.id,
role: item.info?.role || item.role,
content: content,
parts: item.parts || [],
createdAt: item.info?.createdAt,
}
})
const hasMore = messages.length >= limit
res.json({ messages, hasMore })
return
}
if (action === 'prompt') {
const parts: Array<{ type: 'text'; text: string } | { type: 'file'; url: string; name: string; mime: string }> = []
if (attachments && attachments.length > 0) {
for (const att of attachments as Array<{ url: string; name: string; mediaType: string }>) {
parts.push({
type: 'file',
url: att.url,
name: att.name,
mime: att.mediaType,
})
}
}
parts.push({ type: 'text', text })
try {
const response = await client.session.prompt({
path: { id: sessionId },
body: { parts },
})
if (response.data) {
res.json({
message: {
id: (response.data.info as { id?: string })?.id || crypto.randomUUID(),
role: (response.data.info as { role?: string })?.role || 'assistant',
content: '',
parts: response.data.parts || [],
createdAt: new Date().toISOString(),
},
})
return
}
res.status(500).json({ error: 'Failed to get response', details: response.error })
return
} catch (e: unknown) {
console.error('Prompt exception:', e)
const errorMessage = e instanceof Error ? e.message : 'Unknown error'
res.status(500).json({ error: errorMessage })
return
}
}
if (action === 'summarize-session') {
try {
await client.session.summarize({
path: { id: sessionId },
})
const response = await client.session.get({
path: { id: sessionId },
})
if (response.data) {
res.json({
title: response.data.title || 'New Chat',
})
return
}
res.status(500).json({ error: 'Failed to get session' })
return
} catch (e: unknown) {
console.error('Summarize error:', e)
const errorMessage = e instanceof Error ? e.message : 'Unknown error'
res.status(500).json({ error: errorMessage })
return
}
}
res.status(400).json({ error: 'Unknown action' })
} catch (error: unknown) {
console.error('API error:', error)
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
res.status(500).json({ error: errorMessage })
}
})
return router
}

View File

@@ -183,7 +183,7 @@ class SessionPersistenceService implements SessionPersistence {
try {
const content = await fs.readFile(filePath, 'utf-8')
const data = JSON.parse(content)
data.activeDays = Object.values(data.days).filter(d => d.totalDuration > 0).length
data.activeDays = Object.values(data.days).filter((d: any) => d.totalDuration > 0).length
return data
} catch (err) {
return createEmptyMonthData(year, month)
@@ -195,7 +195,7 @@ class SessionPersistenceService implements SessionPersistence {
try {
const content = await fs.readFile(filePath, 'utf-8')
const data = JSON.parse(content)
data.totalActiveDays = Object.values(data.months).filter(m => m.totalDuration > 0).length
data.totalActiveDays = Object.values(data.months).filter((m: any) => m.totalDuration > 0).length
return data
} catch (err) {
return createEmptyYearData(year)

View File

@@ -389,7 +389,7 @@ class TimeTrackerService {
const yearData = await this.persistence.getYearData(targetYear)
totalDuration = yearData.yearlyTotal
activeDays = Object.values(yearData.months).reduce((sum, m) => {
return sum + Object.entries(m).filter(([_, d]) => (d as { totalDuration: number }).totalDuration > 0).length
return sum + Object.entries(m).filter(([_, d]) => (d as any).totalDuration > 0).length
}, 0)
for (const [month, summary] of Object.entries(yearData.months)) {

View File

@@ -0,0 +1,25 @@
import type { Router } from 'express'
import type { ServiceContainer } from '../../infra/container.js'
import { createApiModule } from '../../infra/createModule.js'
import { voiceModule, VOICE_MODULE } from '../../../shared/modules/voice/index.js'
import { createVoiceRoutes } from './routes.js'
export * from './routes.js'
export const createVoiceModule = () => {
return createApiModule(
{
...voiceModule,
basePath: '/voice',
version: '1.0.0',
order: 100,
},
{
routes: (_container: ServiceContainer): Router => {
return createVoiceRoutes()
},
}
)
}
export default createVoiceModule

117
api/modules/voice/routes.ts Normal file
View File

@@ -0,0 +1,117 @@
import { Router } from 'express'
import { config } from '../../config/index.js'
interface AIMLAPISTTCreateResponse {
generation_id?: string
status?: string
error?: {
message: string
}
}
interface AIMLAPISTTQueryResponse {
status?: string
result?: {
results?: {
channels?: Array<{
alternatives?: Array<{
transcript?: string
}>
}>
}
}
error?: {
message: string
}
}
export function createVoiceRoutes(): Router {
const router = Router()
router.post('/stt', async (req, res) => {
console.log('[Voice] Request received, body keys:', Object.keys(req.body || {}))
try {
const apiKey = config.minimaxApiKey
console.log('[Voice] API Key exists:', !!apiKey)
if (!apiKey) {
res.status(500).json({ error: 'MiniMax API key not configured' })
return
}
if (!req.body.audio) {
res.status(400).json({ error: 'No audio data provided' })
return
}
const audioBuffer = Buffer.from(req.body.audio, 'base64')
console.log('[Voice] Audio buffer size:', audioBuffer.length)
const formData = new FormData()
const blob = new Blob([audioBuffer], { type: 'audio/webm' })
formData.append('file', blob, 'audio.webm')
formData.append('model', '#g1_whisper-large')
console.log('[Voice] Creating STT job via MiniMax...')
const createResponse = await fetch('https://api.minimax.chat/v1/stt/create', {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
},
body: formData,
})
console.log('[Voice] Create response status:', createResponse.status)
const createData: AIMLAPISTTCreateResponse = await createResponse.json()
console.log('[Voice] Create response:', createData)
if (!createResponse.ok || !createData.generation_id) {
console.error('[Voice] Failed to create STT job:', createData.error?.message)
res.status(500).json({ error: createData.error?.message || 'Failed to create STT job' })
return
}
const jobId = createData.generation_id
console.log('[Voice] Job ID:', jobId)
console.log('[Voice] Polling for result...')
let resultText = ''
for (let i = 0; i < 30; i++) {
await new Promise(resolve => setTimeout(resolve, 1000))
const queryResponse = await fetch(`https://api.minimax.chat/v1/stt/${jobId}`, {
headers: {
'Authorization': `Bearer ${apiKey}`,
},
})
const queryData: AIMLAPISTTQueryResponse = await queryResponse.json()
console.log('[Voice] Query response:', queryData)
if (queryData.status === 'succeeded') {
resultText = queryData.result?.results?.channels?.[0]?.alternatives?.[0]?.transcript || ''
break
} else if (queryData.status === 'failed') {
console.error('[Voice] STT job failed:', queryData.error?.message)
res.status(500).json({ error: queryData.error?.message || 'STT processing failed' })
return
}
}
if (!resultText) {
res.status(500).json({ error: 'STT processing timeout' })
return
}
console.log('[Voice] Final result:', resultText)
res.json({ text: resultText })
} catch (error) {
console.error('[Voice] STT error:', error)
res.status(500).json({ error: 'Failed to process audio' })
}
})
return router
}

View File

@@ -5,8 +5,11 @@ 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';
import { spawn, ChildProcess } from 'child_process';
log.initialize();
@@ -15,15 +18,21 @@ const __dirname = path.dirname(__filename);
electronState.setDevelopment(!app.isPackaged);
let opencodeProcess: ChildProcess | null = null;
const OPENCODE_PORT = 4096;
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) {
@@ -89,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' };
@@ -352,62 +420,141 @@ ipcMain.handle('remote-download-file', async (_event, id: string, serverHost: st
}
});
ipcMain.handle('opencode-get-status', () => {
return opencodeService.getStatus();
});
ipcMain.handle('opencode-start-server', async () => {
if (opencodeProcess) {
log.info('Opencode server already running');
return { success: true, port: OPENCODE_PORT };
}
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('Starting opencode server...');
opencodeProcess = spawn('opencode', ['serve'], {
stdio: 'pipe',
shell: true,
detached: false,
});
opencodeProcess.stdout?.on('data', (data) => {
log.info(`[opencode] ${data}`);
});
opencodeProcess.stderr?.on('data', (data) => {
log.error(`[opencode error] ${data}`);
});
opencodeProcess.on('error', (err) => {
log.error('Opencode process error:', err);
opencodeProcess = null;
});
opencodeProcess.on('exit', (code) => {
log.info(`Opencode process exited with code ${code}`);
opencodeProcess = null;
});
return { success: true, port: OPENCODE_PORT };
log.info('[PopOut] Creating new window for:', tabData);
const windowId = await createSecondaryWindow(tabData);
return { success: true, windowId };
} catch (error: any) {
log.error('Failed to start opencode server:', error);
log.error('[PopOut] Failed to create window:', error);
return { success: false, error: error.message };
}
});
ipcMain.handle('opencode-stop-server', async () => {
if (!opencodeProcess) {
log.info('Opencode server not running');
return { success: true };
}
ipcMain.handle('transfer-tab-data', async (_event, windowId: number, tabData: any) => {
try {
log.info('Stopping opencode server...');
opencodeProcess.kill('SIGTERM');
opencodeProcess = null;
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('Failed to stop opencode server:', error);
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.');
@@ -444,6 +591,9 @@ app.whenReady().then(async () => {
}
await startServer();
await opencodeService.start();
await createWindow();
startClipboardWatcher();
@@ -473,7 +623,31 @@ app.whenReady().then(async () => {
app.on('window-all-closed', () => {
globalShortcut.unregisterAll();
opencodeService.stop();
xcOpenCodeWebService.stop();
sddService.stop();
terminalService.stop();
stopClipboardWatcher();
if (process.platform !== 'darwin') {
app.quit();
}
});
let isQuitting = false;
app.on('before-quit', async (event) => {
if (isQuitting) return;
isQuitting = true;
log.info('[App] before-quit received, cleaning up...');
stopClipboardWatcher();
await Promise.all([
opencodeService.stop(),
xcOpenCodeWebService.stop(),
sddService.stop(),
terminalService.stop()
]);
log.info('[App] All services stopped');
});

View File

@@ -41,4 +41,27 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.invoke('remote-download-file', id, serverHost, port, fileName, remotePath, localPath, password),
opencodeStartServer: () => ipcRenderer.invoke('opencode-start-server'),
opencodeStopServer: () => ipcRenderer.invoke('opencode-stop-server'),
xcOpenCodeWebStart: () => ipcRenderer.invoke('xc-opencode-web-start'),
xcOpenCodeWebStop: () => ipcRenderer.invoke('xc-opencode-web-stop'),
xcOpenCodeWebGetStatus: () => ipcRenderer.invoke('xc-opencode-web-get-status'),
xcOpenCodeWebGetPort: () => ipcRenderer.invoke('xc-opencode-web-get-port'),
sddStart: () => ipcRenderer.invoke('sdd-start'),
sddStop: () => ipcRenderer.invoke('sdd-stop'),
sddGetStatus: () => ipcRenderer.invoke('sdd-get-status'),
sddGetPort: () => ipcRenderer.invoke('sdd-get-port'),
terminalStart: () => ipcRenderer.invoke('terminal-start'),
terminalStop: () => ipcRenderer.invoke('terminal-stop'),
terminalGetStatus: () => ipcRenderer.invoke('terminal-get-status'),
terminalGetPort: () => ipcRenderer.invoke('terminal-get-port'),
createWindow: (tabData: { route: string; title: string }) => ipcRenderer.invoke('create-window', tabData),
transferTabData: (windowId: number, tabData: any) => ipcRenderer.invoke('transfer-tab-data', windowId, tabData),
onTabDataReceived: (callback: (tabData: any) => void) => {
const handler = (_event: Electron.IpcRendererEvent, tabData: any) => callback(tabData);
ipcRenderer.on('tab-data-received', handler);
return () => ipcRenderer.removeListener('tab-data-received', handler);
},
windowMinimize: () => ipcRenderer.invoke('window-minimize'),
windowMaximize: () => ipcRenderer.invoke('window-maximize'),
windowClose: () => ipcRenderer.invoke('window-close'),
windowIsMaximized: () => ipcRenderer.invoke('window-is-maximized'),
})

View File

@@ -0,0 +1,196 @@
import { spawn, exec, ChildProcess } from 'child_process';
import log from 'electron-log';
const OPENCODE_PORT = 4096;
const HEALTH_CHECK_INTERVAL = 10000;
const MAX_RESTART_ATTEMPTS = 3;
const HEALTH_CHECK_TIMEOUT = 2000;
class OpenCodeService {
private process: ChildProcess | null = null;
private healthCheckTimer: NodeJS.Timeout | null = null;
private restartAttempts = 0;
private _isRunning = false;
get port(): number {
return OPENCODE_PORT;
}
isRunning(): boolean {
return this._isRunning;
}
getStatus(): { running: boolean; port: number; restartAttempts: number } {
return {
running: this._isRunning,
port: this.port,
restartAttempts: this.restartAttempts,
};
}
private getAuthHeaders(): Record<string, string> {
const password = 'xc_opencode_password';
const encoded = Buffer.from(`:${password}`).toString('base64');
return {
'Authorization': `Basic ${encoded}`,
};
}
private async checkHealth(): Promise<boolean> {
try {
const response = await fetch(`http://127.0.0.1:${this.port}/session`, {
method: 'GET',
headers: this.getAuthHeaders(),
signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT),
});
return response.ok;
} catch (error) {
log.warn('[OpenCodeService] Health check failed:', error);
return false;
}
}
private async restart(): Promise<void> {
if (this.restartAttempts >= MAX_RESTART_ATTEMPTS) {
log.error('[OpenCodeService] Max restart attempts reached, giving up');
this._isRunning = false;
return;
}
this.restartAttempts++;
log.info(`[OpenCodeService] Attempting restart (${this.restartAttempts}/${MAX_RESTART_ATTEMPTS})...`);
await this.stop();
await this.start();
}
private startHealthCheck(): void {
if (this.healthCheckTimer) {
clearInterval(this.healthCheckTimer);
}
this.healthCheckTimer = setInterval(async () => {
const isHealthy = await this.checkHealth();
if (!isHealthy && this._isRunning) {
log.warn('[OpenCodeService] Health check failed, attempting restart...');
await this.restart();
}
}, HEALTH_CHECK_INTERVAL);
}
async start(): Promise<{ success: boolean; error?: string }> {
if (this._isRunning && this.process) {
log.info('[OpenCodeService] Already running');
return { success: true };
}
try {
log.info('[OpenCodeService] Starting OpenCode server...');
this.process = spawn('opencode', ['serve'], {
stdio: 'pipe',
shell: true,
detached: false,
});
this.process.stdout?.on('data', (data) => {
log.info(`[OpenCode] ${data}`);
});
this.process.stderr?.on('data', (data) => {
log.error(`[OpenCode error] ${data}`);
});
this.process.on('error', (err) => {
log.error('[OpenCodeService] Process error:', err);
this._isRunning = false;
this.process = null;
});
this.process.on('exit', (code) => {
log.info(`[OpenCodeService] Process exited with code ${code}`);
this._isRunning = false;
this.process = null;
});
const maxWaitTime = 30000;
const checkInterval = 500;
const startTime = Date.now();
while (Date.now() - startTime < maxWaitTime) {
const isHealthy = await this.checkHealth();
if (isHealthy) {
break;
}
await new Promise<void>((resolve) => setTimeout(resolve, checkInterval));
}
const finalHealth = await this.checkHealth();
if (!finalHealth) {
log.warn('[OpenCodeService] Health check failed after max wait time');
}
this._isRunning = true;
this.restartAttempts = 0;
this.startHealthCheck();
log.info(`[OpenCodeService] Started successfully on port ${this.port}`);
return { success: true };
} catch (error: any) {
log.error('[OpenCodeService] Failed to start:', error);
this._isRunning = false;
return { success: false, error: error.message };
}
}
async stop(): Promise<{ success: boolean; error?: string }> {
if (this.healthCheckTimer) {
clearInterval(this.healthCheckTimer);
this.healthCheckTimer = null;
}
if (!this.process) {
log.info('[OpenCodeService] Not running');
return { success: true };
}
try {
log.info('[OpenCodeService] Stopping...');
const pid = this.process?.pid;
const processRef = this.process;
this.process = null;
this._isRunning = false;
if (pid && process.platform === 'win32') {
return new Promise((resolve) => {
exec(`taskkill /F /T /PID ${pid}`, (error) => {
if (error) {
log.warn('[OpenCodeService] taskkill failed, process may already be dead:', error.message);
}
this.restartAttempts = 0;
log.info('[OpenCodeService] Stopped');
resolve({ success: true });
});
});
} else if (pid && processRef) {
processRef.kill('SIGTERM');
await new Promise<void>((resolve) => setTimeout(resolve, 1000));
if (!processRef.killed) {
processRef.kill('SIGKILL');
}
this.restartAttempts = 0;
log.info('[OpenCodeService] Stopped');
return { success: true };
}
this.restartAttempts = 0;
return { success: true };
} catch (error: any) {
log.error('[OpenCodeService] Failed to stop:', error);
return { success: false, error: error.message };
}
}
}
export const opencodeService = new OpenCodeService();

View File

@@ -0,0 +1,190 @@
import { spawn, exec, ChildProcess } from 'child_process';
import { app } from 'electron';
import path from 'path';
import log from 'electron-log';
const SDD_PORT = 9998;
const HEALTH_CHECK_INTERVAL = 10000;
const HEALTH_CHECK_TIMEOUT = 2000;
class SDDService {
private process: ChildProcess | null = null;
private processPid: number | null = null;
private healthCheckTimer: NodeJS.Timeout | null = null;
private _isRunning = false;
private _isStarting = false;
get port(): number {
return SDD_PORT;
}
isRunning(): boolean {
return this._isRunning;
}
getStatus(): { running: boolean; port: number } {
return {
running: this._isRunning,
port: this.port,
};
}
private getExePath(): string {
const exeName = 'XCSDD.exe';
const isPackaged = app.isPackaged;
let basePath: string;
if (isPackaged) {
basePath = path.join(process.resourcesPath, 'app.asar.unpacked');
} else {
basePath = process.cwd();
}
return path.join(basePath, 'services', 'xcsdd', exeName);
}
private getExeArgs(): string[] {
return ['--port', this.port.toString(), '--headless'];
}
private async checkHealth(): Promise<boolean> {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), HEALTH_CHECK_TIMEOUT);
const response = await fetch(`http://127.0.0.1:${this.port}`, {
signal: controller.signal,
});
clearTimeout(timeoutId);
return response.ok || response.status === 401;
} catch (error) {
log.warn('[SDDService] Health check failed:', error);
return false;
}
}
private startHealthCheck(): void {
if (this.healthCheckTimer) {
clearInterval(this.healthCheckTimer);
}
this.healthCheckTimer = setInterval(async () => {
const isHealthy = await this.checkHealth();
if (!isHealthy && this._isRunning) {
log.warn('[SDDService] Service not responding');
}
}, HEALTH_CHECK_INTERVAL);
}
async start(): Promise<{ success: boolean; error?: string }> {
if (this._isRunning && this.process) {
log.info('[SDDService] Already running');
return { success: true };
}
if (this._isStarting) {
log.info('[SDDService] Already starting');
return { success: true };
}
this._isStarting = true;
try {
const exePath = this.getExePath();
const exeArgs = this.getExeArgs();
log.info(`[SDDService] Starting from: ${exePath} with args: ${exeArgs.join(' ')}`);
this.process = spawn(exePath, exeArgs, {
stdio: 'pipe',
});
this.processPid = this.process.pid ?? null;
this.process.stdout?.on('data', (data) => {
log.info(`[SDD] ${data}`);
});
this.process.stderr?.on('data', (data) => {
log.error(`[SDD error] ${data}`);
});
this.process.on('error', (err) => {
log.error('[SDDService] Process error:', err);
this._isRunning = false;
this._isStarting = false;
this.process = null;
this.processPid = null;
});
this.process.on('exit', (code) => {
log.info(`[SDDService] Process exited with code ${code}`);
this._isRunning = false;
this._isStarting = false;
this.process = null;
this.processPid = null;
});
const maxWaitTime = 30000;
const checkInterval = 500;
const startTime = Date.now();
while (Date.now() - startTime < maxWaitTime) {
const isHealthy = await this.checkHealth();
if (isHealthy) {
break;
}
await new Promise<void>((resolve) => setTimeout(resolve, checkInterval));
}
const finalHealth = await this.checkHealth();
if (!finalHealth) {
log.warn('[SDDService] Health check failed after max wait time');
}
this._isRunning = true;
this._isStarting = false;
this.startHealthCheck();
log.info(`[SDDService] Started successfully on port ${this.port}`);
return { success: true };
} catch (error: any) {
log.error('[SDDService] Failed to start:', error);
this._isRunning = false;
this._isStarting = false;
return { success: false, error: error.message };
}
}
async stop(): Promise<{ success: boolean; error?: string }> {
if (this.healthCheckTimer) {
clearInterval(this.healthCheckTimer);
this.healthCheckTimer = null;
}
if (!this.processPid) {
log.info('[SDDService] Not running');
return { success: true };
}
return new Promise((resolve) => {
log.info(`[SDDService] Stopping process ${this.processPid}...`);
exec(`taskkill /F /T /PID ${this.processPid}`, (error) => {
this.process = null;
this.processPid = null;
this._isRunning = false;
if (error) {
log.error('[SDDService] Failed to stop:', error);
resolve({ success: false, error: error.message });
} else {
log.info('[SDDService] Stopped');
resolve({ success: true });
}
});
});
}
}
export const sddService = new SDDService();

View File

@@ -0,0 +1,190 @@
import { spawn, exec, ChildProcess } from 'child_process';
import { app } from 'electron';
import path from 'path';
import log from 'electron-log';
const TERMINAL_PORT = 9997;
const HEALTH_CHECK_INTERVAL = 10000;
const HEALTH_CHECK_TIMEOUT = 2000;
class TerminalService {
private process: ChildProcess | null = null;
private processPid: number | null = null;
private healthCheckTimer: NodeJS.Timeout | null = null;
private _isRunning = false;
private _isStarting = false;
get port(): number {
return TERMINAL_PORT;
}
isRunning(): boolean {
return this._isRunning;
}
getStatus(): { running: boolean; port: number } {
return {
running: this._isRunning,
port: this.port,
};
}
private getExePath(): string {
const exeName = 'XCTerminal.exe';
const isPackaged = app.isPackaged;
let basePath: string;
if (isPackaged) {
basePath = path.join(process.resourcesPath, 'app.asar.unpacked');
} else {
basePath = process.cwd();
}
return path.join(basePath, 'services', 'xcterminal', exeName);
}
private getExeArgs(): string[] {
return ['--port', this.port.toString()];
}
private async checkHealth(): Promise<boolean> {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), HEALTH_CHECK_TIMEOUT);
const response = await fetch(`http://127.0.0.1:${this.port}`, {
signal: controller.signal,
});
clearTimeout(timeoutId);
return response.ok || response.status === 401;
} catch (error) {
log.warn('[TerminalService] Health check failed:', error);
return false;
}
}
private startHealthCheck(): void {
if (this.healthCheckTimer) {
clearInterval(this.healthCheckTimer);
}
this.healthCheckTimer = setInterval(async () => {
const isHealthy = await this.checkHealth();
if (!isHealthy && this._isRunning) {
log.warn('[TerminalService] Service not responding');
}
}, HEALTH_CHECK_INTERVAL);
}
async start(): Promise<{ success: boolean; error?: string }> {
if (this._isRunning && this.process) {
log.info('[TerminalService] Already running');
return { success: true };
}
if (this._isStarting) {
log.info('[TerminalService] Already starting');
return { success: true };
}
this._isStarting = true;
try {
const exePath = this.getExePath();
const exeArgs = this.getExeArgs();
log.info(`[TerminalService] Starting from: ${exePath} with args: ${exeArgs.join(' ')}`);
this.process = spawn(exePath, exeArgs, {
stdio: 'pipe',
});
this.processPid = this.process.pid ?? null;
this.process.stdout?.on('data', (data) => {
log.info(`[Terminal] ${data}`);
});
this.process.stderr?.on('data', (data) => {
log.error(`[Terminal error] ${data}`);
});
this.process.on('error', (err) => {
log.error('[TerminalService] Process error:', err);
this._isRunning = false;
this._isStarting = false;
this.process = null;
this.processPid = null;
});
this.process.on('exit', (code) => {
log.info(`[TerminalService] Process exited with code ${code}`);
this._isRunning = false;
this._isStarting = false;
this.process = null;
this.processPid = null;
});
const maxWaitTime = 30000;
const checkInterval = 500;
const startTime = Date.now();
while (Date.now() - startTime < maxWaitTime) {
const isHealthy = await this.checkHealth();
if (isHealthy) {
break;
}
await new Promise<void>((resolve) => setTimeout(resolve, checkInterval));
}
const finalHealth = await this.checkHealth();
if (!finalHealth) {
log.warn('[TerminalService] Health check failed after max wait time');
}
this._isRunning = true;
this._isStarting = false;
this.startHealthCheck();
log.info(`[TerminalService] Started successfully on port ${this.port}`);
return { success: true };
} catch (error: any) {
log.error('[TerminalService] Failed to start:', error);
this._isRunning = false;
this._isStarting = false;
return { success: false, error: error.message };
}
}
async stop(): Promise<{ success: boolean; error?: string }> {
if (this.healthCheckTimer) {
clearInterval(this.healthCheckTimer);
this.healthCheckTimer = null;
}
if (!this.processPid) {
log.info('[TerminalService] Not running');
return { success: true };
}
return new Promise((resolve) => {
log.info(`[TerminalService] Stopping process ${this.processPid}...`);
exec(`taskkill /F /T /PID ${this.processPid}`, (error) => {
this.process = null;
this.processPid = null;
this._isRunning = false;
if (error) {
log.error('[TerminalService] Failed to stop:', error);
resolve({ success: false, error: error.message });
} else {
log.info('[TerminalService] Stopped');
resolve({ success: true });
}
});
});
}
}
export const terminalService = new TerminalService();

View File

@@ -0,0 +1,190 @@
import { spawn, exec, ChildProcess } from 'child_process';
import { app } from 'electron';
import path from 'path';
import log from 'electron-log';
const XCOPENCODEWEB_PORT = 9999;
const HEALTH_CHECK_INTERVAL = 10000;
const HEALTH_CHECK_TIMEOUT = 2000;
class XCOpenCodeWebService {
private process: ChildProcess | null = null;
private processPid: number | null = null;
private healthCheckTimer: NodeJS.Timeout | null = null;
private _isRunning = false;
private _isStarting = false;
get port(): number {
return XCOPENCODEWEB_PORT;
}
isRunning(): boolean {
return this._isRunning;
}
getStatus(): { running: boolean; port: number } {
return {
running: this._isRunning,
port: this.port,
};
}
private getExePath(): string {
const exeName = 'XCOpenCodeWeb.exe';
const isPackaged = app.isPackaged;
let basePath: string;
if (isPackaged) {
basePath = path.join(process.resourcesPath, 'app.asar.unpacked');
} else {
basePath = process.cwd();
}
return path.join(basePath, 'services', 'xcopencodeweb', exeName);
}
private getExeArgs(): string[] {
return ['--port', this.port.toString()];
}
private async checkHealth(): Promise<boolean> {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), HEALTH_CHECK_TIMEOUT);
const response = await fetch(`http://127.0.0.1:${this.port}`, {
signal: controller.signal,
});
clearTimeout(timeoutId);
return response.ok || response.status === 401;
} catch (error) {
log.warn('[XCOpenCodeWebService] Health check failed:', error);
return false;
}
}
private startHealthCheck(): void {
if (this.healthCheckTimer) {
clearInterval(this.healthCheckTimer);
}
this.healthCheckTimer = setInterval(async () => {
const isHealthy = await this.checkHealth();
if (!isHealthy && this._isRunning) {
log.warn('[XCOpenCodeWebService] Service not responding');
}
}, HEALTH_CHECK_INTERVAL);
}
async start(): Promise<{ success: boolean; error?: string }> {
if (this._isRunning && this.process) {
log.info('[XCOpenCodeWebService] Already running');
return { success: true };
}
if (this._isStarting) {
log.info('[XCOpenCodeWebService] Already starting');
return { success: true };
}
this._isStarting = true;
try {
const exePath = this.getExePath();
const exeArgs = this.getExeArgs();
log.info(`[XCOpenCodeWebService] Starting from: ${exePath} with args: ${exeArgs.join(' ')}`);
this.process = spawn(exePath, exeArgs, {
stdio: 'pipe',
});
this.processPid = this.process.pid ?? null;
this.process.stdout?.on('data', (data) => {
log.info(`[XCOpenCodeWeb] ${data}`);
});
this.process.stderr?.on('data', (data) => {
log.error(`[XCOpenCodeWeb error] ${data}`);
});
this.process.on('error', (err) => {
log.error('[XCOpenCodeWebService] Process error:', err);
this._isRunning = false;
this._isStarting = false;
this.process = null;
this.processPid = null;
});
this.process.on('exit', (code) => {
log.info(`[XCOpenCodeWebService] Process exited with code ${code}`);
this._isRunning = false;
this._isStarting = false;
this.process = null;
this.processPid = null;
});
const maxWaitTime = 30000;
const checkInterval = 500;
const startTime = Date.now();
while (Date.now() - startTime < maxWaitTime) {
const isHealthy = await this.checkHealth();
if (isHealthy) {
break;
}
await new Promise<void>((resolve) => setTimeout(resolve, checkInterval));
}
const finalHealth = await this.checkHealth();
if (!finalHealth) {
log.warn('[XCOpenCodeWebService] Health check failed after max wait time');
}
this._isRunning = true;
this._isStarting = false;
this.startHealthCheck();
log.info(`[XCOpenCodeWebService] Started successfully on port ${this.port}`);
return { success: true };
} catch (error: any) {
log.error('[XCOpenCodeWebService] Failed to start:', error);
this._isRunning = false;
this._isStarting = false;
return { success: false, error: error.message };
}
}
async stop(): Promise<{ success: boolean; error?: string }> {
if (this.healthCheckTimer) {
clearInterval(this.healthCheckTimer);
this.healthCheckTimer = null;
}
if (!this.processPid) {
log.info('[XCOpenCodeWebService] Not running');
return { success: true };
}
return new Promise((resolve) => {
log.info(`[XCOpenCodeWebService] Stopping process ${this.processPid}...`);
exec(`taskkill /F /PID ${this.processPid}`, (error) => {
this.process = null;
this.processPid = null;
this._isRunning = false;
if (error) {
log.error('[XCOpenCodeWebService] Failed to stop:', error);
resolve({ success: false, error: error.message });
} else {
log.info('[XCOpenCodeWebService] Stopped');
resolve({ success: true });
}
});
});
}
}
export const xcOpenCodeWebService = new XCOpenCodeWebService();

View File

@@ -13,6 +13,8 @@ class ElectronState {
isDev: false,
}
private windows = new Map<number, BrowserWindow>()
getMainWindow(): BrowserWindow | null {
return this.state.mainWindow
}
@@ -37,7 +39,24 @@ class ElectronState {
this.state.isDev = isDev
}
addWindow(window: BrowserWindow): void {
this.windows.set(window.id, window)
}
removeWindow(id: number): void {
this.windows.delete(id)
}
getWindow(id: number): BrowserWindow | undefined {
return this.windows.get(id)
}
getAllWindows(): BrowserWindow[] {
return Array.from(this.windows.values())
}
reset(): void {
this.windows.clear()
this.state = {
mainWindow: null,
serverPort: 3001,

591
package-lock.json generated
View File

@@ -1,11 +1,11 @@
{
"name": "xcnote",
"name": "xcdesktop",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "xcnote",
"name": "xcdesktop",
"version": "0.0.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@@ -20,11 +20,15 @@
"@milkdown/preset-commonmark": "^7.18.0",
"@milkdown/preset-gfm": "^7.18.0",
"@milkdown/react": "^7.18.0",
"@opencode-ai/sdk": "^1.2.26",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-slot": "^1.2.4",
"@types/jszip": "^3.4.0",
"@types/multer": "^2.0.0",
"@types/prismjs": "^1.26.5",
"axios": "^1.13.5",
"chokidar": "^5.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cors": "^2.8.5",
"dotenv": "^17.2.1",
@@ -40,6 +44,7 @@
"react-router-dom": "^7.3.0",
"recharts": "^3.7.0",
"remark-breaks": "^4.0.0",
"tailwind-merge": "^3.5.0",
"zod": "^4.3.6",
"zustand": "^5.0.11"
},
@@ -3305,6 +3310,12 @@
"url": "https://github.com/sponsors/ocavue"
}
},
"node_modules/@opencode-ai/sdk": {
"version": "1.2.26",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.2.26.tgz",
"integrity": "sha512-HPB+0pfvTMPj2KEjNLF3oqgldKW8koTJ7ssqXwzndazqxS+gUynzvdIKIQP4+QIInNcc5nJMG9JtfLcePGgTLQ==",
"license": "MIT"
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -3323,6 +3334,419 @@
"dev": true,
"license": "MIT"
},
"node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
"license": "MIT"
},
"node_modules/@radix-ui/react-alert-dialog": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz",
"integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dialog": "1.1.15",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-context": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
"integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-focus-guards": "1.1.3",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
"integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-escape-keydown": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-guards": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
"integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-scope": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz",
"integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-id": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
"integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-portal": {
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
"integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-presence": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
"integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
"integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-effect-event": "0.0.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-effect-event": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
"integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-escape-keydown": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
"integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-callback-ref": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
@@ -4308,7 +4732,7 @@
"version": "18.3.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
@@ -5464,6 +5888,18 @@
"dev": true,
"license": "Python-2.0"
},
"node_modules/aria-hidden": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/aria-query": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
@@ -6313,6 +6749,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/class-variance-authority": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
"integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
"license": "Apache-2.0",
"dependencies": {
"clsx": "^2.1.1"
},
"funding": {
"url": "https://polar.sh/cva"
}
},
"node_modules/cli-cursor": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz",
@@ -7132,6 +7580,12 @@
"license": "MIT",
"optional": true
},
"node_modules/detect-node-es": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"license": "MIT"
},
"node_modules/devlop": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
@@ -8602,6 +9056,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-nonce": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
"integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
@@ -12680,6 +13143,53 @@
"node": ">=0.10.0"
}
},
"node_modules/react-remove-scroll": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
"integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==",
"license": "MIT",
"dependencies": {
"react-remove-scroll-bar": "^2.3.7",
"react-style-singleton": "^2.2.3",
"tslib": "^2.1.0",
"use-callback-ref": "^1.3.3",
"use-sidecar": "^1.1.3"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-remove-scroll-bar": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
"integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
"license": "MIT",
"dependencies": {
"react-style-singleton": "^2.2.2",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-router": {
"version": "7.13.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.0.tgz",
@@ -12731,6 +13241,28 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/react-style-singleton": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
"integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
"license": "MIT",
"dependencies": {
"get-nonce": "^1.0.0",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/read-binary-file-arch": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz",
@@ -13943,6 +14475,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/tailwind-merge": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz",
"integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"
}
},
"node_modules/tailwindcss": {
"version": "3.4.19",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
@@ -14901,6 +15443,49 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-callback-ref": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
"integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/use-sidecar": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
"integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
"license": "MIT",
"dependencies": {
"detect-node-es": "^1.1.0",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",

View File

@@ -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"
},
@@ -122,10 +128,12 @@
"dist-api/**/*",
"shared/**/*",
"tools/**/*",
"services/**/*",
"package.json"
],
"asarUnpack": [
"tools/**/*"
"tools/**/*",
"services/**/*"
],
"win": {
"target": "nsis"

View File

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

View File

@@ -1,77 +1,2 @@
APP_NAME = Xuanchi Git
RUN_USER = xuanchi
WORK_PATH = C:\Users\xuanchi\Desktop\remote\gitea
RUN_MODE = prod
[database]
DB_TYPE = sqlite3
HOST = 127.0.0.1:5432
NAME = gitea
USER = gitea
PASSWD =
SCHEMA =
SSL_MODE = disable
PATH = C:\Users\xuanchi\Desktop\remote\gitea\data\gitea.db
LOG_SQL = false
[repository]
ROOT = C:/Users/xuanchi/Desktop/remote/gitea/data/gitea-repositories
[server]
SSH_DOMAIN = localhost
DOMAIN = localhost
HTTP_PORT = 3001
ROOT_URL = http://localhost:3001/
APP_DATA_PATH = C:\Users\xuanchi\Desktop\remote\gitea\data
DISABLE_SSH = false
SSH_PORT = 22
LFS_START_SERVER = true
LFS_JWT_SECRET = R7kPD0XqG0zTeGhLu1r8t4h-Y3FfofYW1_6GPhNnVZg
OFFLINE_MODE = true
[lfs]
PATH = C:/Users/xuanchi/Desktop/remote/gitea/data/lfs
[mailer]
ENABLED = false
[service]
REGISTER_EMAIL_CONFIRM = false
ENABLE_NOTIFY_MAIL = false
DISABLE_REGISTRATION = false
ALLOW_ONLY_EXTERNAL_REGISTRATION = false
ENABLE_CAPTCHA = false
REQUIRE_SIGNIN_VIEW = false
DEFAULT_KEEP_EMAIL_PRIVATE = false
DEFAULT_ALLOW_CREATE_ORGANIZATION = true
DEFAULT_ENABLE_TIMETRACKING = true
NO_REPLY_ADDRESS = noreply.localhost
[openid]
ENABLE_OPENID_SIGNIN = true
ENABLE_OPENID_SIGNUP = true
[cron.update_checker]
ENABLED = false
[session]
PROVIDER = file
[log]
MODE = console
LEVEL = info
ROOT_PATH = C:/Users/xuanchi/Desktop/remote/gitea/log
[repository.pull-request]
DEFAULT_MERGE_STYLE = merge
[repository.signing]
DEFAULT_TRUST_MODEL = committer
[security]
INSTALL_LOCK = true
INTERNAL_TOKEN = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE3NzI2Mzc5MjN9.PnM7mBbYjj8dz5dHYDwncvicVHOllHb0cWM9lSUdqIM
PASSWORD_HASH_ALGO = pbkdf2
[oauth2]
JWT_SECRET = aLiiycJwXwTxX9ZaE2By40OGAxkVcPsOwz-WzJWHfzA

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

View File

@@ -1,23 +0,0 @@
[diff]
algorithm = histogram
[core]
logallrefupdates = true
quotePath = false
commitGraph = true
longpaths = true
[gc]
reflogexpire = 90
writeCommitGraph = true
[user]
name = Gitea
email = gitea@fake.local
[receive]
advertisePushOptions = true
procReceiveRefs = refs/for
[fetch]
writeCommitGraph = true
[safe]
directory = *
[uploadpack]
allowfilter = true
allowAnySHA1InWant = true

View File

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

View File

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

View File

@@ -1,52 +0,0 @@
-----BEGIN PRIVATE KEY-----
MIIJQQIBADANBgkqhkiG9w0BAQEFAASCCSswggknAgEAAoICAQDE959xE4ZOnhPD
4iDxlW5dVyXkzpbqADw9+N1D8vzCpvG2937zuQsMQ5BL1Qhzb6fQ8+Mo77TnDFLQ
zXSW2r/zs1QNHZeCuPN9etrlkNKQT6zLFXobRXDeHf/CDvI2l6gJI1s9sMHgurm2
XBRefVFqJUkeR3CLw2vNn09FxnwGQoTMyUejuA+n6ITII/0RDEgXOdZwKDOEXZom
N7J25YPhsYZ1BGj38tysb/gWNhsFrxVCHPOGiuDll5x5fGMRmb436PvvWktX0Yfg
4NTa6Re5IHpVCSAQR64HD8j0FsGsMDvXhrja5mdRWWjgnbuprtr/f8ei2AEj8CCe
mh7OKb6y1pryLq12hins4cw3PdzS/3+I0VSa5uUpdvZRymjMLJr9tD2UdfJWr6lB
Jp0U0wQ0vBl08kHsTIBOPGJlFtSZtwzP8+C2eAEw20C15qh7sE/NkbS5oyV0XZfB
gtVB0W+FCJNfC5HRuBl3x0Ei0jpFG/QncuHXXripgF/bNcDp+K5iGyJAWMSb9aJp
3lmijC99FGPMLiA4j5QkcR82yIWEVlrgYVEnP9dqaBEt4Rs2Gl1618uj0zdNq6Nj
DyBB6zihf0PnBjvGSG2uqQKz2236MvhxYmPn8vxDuvq7/xdax0So+OGuptbHXhDR
aLNqYCw24Vqg2sXhiuHbdDMrGQUlgQIDAQABAoICAFtlM3y6vJV6UF2Sbgrrddyl
9ZVoLyrBlTKEadd/xr1jzcFCsovRD0lPiINHhLZb1xjkMkHqiJy1YTA2RaVCN9OT
IKs9UfJ8c6+D9FKVkr8X2WwauSAyZp8KeITJfqbKVFR5LXtAq5XlzwrJS0JVEBQa
3QTJzXVs5nbxN01/PkmBUDHeK/nSDKGzCPn9iQ4CDumIEvLUFKOU81RMf+kfssRl
JajitPponPD+u7VCweMvTMPyvyVtB9JBOYA9sZKXLmavG0gDM/a56Tz11o8FndZv
NZSDuXcUa7InJu3sKU4Gy53Ei743LzXWMQ4Y4t1nn4Ly/eWYKV9NqzUs+qJbSHrO
cmFqYr6ypG86TbGk1ji3eDRjmvsUYM/lTNurAh9BPzGz0mTlrUGXc+OPmAU1LD6T
EeArMLHVxunON27T6ytHUV4VDbd8oa1KzsdiKEW71/WsK09kFLkOcGVR30OzLni5
pQRxUukVX7twcU4hmrheSNB+CMtxACfHRxPalczVCkX/xp2jxwfPGjqr4aSQqQfK
+hMDy5/jc6PrhPd7RSadWPVnFYJaYofWXgi/uU+26jrRmNswetNdvPv9zu5nS1Hs
VFK8Q5zgqsdG+QLEyNWtnz5fwkNMx49cB154V8AR1ZI6XLgbRUL069gNpvHzLl/N
eTb9MKj2d1/b6f3XZxEpAoIBAQDz7tEI0YC1Q5M0EQC0j8zvZy5FLP4NtwE9ZbHt
NsjsHC5xIb3ujhtIje66X/5cnwGy0Bh2QbMDeFXWKlHcw+CiFe1/13kLLUoGetMN
yHXLPCVx3iYNIopZgG8Db7qM7rYkmgVyE6VWyyMzVrFbO/rvtX8BK9jjrDVNRzsF
g1MvGpqSPZu4O3j5BK13U0+eBhTQIvVijvUVCc50ftQHXjhwGfvUsXoggemG0Q89
UpcKHvsAZbPOtP16HHcTuWk1SyF4qeVN+z1O+gOqebXW8qdzIBsDE2kUcq9bube5
5dJ8CknbKNYc/dTIMEdKu2zXDK57eL8OUZcT3csuI001d7JfAoIBAQDOtgfRub07
xNAeH9zK/4/5Smj2D+Ja6C6a37s5fsS1m1KhG0JvD7f4dhABxIf7PY0mvyxSpJt9
LzyYPzR8B/DoaM5L3aDxcIhsp7JQ+STj1V2SKsNW8M4kRTiEPZh81jzZYtBCxe1o
sLu3lhICusrLtfSSbix2ijMdPDEe1aDMT/P3TVrHUk2u5fMrvZHDB6VqvidXqLyG
R4ifF4eblfbWPtlnNIh4V2vCX4+dfH25oyaO/KKTMuV+0h2+h6AjTOcV2ntXlyZK
/oUDWRPbM6m019DEBoG9VBawyY6aGTM44RHjKBBz6itj938TlseF2qm4BPctfIF+
gVvKqwSM/PQfAoIBAHCEmlts9+ek0gPUS/T919QeThOOm2mMHsBJZnc7LBbtMOby
X3/ogOFIxvOlT9k9ZzUqE/6Ic6CII1/0iWpB2B4r6y9rHuRu8ZRnl27mJp+mkMcj
Z33rjtGWEp8NLInRmqbrfNOQCFYuwX/u30RsOGXV3E2YAiWSy8tnrevvFbHGncIT
NP8YP8btx24hObp0p6kSVwotUxNvQJIv5nG3nmTnN2h2rRTNmACd8l+g9xauD62x
O+1/QuTOuIaaodL5YukbxS/hUfhaDtLV7XDG4UKTbqJOk8vg0s6Grh7Lyfl5bXPw
NEOPOlVVH61zItNXgCxoGAjszblWN2CC3BxrqBUCggEASt9QMbzvOAjvwRmVZcnv
okI7hnT2bisPRnWnYQnzwjwCT+yJwaSV5F8PKTTAdFY1HEW+jiilUVCcyCCMqChQ
MD5WCtC6DPnP0FtlkULNA+EyxVDL9F/Aqw6PjAarhvwqiirqeGUsuvDY7YRj/a0e
6256qddSL5WbMgmtWRfT6G1FVtwj93JuRN1xmPRPKa9JUUKTCYNK1fBvIgDp04cc
IzockO9MRxqTI5JteIOxHl5kBwKm+F5FFgyRTYPekyq1wQqkBnPvINbT4wSO1qT9
4U0Shw48TBF7LomzJ0ndbcrIKdlHLFUzZkAtPTEuD+PF+auCxG0GkoXUc7JCMbcl
zQKCAQBuyk/G/9G458QPfvp4if0TNMPuX+yzcmjEcOf4x2vgTA1t6me4nPm4xEF2
TUO/hECYiNG1DyeN/rRdW6lce9CZMaP9D1Dc1g4fAGfxBBhrBvV7o4h0dffpOTNh
wyKx08C8rmImmvlt3aWyH+2sTe43GScbchSkEid8M/0RpaKp4b61zBhGUgYspKNS
QL+7dTq9iosBTaR+zWKBv6cfOahxXFdVQYkh1woDCLdWX5FCKjV/gD46UFEtMTYf
ABcF+5u2Pbxsf49gUN1wiJBx0o5+81IB34lIscp8wA+3ZRUeDnAbaEPTeoVIP+Of
ohu0miobcXoUkKle/P8TFqStcnW6
-----END PRIVATE KEY-----

View File

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

View File

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

View File

@@ -1,223 +0,0 @@
=============== Mar 4, 2026 (CST) ===============
23:25:27.295431 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
23:25:27.296428 db@open opening
23:25:27.297425 version@stat F·[] S·0B[] Sc·[]
23:25:27.297425 db@janitor F·2 G·0
23:25:27.297425 db@open done T·997.1µs
=============== Mar 5, 2026 (CST) ===============
00:58:48.117008 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
00:58:48.118039 version@stat F·[] S·0B[] Sc·[]
00:58:48.118039 db@open opening
00:58:48.118039 journal@recovery F·1
00:58:48.118039 journal@recovery recovering @1
00:58:48.119011 memdb@flush created L0@2 N·28 S·574B "act..igh,v28":"web..low,v19"
00:58:48.120008 version@stat F·[1] S·574B[574B] Sc·[0.25]
00:58:48.122002 db@janitor F·3 G·0
00:58:48.122002 db@open done T·3.9631ms
=============== Mar 5, 2026 (CST) ===============
16:44:08.552284 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
16:44:08.552284 version@stat F·[1] S·574B[574B] Sc·[0.25]
16:44:08.552284 db@open opening
16:44:08.552284 journal@recovery F·1
16:44:08.552284 journal@recovery recovering @3
16:44:08.553282 version@stat F·[1] S·574B[574B] Sc·[0.25]
16:44:08.555630 db@janitor F·3 G·0
16:44:08.555630 db@open done T·3.3467ms
=============== Mar 5, 2026 (CST) ===============
16:58:44.704290 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
16:58:44.704290 version@stat F·[1] S·574B[574B] Sc·[0.25]
16:58:44.704290 db@open opening
16:58:44.704290 journal@recovery F·1
16:58:44.704290 journal@recovery recovering @5
16:58:44.705289 version@stat F·[1] S·574B[574B] Sc·[0.25]
16:58:44.709300 db@janitor F·3 G·0
16:58:44.709300 db@open done T·5.0092ms
=============== Mar 5, 2026 (CST) ===============
16:58:55.013041 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
16:58:55.014038 version@stat F·[1] S·574B[574B] Sc·[0.25]
16:58:55.014038 db@open opening
16:58:55.014038 journal@recovery F·1
16:58:55.014038 journal@recovery recovering @7
16:58:55.014038 version@stat F·[1] S·574B[574B] Sc·[0.25]
16:58:55.019025 db@janitor F·3 G·0
16:58:55.019025 db@open done T·4.9869ms
=============== Mar 5, 2026 (CST) ===============
17:01:48.341808 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
17:01:48.341808 version@stat F·[1] S·574B[574B] Sc·[0.25]
17:01:48.341808 db@open opening
17:01:48.341808 journal@recovery F·1
17:01:48.341808 journal@recovery recovering @9
17:01:48.342805 version@stat F·[1] S·574B[574B] Sc·[0.25]
17:01:48.346766 db@janitor F·3 G·0
17:01:48.346766 db@open done T·4.9576ms
=============== Mar 5, 2026 (CST) ===============
17:05:22.244372 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
17:05:22.245370 version@stat F·[1] S·574B[574B] Sc·[0.25]
17:05:22.245370 db@open opening
17:05:22.245370 journal@recovery F·1
17:05:22.245370 journal@recovery recovering @11
17:05:22.245370 version@stat F·[1] S·574B[574B] Sc·[0.25]
17:05:22.250356 db@janitor F·3 G·0
17:05:22.250356 db@open done T·4.9865ms
=============== Mar 5, 2026 (CST) ===============
17:15:26.696181 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
17:15:26.696181 version@stat F·[1] S·574B[574B] Sc·[0.25]
17:15:26.696181 db@open opening
17:15:26.696181 journal@recovery F·1
17:15:26.696181 journal@recovery recovering @13
17:15:26.696181 version@stat F·[1] S·574B[574B] Sc·[0.25]
17:15:26.701109 db@janitor F·3 G·0
17:15:26.701109 db@open done T·4.9277ms
=============== Mar 5, 2026 (CST) ===============
17:42:26.549391 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
17:42:26.550343 version@stat F·[1] S·574B[574B] Sc·[0.25]
17:42:26.550343 db@open opening
17:42:26.550343 journal@recovery F·1
17:42:26.550343 journal@recovery recovering @15
17:42:26.552338 version@stat F·[1] S·574B[574B] Sc·[0.25]
17:42:26.554337 db@janitor F·3 G·0
17:42:26.554337 db@open done T·3.9934ms
=============== Mar 5, 2026 (CST) ===============
17:53:41.022244 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
17:53:41.022244 version@stat F·[1] S·574B[574B] Sc·[0.25]
17:53:41.022244 db@open opening
17:53:41.022244 journal@recovery F·1
17:53:41.022244 journal@recovery recovering @17
17:53:41.023231 version@stat F·[1] S·574B[574B] Sc·[0.25]
17:53:41.026274 db@janitor F·3 G·0
17:53:41.026274 db@open done T·4.0304ms
=============== Mar 5, 2026 (CST) ===============
18:06:44.931577 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
18:06:44.932090 version@stat F·[1] S·574B[574B] Sc·[0.25]
18:06:44.932090 db@open opening
18:06:44.932090 journal@recovery F·1
18:06:44.932602 journal@recovery recovering @19
18:06:44.933113 version@stat F·[1] S·574B[574B] Sc·[0.25]
18:06:44.935926 db@janitor F·3 G·0
18:06:44.935926 db@open done T·3.8362ms
=============== Mar 5, 2026 (CST) ===============
18:17:10.357320 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
18:17:10.357320 version@stat F·[1] S·574B[574B] Sc·[0.25]
18:17:10.357833 db@open opening
18:17:10.357841 journal@recovery F·1
18:17:10.357841 journal@recovery recovering @21
18:17:10.357841 version@stat F·[1] S·574B[574B] Sc·[0.25]
18:17:10.361450 db@janitor F·3 G·0
18:17:10.361450 db@open done T·3.6088ms
=============== Mar 5, 2026 (CST) ===============
18:20:47.461717 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
18:20:47.462247 version@stat F·[1] S·574B[574B] Sc·[0.25]
18:20:47.462247 db@open opening
18:20:47.462247 journal@recovery F·1
18:20:47.462247 journal@recovery recovering @23
18:20:47.464218 version@stat F·[1] S·574B[574B] Sc·[0.25]
18:20:47.466420 db@janitor F·3 G·0
18:20:47.466420 db@open done T·4.1729ms
=============== Mar 5, 2026 (CST) ===============
18:24:07.472215 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
18:24:07.473212 version@stat F·[1] S·574B[574B] Sc·[0.25]
18:24:07.473212 db@open opening
18:24:07.473212 journal@recovery F·1
18:24:07.473212 journal@recovery recovering @25
18:24:07.473212 version@stat F·[1] S·574B[574B] Sc·[0.25]
18:24:07.476910 db@janitor F·3 G·0
18:24:07.476910 db@open done T·3.6976ms
=============== Mar 5, 2026 (CST) ===============
18:41:46.328705 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
18:41:46.329704 version@stat F·[1] S·574B[574B] Sc·[0.25]
18:41:46.329704 db@open opening
18:41:46.329704 journal@recovery F·1
18:41:46.329704 journal@recovery recovering @27
18:41:46.329704 version@stat F·[1] S·574B[574B] Sc·[0.25]
18:41:46.334694 db@janitor F·3 G·0
18:41:46.334694 db@open done T·4.9906ms
=============== Mar 5, 2026 (CST) ===============
20:17:07.605654 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
20:17:07.606653 version@stat F·[1] S·574B[574B] Sc·[0.25]
20:17:07.606653 db@open opening
20:17:07.606653 journal@recovery F·1
20:17:07.606653 journal@recovery recovering @29
20:17:07.606653 version@stat F·[1] S·574B[574B] Sc·[0.25]
20:17:07.609645 db@janitor F·3 G·0
20:17:07.609645 db@open done T·2.9923ms
=============== Mar 5, 2026 (CST) ===============
20:18:45.285721 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
20:18:45.286227 version@stat F·[1] S·574B[574B] Sc·[0.25]
20:18:45.286227 db@open opening
20:18:45.286227 journal@recovery F·1
20:18:45.286227 journal@recovery recovering @31
20:18:45.286227 version@stat F·[1] S·574B[574B] Sc·[0.25]
20:18:45.290050 db@janitor F·3 G·0
20:18:45.290050 db@open done T·3.823ms
=============== Mar 5, 2026 (CST) ===============
20:24:14.214040 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
20:24:14.215038 version@stat F·[1] S·574B[574B] Sc·[0.25]
20:24:14.215038 db@open opening
20:24:14.215038 journal@recovery F·1
20:24:14.217038 journal@recovery recovering @33
20:24:14.217038 version@stat F·[1] S·574B[574B] Sc·[0.25]
20:24:14.221083 db@janitor F·3 G·0
20:24:14.221083 db@open done T·6.0448ms
=============== Mar 5, 2026 (CST) ===============
20:27:37.450756 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
20:27:37.451263 version@stat F·[1] S·574B[574B] Sc·[0.25]
20:27:37.451263 db@open opening
20:27:37.451263 journal@recovery F·1
20:27:37.451263 journal@recovery recovering @35
20:27:37.451263 version@stat F·[1] S·574B[574B] Sc·[0.25]
20:27:37.454842 db@janitor F·3 G·0
20:27:37.454842 db@open done T·3.579ms
=============== Mar 5, 2026 (CST) ===============
20:30:19.881992 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
20:30:19.881992 version@stat F·[1] S·574B[574B] Sc·[0.25]
20:30:19.881992 db@open opening
20:30:19.881992 journal@recovery F·1
20:30:19.881992 journal@recovery recovering @37
20:30:19.882990 version@stat F·[1] S·574B[574B] Sc·[0.25]
20:30:19.884984 db@janitor F·3 G·0
20:30:19.884984 db@open done T·2.9921ms
=============== Mar 5, 2026 (CST) ===============
20:33:21.085745 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
20:33:21.086742 version@stat F·[1] S·574B[574B] Sc·[0.25]
20:33:21.086742 db@open opening
20:33:21.086742 journal@recovery F·1
20:33:21.086742 journal@recovery recovering @39
20:33:21.088251 version@stat F·[1] S·574B[574B] Sc·[0.25]
20:33:21.093539 db@janitor F·3 G·0
20:33:21.093539 db@open done T·6.7975ms
=============== Mar 5, 2026 (CST) ===============
20:35:21.489745 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
20:35:21.490699 version@stat F·[1] S·574B[574B] Sc·[0.25]
20:35:21.490699 db@open opening
20:35:21.490699 journal@recovery F·1
20:35:21.490699 journal@recovery recovering @41
20:35:21.491220 version@stat F·[1] S·574B[574B] Sc·[0.25]
20:35:21.495194 db@janitor F·3 G·0
20:35:21.495194 db@open done T·4.4953ms
=============== Mar 5, 2026 (CST) ===============
20:42:06.364290 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
20:42:06.364290 version@stat F·[1] S·574B[574B] Sc·[0.25]
20:42:06.364290 db@open opening
20:42:06.364796 journal@recovery F·1
20:42:06.364796 journal@recovery recovering @43
20:42:06.364796 version@stat F·[1] S·574B[574B] Sc·[0.25]
20:42:06.369434 db@janitor F·3 G·0
20:42:06.369434 db@open done T·5.1446ms
=============== Mar 5, 2026 (CST) ===============
20:46:58.679386 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
20:46:58.679386 version@stat F·[1] S·574B[574B] Sc·[0.25]
20:46:58.679386 db@open opening
20:46:58.679386 journal@recovery F·1
20:46:58.679386 journal@recovery recovering @45
20:46:58.680383 version@stat F·[1] S·574B[574B] Sc·[0.25]
20:46:58.683452 db@janitor F·3 G·0
20:46:58.683452 db@open done T·4.0659ms
=============== Mar 5, 2026 (CST) ===============
20:49:54.485040 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
20:49:54.485040 version@stat F·[1] S·574B[574B] Sc·[0.25]
20:49:54.485040 db@open opening
20:49:54.485040 journal@recovery F·1
20:49:54.485040 journal@recovery recovering @47
20:49:54.486036 version@stat F·[1] S·574B[574B] Sc·[0.25]
20:49:54.488644 db@janitor F·3 G·0
20:49:54.488644 db@open done T·3.6037ms

View File

@@ -118,13 +118,13 @@ class App {
});
});
this.container.register('openCodeService', (c) => {
const OpenCodeService = require('../services/opencode/OpenCodeService');
this.container.register('xcOpenCodeWebService', (c) => {
const XCOpenCodeWebService = require('../services/opencode/XCOpenCodeWebService');
const config = c.resolve('config');
const opencodeConfig = config.getSection('opencode') || {};
return new OpenCodeService({
enabled: opencodeConfig.enabled !== false,
port: opencodeConfig.port || 3002
const xcopencodewebConfig = config.getSection('xcopencodeweb') || {};
return new XCOpenCodeWebService({
enabled: xcopencodewebConfig.enabled !== false,
port: xcopencodewebConfig.port
});
});
@@ -202,9 +202,9 @@ class App {
frpService.start();
logger.info('FRP service started');
const openCodeService = this.container.resolve('openCodeService');
openCodeService.start();
logger.info('OpenCode service started');
const xcOpenCodeWebService = this.container.resolve('xcOpenCodeWebService');
xcOpenCodeWebService.start();
logger.info('XCOpenCodeWeb service started');
const giteaService = this.container.resolve('giteaService');
giteaService.start();
@@ -484,9 +484,9 @@ class App {
frpService.stop();
logger.info('FRP service stopped');
const openCodeService = this.container.resolve('openCodeService');
openCodeService.stop();
logger.info('OpenCode service stopped');
const xcOpenCodeWebService = this.container.resolve('xcOpenCodeWebService');
xcOpenCodeWebService.stop();
logger.info('XCOpenCodeWeb service stopped');
const giteaService = this.container.resolve('giteaService');
giteaService.stop();

View File

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

View File

@@ -7,13 +7,21 @@ class OpenCodeService {
constructor(options = {}) {
this.process = null;
this.isRunning = false;
this.port = options.port || 3002;
this.port = options.port;
this.enabled = options.enabled !== false;
try {
const result = execSync('where opencode', { encoding: 'utf8', windowsHide: true });
const firstLine = result.split('\n')[0].trim();
this.opencodePath = firstLine.endsWith('.cmd') ? firstLine : firstLine + '.cmd';
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;
}
@@ -42,17 +50,17 @@ class OpenCodeService {
}
try {
logger.info('Starting OpenCode service', { port: this.port, opencodePath: this.opencodePath });
logger.info('Starting OpenCode service', { port: this.port || 'default', opencodePath: this.opencodePath });
const env = {
...process.env,
OPENCODE_SERVER_PASSWORD: password
};
this.process = spawn('powershell.exe', [
'-NoProfile',
'-Command',
`& '${this.opencodePath}' serve --port ${this.port}`
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,
@@ -62,14 +70,14 @@ class OpenCodeService {
this.isRunning = true;
this.process.stdout.on('data', (data) => {
const output = data.toString().trim();
const output = data.toString('utf8').trim();
if (output) {
logger.info(`[OpenCode] ${output}`);
}
});
this.process.stderr.on('data', (data) => {
const output = data.toString().trim();
const output = data.toString('utf8').trim();
if (output) {
logger.error(`[OpenCode] ${output}`);
}
@@ -113,7 +121,7 @@ class OpenCodeService {
getStatus() {
return {
running: this.isRunning,
port: this.port
port: this.port || null
};
}
}

View File

@@ -0,0 +1,149 @@
const { spawn } = require('child_process');
const path = require('path');
const logger = require('../../utils/logger');
class XCOpenCodeWebService {
constructor(options = {}) {
this.process = null;
this.isRunning = false;
this.port = options.port || 9999;
this.enabled = options.enabled !== false;
this.healthCheckInterval = 10000;
this.healthCheckTimeout = 2000;
this.healthCheckTimer = null;
}
getExePath() {
const exeName = 'XCOpenCodeWeb.exe';
const basePath = path.join(__dirname, '../../../xcopencodeweb');
return path.join(basePath, exeName);
}
getExeArgs() {
return ['--port', this.port.toString()];
}
async checkHealth() {
try {
const timeoutMs = this.healthCheckTimeout;
const fetchPromise = fetch(`http://127.0.0.1:${this.port}`);
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), timeoutMs)
);
const response = await Promise.race([fetchPromise, timeoutPromise]);
return response.ok || response.status === 401;
} catch (error) {
logger.warn('[XCOpenCodeWebService] Health check failed:', error.message);
return false;
}
}
startHealthCheck() {
if (this.healthCheckTimer) {
clearInterval(this.healthCheckTimer);
}
this.healthCheckTimer = setInterval(async () => {
const isHealthy = await this.checkHealth();
if (!isHealthy && this.isRunning) {
logger.warn('[XCOpenCodeWebService] Service not responding');
}
}, this.healthCheckInterval);
}
stopHealthCheck() {
if (this.healthCheckTimer) {
clearInterval(this.healthCheckTimer);
this.healthCheckTimer = null;
}
}
start() {
if (!this.enabled) {
logger.info('XCOpenCodeWeb service is disabled');
return;
}
if (this.isRunning && this.process) {
logger.warn('XCOpenCodeWeb service is already running');
return;
}
try {
const exePath = this.getExePath();
const exeArgs = this.getExeArgs();
logger.info(`[XCOpenCodeWebService] Starting from: ${exePath} with args: ${exeArgs.join(' ')}`);
this.process = spawn(exePath, exeArgs, {
stdio: ['ignore', 'pipe', 'pipe'],
detached: false,
});
this.process.stdout.on('data', (data) => {
const output = data.toString().trim();
if (output) {
logger.info(`[XCOpenCodeWeb] ${output}`);
}
});
this.process.stderr.on('data', (data) => {
const output = data.toString().trim();
if (output) {
logger.error(`[XCOpenCodeWeb error] ${output}`);
}
});
this.process.on('error', (error) => {
logger.error('[XCOpenCodeWebService] Process error', { error: error.message });
this.isRunning = false;
this.process = null;
});
this.process.on('exit', (code) => {
logger.info('[XCOpenCodeWebService] Process exited', { code });
this.isRunning = false;
this.process = null;
});
this.isRunning = true;
this.startHealthCheck();
logger.info(`[XCOpenCodeWebService] Started successfully on port ${this.port}`);
} catch (error) {
logger.error('[XCOpenCodeWebService] Failed to start', { error: error.message });
this.isRunning = false;
}
}
stop() {
this.stopHealthCheck();
if (!this.process) {
logger.info('[XCOpenCodeWebService] Not running');
return;
}
try {
logger.info('[XCOpenCodeWebService] Stopping...');
this.process.kill();
this.process = null;
this.isRunning = false;
logger.info('[XCOpenCodeWebService] Stopped');
} catch (error) {
logger.error('[XCOpenCodeWebService] Failed to stop', { error: error.message });
}
}
getStatus() {
return {
running: this.isRunning,
port: this.port
};
}
}
module.exports = XCOpenCodeWebService;

View File

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

View File

@@ -0,0 +1,99 @@
# XCOpenCodeWeb
XCOpenCodeWeb 是一个基于 Web 的 AI 编程助手界面,用于与 OpenCode 服务器交互。
## 快速开始
### 使用单文件 exe推荐
直接下载 `XCOpenCodeWeb.exe`,双击运行:
```bash
# 默认端口 3000
XCOpenCodeWeb.exe
# 指定端口
XCOpenCodeWeb.exe --port 8080
# 查看帮助
XCOpenCodeWeb.exe --help
```
启动后访问 http://localhost:3000
### 从源码运行
需要安装 [Bun](https://bun.sh) 或 Node.js 20+
```bash
# 安装依赖
bun install
# 构建前端
bun run build
# 启动服务器
bun server/index.js --port 3000
```
## 构建单文件 exe
```bash
cd web
bun run build:exe
```
输出:`web/XCOpenCodeWeb.exe`(约 320MB
详细文档:[docs/single-file-exe-build.md](docs/single-file-exe-build.md)
## 项目结构
```
├── ui/ # 前端组件库
├── web/
│ ├── src/ # 前端源码
│ ├── server/ # 后端服务器
│ ├── bin/ # CLI 工具
│ └── dist/ # 构建输出
├── docs/ # 文档
└── AGENTS.md # AI Agent 参考文档
```
## 常用命令
```bash
# 开发模式
bun run dev # 前端热更新
bun run dev:server # 启动开发服务器
# 构建
bun run build # 构建前端
bun run build:exe # 构建单文件 exe
# 代码检查
bun run type-check:web # TypeScript 类型检查
bun run lint:web # ESLint 检查
```
## 依赖
- [Bun](https://bun.sh) - 运行时和打包工具
- [React](https://react.dev) - 前端框架
- [Express](https://expressjs.com) - 后端服务器
- [Tailwind CSS](https://tailwindcss.com) - 样式框架
## 配置
### 环境变量
| 变量 | 说明 |
|------|------|
| `XCOpenCodeWeb_PORT` | 服务器端口(默认 3000 |
| `OPENCODE_HOST` | 外部 OpenCode 服务器地址 |
| `OPENCODE_PORT` | 外部 OpenCode 端口 |
| `OPENCODE_SKIP_START` | 跳过启动 OpenCode |
## 许可证
MIT

View File

@@ -0,0 +1,99 @@
# XCOpenCodeWeb
XCOpenCodeWeb 是一个基于 Web 的 AI 编程助手界面,用于与 OpenCode 服务器交互。
## 快速开始
### 使用单文件 exe推荐
直接下载 `XCOpenCodeWeb.exe`,双击运行:
```bash
# 默认端口 3000
XCOpenCodeWeb.exe
# 指定端口
XCOpenCodeWeb.exe --port 8080
# 查看帮助
XCOpenCodeWeb.exe --help
```
启动后访问 http://localhost:3000
### 从源码运行
需要安装 [Bun](https://bun.sh) 或 Node.js 20+
```bash
# 安装依赖
bun install
# 构建前端
bun run build
# 启动服务器
bun server/index.js --port 3000
```
## 构建单文件 exe
```bash
cd web
bun run build:exe
```
输出:`web/XCOpenCodeWeb.exe`(约 320MB
详细文档:[docs/single-file-exe-build.md](docs/single-file-exe-build.md)
## 项目结构
```
├── ui/ # 前端组件库
├── web/
│ ├── src/ # 前端源码
│ ├── server/ # 后端服务器
│ ├── bin/ # CLI 工具
│ └── dist/ # 构建输出
├── docs/ # 文档
└── AGENTS.md # AI Agent 参考文档
```
## 常用命令
```bash
# 开发模式
bun run dev # 前端热更新
bun run dev:server # 启动开发服务器
# 构建
bun run build # 构建前端
bun run build:exe # 构建单文件 exe
# 代码检查
bun run type-check:web # TypeScript 类型检查
bun run lint:web # ESLint 检查
```
## 依赖
- [Bun](https://bun.sh) - 运行时和打包工具
- [React](https://react.dev) - 前端框架
- [Express](https://expressjs.com) - 后端服务器
- [Tailwind CSS](https://tailwindcss.com) - 样式框架
## 配置
### 环境变量
| 变量 | 说明 |
|------|------|
| `XCOpenCodeWeb_PORT` | 服务器端口(默认 3000 |
| `OPENCODE_HOST` | 外部 OpenCode 服务器地址 |
| `OPENCODE_PORT` | 外部 OpenCode 端口 |
| `OPENCODE_SKIP_START` | 跳过启动 OpenCode |
## 许可证
MIT

Binary file not shown.

BIN
services/xcsdd/XCSDD.exe Normal file

Binary file not shown.

View File

@@ -0,0 +1,12 @@
import { defineModule } from '../types.js'
export const OPENCODE_MODULE = defineModule({
id: 'opencode',
name: 'OpenCode',
basePath: '/opencode',
order: 15,
version: '1.0.0',
backend: {
enabled: true,
},
})

View File

@@ -0,0 +1,12 @@
import { defineModule } from '../types.js'
export const SDD_MODULE = defineModule({
id: 'sdd',
name: 'SDD',
basePath: '/sdd',
order: 16,
version: '1.0.0',
backend: {
enabled: false,
},
})

View File

@@ -0,0 +1,12 @@
import { defineModule } from '../types.js'
export const TERMINAL_MODULE = defineModule({
id: 'terminal',
name: 'Terminal',
basePath: '/terminal',
order: 17,
version: '1.0.0',
backend: {
enabled: false,
},
})

View File

@@ -0,0 +1,13 @@
export const VOICE_MODULE = 'voice' as const
export interface VoiceModule {
id: typeof VOICE_MODULE
name: '语音'
icon: 'mic'
}
export const voiceModule: VoiceModule = {
id: VOICE_MODULE,
name: '语音',
icon: 'mic',
}

View File

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

View File

@@ -0,0 +1,290 @@
import { useRef, useState, useCallback, useEffect } from 'react'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from './ui/AlertDialog'
import { Messages } from './Messages'
import { MultimodalInput } from './MultimodalInput'
import { generateUUID } from '@/lib/utils'
import { Eraser } from 'lucide-react'
import { useDragStore } from '@/stores/dragStore'
import { getSettingsConfig } from '@/lib/api'
type Status = 'submitted' | 'streaming' | 'ready' | 'error'
interface Attachment {
url: string
name: string
contentType: string
}
interface ChatMessage {
id: string
role: 'user' | 'assistant' | 'system'
content: string
parts: Array<{ type: string; text?: string }>
createdAt?: Date
}
const OPENCODE_API = '/api/opencode'
const INITIAL_MESSAGE_LIMIT = 20
export function Chat({
id,
initialMessages,
onClear,
}: {
id: string
initialMessages: ChatMessage[]
onClear?: () => void
}) {
const messagesEndRef = useRef<HTMLDivElement>(null)
const [messages, setMessages] = useState<ChatMessage[]>(initialMessages)
const [hasMoreMessages, setHasMoreMessages] = useState(false)
const [input, setInput] = useState<string>('')
const [showCreditCardAlert, setShowCreditCardAlert] = useState(false)
const [status, setStatus] = useState<Status>('ready')
const [attachments, setAttachments] = useState<Attachment[]>([])
const [showInputAtBottom, setShowInputAtBottom] = useState(false)
const [notebookRoot, setNotebookRoot] = useState<string>('')
const { state: dragState } = useDragStore()
useEffect(() => {
getSettingsConfig().then(config => {
// 统一 notebookRoot 的斜杠
const normalizedRoot = (config.notebookRoot || '').replace(/\\/g, '/')
setNotebookRoot(normalizedRoot)
}).catch(e => {
console.error('Failed to get notebook root:', e)
})
}, [])
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
// 优先从 dragStore 获取
let { draggedPath, draggedType } = dragState
// 如果 dragStore 为空,尝试从 dataTransfer 获取
if (!draggedPath) {
draggedPath = e.dataTransfer.getData('text/plain')
}
if (draggedPath) {
// 统一路径中的斜杠,并拼接完整路径,用【】包围
const normalizedPath = draggedPath.replace(/\\/g, '/')
const fullPath = notebookRoot ? `${notebookRoot}/${normalizedPath}` : normalizedPath
const pathWithBrackets = `${fullPath}`
setInput(prev => prev + pathWithBrackets)
// 延迟聚焦输入框
setTimeout(() => {
const textarea = document.querySelector('textarea') as HTMLTextAreaElement
textarea?.focus()
}, 0)
}
}, [dragState, notebookRoot])
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
}, [])
const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'auto' })
}, [])
const loadMessages = useCallback(async () => {
try {
const res = await fetch(OPENCODE_API, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'get-messages',
sessionId: id,
limit: INITIAL_MESSAGE_LIMIT
}),
})
if (res.ok) {
const data = await res.json()
const newMessages = data.messages || []
setMessages(newMessages)
setHasMoreMessages(data.hasMore ?? newMessages.length >= INITIAL_MESSAGE_LIMIT)
setTimeout(scrollToBottom, 100)
}
} catch (error) {
console.error('Failed to load messages:', error)
}
}, [id, scrollToBottom])
const loadMoreMessages = useCallback(async () => {
if (messages.length === 0) return
const currentCount = messages.length
const newLimit = currentCount + INITIAL_MESSAGE_LIMIT
try {
const res = await fetch(OPENCODE_API, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'get-messages',
sessionId: id,
limit: newLimit
}),
})
if (res.ok) {
const data = await res.json()
const newMessages = data.messages || []
setMessages(prev => {
const existingIds = new Set(prev.map(m => m.id))
const uniqueNew = newMessages.filter((m: ChatMessage) => !existingIds.has(m.id))
return [...uniqueNew, ...prev]
})
setHasMoreMessages(data.hasMore ?? newMessages.length >= newLimit)
}
} catch (error) {
console.error('Failed to load more messages:', error)
}
}, [id, messages])
useEffect(() => {
if (id) {
loadMessages()
}
}, [id, loadMessages])
const handleSendMessage = useCallback(async (text: string, atts: Attachment[]) => {
const userMessage: ChatMessage = {
id: generateUUID(),
role: 'user',
content: text,
parts: [],
createdAt: new Date(),
}
setMessages(prev => [...prev, userMessage])
setStatus('submitted')
try {
const res = await fetch(OPENCODE_API, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'prompt',
sessionId: id,
text,
attachments: atts.map(a => ({
url: a.url,
name: a.name,
mediaType: a.contentType,
})),
}),
})
if (!res.ok) {
throw new Error(await res.text())
}
const data = await res.json()
if (data.message) {
setMessages(prev => [...prev, data.message])
setShowInputAtBottom(true)
}
setStatus('ready')
} catch (error: unknown) {
console.error('Send message error:', error)
setStatus('error')
const errorMessage = error instanceof Error ? error.message : 'Failed to send message'
if (errorMessage.includes('credit card')) {
setShowCreditCardAlert(true)
}
}
}, [id])
const handleStop = useCallback(() => {
setStatus('ready')
}, [])
return (
<>
<div className="overscroll-behavior-contain flex h-full min-w-0 touch-pan-y flex-col bg-background">
{onClear && messages.length > 0 && (
<div className="absolute top-4 right-4 z-10">
<button
onClick={onClear}
className="flex items-center rounded-md bg-primary p-2 text-primary-foreground hover:bg-primary/90"
>
<Eraser className="h-4 w-4" />
</button>
</div>
)}
<Messages
messages={messages}
status={status}
hasMoreMessages={hasMoreMessages}
loadMoreMessages={loadMoreMessages}
/>
<div ref={messagesEndRef} />
<div
className={`mx-auto flex w-full max-w-4xl gap-2 px-2 pb-3 transition-all duration-300 md:px-4 md:pb-4 ${
showInputAtBottom || messages.length > 0
? 'sticky bottom-0'
: 'absolute bottom-[40%] left-0 right-0'
}`}
>
<MultimodalInput
attachments={attachments}
chatId={id}
input={input}
messages={messages}
sendMessage={handleSendMessage}
setAttachments={setAttachments}
setInput={setInput}
status={status}
stop={handleStop}
onDrop={handleDrop}
onDragOver={handleDragOver}
/>
</div>
</div>
<AlertDialog
onOpenChange={setShowCreditCardAlert}
open={showCreditCardAlert}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>API </AlertDialogTitle>
<AlertDialogDescription>
AI API
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setShowCreditCardAlert(false)
}}
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@@ -0,0 +1,9 @@
export function Greeting() {
return (
<div className="mx-auto mt-60 max-w-2xl px-4 text-center">
<h1 className="text-3xl font-bold tracking-tight">
</h1>
</div>
)
}

View File

@@ -0,0 +1,95 @@
import { memo, useState } from 'react'
import { cn } from '@/lib/utils'
import { ArrowDownIcon, SparklesIcon, ChevronDown, ChevronRight } from 'lucide-react'
interface MessageProps {
message: {
id: string
role: 'user' | 'assistant' | 'system'
content: string
parts?: Array<{ type: string; text?: string }>
}
isLoading?: boolean
}
function PureMessage({ message, isLoading }: MessageProps) {
const isUser = message.role === 'user'
const [reasoningCollapsed, setReasoningCollapsed] = useState(true)
return (
<div
className={cn(
'flex w-full gap-4 px-4',
isUser ? 'justify-end' : 'justify-start'
)}
>
<div className={cn(
'flex max-w-[80%] flex-col gap-1 rounded-2xl px-4 py-3',
isUser ? 'bg-gray-100 dark:bg-gray-800' : 'bg-muted/50'
)}>
{message.parts?.map((part: { type: string; text?: string }, i: number) => {
const safeString = (val: unknown): string => {
if (val === null || val === undefined) return ''
if (typeof val === 'string') return val
if (typeof val === 'object') return JSON.stringify(val, null, 2)
return String(val)
}
if (part.type === 'text') {
return (
<div key={i} className={cn('whitespace-pre-wrap text-sm rounded-lg px-3 py-2 bg-gray-100 dark:bg-gray-800', isUser && 'text-primary-foreground')}>
{safeString(part.text)}
</div>
)
}
if (part.type === 'reasoning') {
return (
<div key={i} className="rounded border border-gray-500/30 bg-gray-500/10 p-2 text-xs text-gray-600 dark:text-gray-400">
<div
className="flex items-center gap-1 font-semibold cursor-pointer"
onClick={() => setReasoningCollapsed(!reasoningCollapsed)}
>
{reasoningCollapsed ? <ChevronRight className="size-3" /> : <ChevronDown className="size-3" />}
<SparklesIcon className="size-3" />
Thinking
</div>
{!reasoningCollapsed && (
<div className="mt-1 whitespace-pre-wrap">{safeString(part.text)}</div>
)}
</div>
)
}
return null
})}
{(!message.parts || message.parts.length === 0) && message.content && (
<div className={cn('whitespace-pre-wrap text-sm', isUser && 'text-primary-foreground')}>{message.content}</div>
)}
{isLoading && (
<div className={cn('flex items-center gap-1 text-xs', isUser ? 'text-primary-foreground/70' : 'text-muted-foreground')}>
<span className="animate-pulse">Thinking</span>
<ArrowDownIcon className="size-3 animate-bounce" />
</div>
)}
</div>
</div>
)
}
export const PreviewMessage = memo(PureMessage)
export function ThinkingMessage() {
return (
<div className="flex w-full gap-4 px-4">
<div className="flex flex-col gap-1 rounded-lg bg-muted/50 p-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="animate-pulse">Thinking</span>
<span className="size-1.5 animate-bounce rounded-full bg-muted-foreground" />
<span className="size-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:0.2s]" />
<span className="size-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:0.4s]" />
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,103 @@
import { ArrowDownIcon, Loader2Icon } from 'lucide-react'
import { useState, useEffect } from 'react'
import { useMessages } from '@/hooks/useMessages'
import { Greeting } from './Greeting'
import { PreviewMessage, ThinkingMessage } from './Message'
type Status = 'submitted' | 'streaming' | 'ready' | 'error'
interface ChatMessage {
id: string
role: 'user' | 'assistant' | 'system'
content: string
parts: Array<{ type: string; text?: string }>
createdAt?: Date
}
type MessagesProps = {
messages: ChatMessage[]
status: Status
hasMoreMessages?: boolean
loadMoreMessages?: () => void
}
export function Messages({ messages, status, hasMoreMessages, loadMoreMessages }: MessagesProps) {
const [isLoadingMore, setIsLoadingMore] = useState(false)
const {
containerRef: messagesContainerRef,
endRef: messagesEndRef,
isAtBottom,
scrollToBottom,
} = useMessages({ status })
useEffect(() => {
if (messages.length > 0) {
const container = messagesContainerRef.current
if (container) {
container.scrollTop = container.scrollHeight
}
}
}, [messages, messagesContainerRef])
const handleLoadMore = async () => {
if (!loadMoreMessages || isLoadingMore) return
setIsLoadingMore(true)
await loadMoreMessages()
setIsLoadingMore(false)
}
return (
<div className="relative flex-1 bg-background pt-2 pb-4">
<div
className="absolute inset-0 touch-pan-y overflow-y-auto bg-background"
ref={messagesContainerRef}
>
<div className="mx-auto flex min-w-0 max-w-4xl flex-col gap-4 px-2 py-2 md:gap-6 md:px-4">
{hasMoreMessages && (
<div className="flex justify-center py-2">
<button
onClick={handleLoadMore}
disabled={isLoadingMore}
className="flex items-center gap-2 rounded-full border bg-background px-4 py-2 text-sm text-muted-foreground hover:bg-muted disabled:opacity-50"
>
{isLoadingMore && <Loader2Icon className="size-4 animate-spin" />}
{isLoadingMore ? 'Loading...' : 'Load more messages'}
</button>
</div>
)}
{messages.length === 0 && <Greeting />}
{messages.map((message, index) => (
<PreviewMessage
key={message.id}
message={message}
isLoading={status === 'streaming' && messages.length - 1 === index}
/>
))}
{status === 'submitted' && <ThinkingMessage />}
<div
className="min-h-[24px] min-w-[24px] shrink-0"
ref={messagesEndRef}
/>
</div>
</div>
<button
aria-label="Scroll to bottom"
className={`absolute bottom-4 left-1/2 z-10 -translate-x-1/2 rounded-full border bg-background p-2 shadow-lg transition-all hover:bg-muted ${
isAtBottom
? 'pointer-events-none scale-0 opacity-0'
: 'pointer-events-auto scale-100 opacity-100'
}`}
onClick={() => scrollToBottom('smooth')}
type="button"
>
<ArrowDownIcon className="size-4" />
</button>
</div>
)
}

View File

@@ -0,0 +1,177 @@
import { useCallback, useEffect, useRef } from 'react'
import { cn } from '@/lib/utils'
import { ArrowUpIcon, StopIcon } from './icons'
import { Button } from './ui/Button'
type Status = 'submitted' | 'streaming' | 'ready' | 'error'
interface Attachment {
url: string
name: string
contentType: string
}
interface ChatMessage {
id: string
role: 'user' | 'assistant' | 'system'
content: string
parts: unknown[]
createdAt?: Date
}
interface MultimodalInputProps {
chatId: string
input: string
setInput: (value: string) => void
status: Status
stop: () => void
attachments: Attachment[]
setAttachments: (attachments: Attachment[]) => void
messages: ChatMessage[]
sendMessage: (text: string, attachments: Attachment[]) => Promise<void>
className?: string
onDrop?: (e: React.DragEvent) => void
onDragOver?: (e: React.DragEvent) => void
}
export function MultimodalInput({
chatId,
input,
setInput,
status,
stop,
attachments,
setAttachments,
sendMessage,
className,
onDrop,
onDragOver,
}: MultimodalInputProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null)
const adjustHeight = useCallback(() => {
if (textareaRef.current) {
textareaRef.current.style.height = '44px'
}
}, [])
useEffect(() => {
if (textareaRef.current) {
adjustHeight()
}
}, [adjustHeight])
const resetHeight = useCallback(() => {
if (textareaRef.current) {
textareaRef.current.style.height = '44px'
}
}, [])
const handleInput = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
setInput(event.target.value)
}
const submitForm = useCallback(() => {
if (!input.trim() && attachments.length === 0) return
sendMessage(input, attachments)
setAttachments([])
resetHeight()
setInput('')
textareaRef.current?.focus()
}, [input, setInput, attachments, sendMessage, setAttachments, resetHeight])
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
if (!input.trim() && attachments.length === 0) {
return
}
if (status !== 'ready') {
console.warn('Please wait for the model to finish its response!')
} else {
submitForm()
}
}
}
useEffect(() => {
if (textareaRef.current) {
const target = textareaRef.current
target.style.height = '44px'
const scrollHeight = target.scrollHeight
if (scrollHeight > 44) {
target.style.height = `${Math.min(scrollHeight, 200)}px`
}
}
}, [input])
return (
<div className={cn('relative flex w-full flex-col gap-4', className)}>
<div className="relative flex w-full items-end gap-2 rounded-xl border border-border bg-background p-3 shadow-xs transition-all duration-200 focus-within:border-border hover:border-muted-foreground/50">
{attachments.length > 0 && (
<div className="flex flex-row items-end gap-2 overflow-x-scroll">
{attachments.map((attachment) => (
<div
key={attachment.url}
className="relative flex items-center gap-1 rounded-md bg-muted px-2 py-1 text-xs"
>
<span className="max-w-[100px] truncate">{attachment.name}</span>
<button
className="text-muted-foreground hover:text-foreground"
onClick={() => {
setAttachments(attachments.filter((a) => a.url !== attachment.url))
}}
>
×
</button>
</div>
))}
</div>
)}
<textarea
ref={textareaRef}
className="max-h-[200px] min-h-[44px] w-full resize-none border-0 bg-transparent p-2 text-base outline-none placeholder:text-muted-foreground [-ms-overflow-style:none] [scrollbar-width:none]"
style={{ height: '44px' }}
value={input}
onChange={handleInput}
onKeyDown={handleKeyDown}
onDrop={(e) => {
e.preventDefault()
onDrop?.(e)
}}
onDragOver={(e) => {
e.preventDefault()
onDragOver?.(e)
}}
placeholder="发送消息..."
rows={1}
/>
<div className="flex items-center gap-1">
{status === 'submitted' ? (
<Button
className="size-8 rounded-full bg-foreground p-1 text-background transition-colors duration-200 hover:bg-foreground/90"
onClick={() => {
stop()
}}
>
<StopIcon size={14} />
</Button>
) : (
<Button
className="size-8 rounded-full bg-primary text-primary-foreground transition-colors duration-200 hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground"
disabled={!input.trim() && attachments.length === 0}
onClick={submitForm}
>
<ArrowUpIcon size={14} />
</Button>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,27 @@
import {
ArrowUp,
ArrowDown,
Paperclip,
StopCircle,
ChevronDown,
Check,
Plus,
Trash,
PanelLeftClose,
PanelLeft,
Mic,
} from 'lucide-react'
export {
ArrowUp as ArrowUpIcon,
ArrowDown as ArrowDownIcon,
Paperclip as PaperclipIcon,
StopCircle as StopIcon,
ChevronDown as ChevronDownIcon,
Check as CheckIcon,
Plus as PlusIcon,
Trash as TrashIcon,
PanelLeftClose as PanelLeftCloseIcon,
PanelLeft as PanelLeftOpenIcon,
Mic as MicIcon,
}

View File

@@ -0,0 +1,132 @@
import * as React from 'react'
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
import { cn } from '@/lib/utils'
import { buttonVariants } from './Button'
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className
)}
ref={ref}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col space-y-2 text-center sm:text-left', className)}
{...props}
/>
)
AlertDialogHeader.displayName = 'AlertDialogHeader'
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = 'AlertDialogFooter'
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
className={cn('text-lg font-semibold', className)}
ref={ref}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
className={cn('text-sm text-muted-foreground', className)}
ref={ref}
{...props}
/>
))
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
ref={ref}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
className={cn(
buttonVariants({ variant: 'outline' }),
'mt-2 sm:mt-0',
className
)}
ref={ref}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@@ -0,0 +1,52 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = 'Button'
export { Button, buttonVariants }

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,58 @@
import { useEffect } from 'react'
import { useTabStore, TabState } from '@/stores'
export interface PopOutTabData {
file: {
name: string
path: string
type: 'file' | 'dir'
size: number
modified: string
}
content: string
unsavedContent: string
isEditing: boolean
loading: boolean
loaded: boolean
}
export function usePopOutTab() {
const { selectFile } = useTabStore()
useEffect(() => {
const unsubscribe = window.electronAPI?.onTabDataReceived((tabData: PopOutTabData) => {
console.log('[PopOut] Received tab data:', tabData)
if (!tabData?.file?.path) {
console.error('[PopOut] Invalid tab data received')
return
}
const { file, content, unsavedContent, isEditing, loading, loaded } = tabData
const newTab: TabState = {
file: file as any,
content: content || '',
unsavedContent: unsavedContent || content || '',
isEditing: isEditing || false,
loading: false,
loaded: true,
}
useTabStore.setState((state) => {
const newTabs = new Map(state.tabs)
newTabs.set(file.path, newTab)
return {
tabs: newTabs,
activeTabId: file.path,
}
})
selectFile(file as any)
})
return () => {
unsubscribe?.()
}
}, [selectFile])
}

View File

@@ -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), [])

54
src/hooks/useMessages.ts Normal file
View File

@@ -0,0 +1,54 @@
import { useRef, useEffect, useState, useCallback } from 'react'
type Status = 'submitted' | 'streaming' | 'ready' | 'error'
interface UseMessagesOptions {
status: Status
}
export function useMessages({ status }: UseMessagesOptions) {
const [hasSentMessage, setHasSentMessage] = useState(false)
const [isAtBottom, setIsAtBottom] = useState(true)
const messagesContainerRef = useRef<HTMLDivElement>(null)
const messagesEndRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (status === 'submitted' || status === 'streaming') {
setHasSentMessage(true)
}
}, [status])
useEffect(() => {
const container = messagesContainerRef.current
if (!container) return
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = container
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
setIsAtBottom(distanceFromBottom <= 100)
}
handleScroll()
container.addEventListener('scroll', handleScroll)
return () => container.removeEventListener('scroll', handleScroll)
}, [])
const scrollToBottom = useCallback((behavior: 'smooth' | 'auto' = 'smooth') => {
messagesEndRef.current?.scrollIntoView({ behavior })
}, [])
useEffect(() => {
if (status === 'ready' || status === 'submitted') {
scrollToBottom('auto')
}
}, [status, scrollToBottom])
return {
containerRef: messagesContainerRef,
endRef: messagesEndRef,
isAtBottom,
scrollToBottom,
hasSentMessage,
}
}

View File

@@ -83,6 +83,10 @@ export const getSettings = async (): Promise<Settings> => {
return await fetchApi<Settings>('/api/settings')
}
export const getSettingsConfig = async (): Promise<{ notebookRoot: string }> => {
return await fetchApi<{ notebookRoot: string }>('/api/settings/config')
}
export const saveSettings = async (settings: Settings): Promise<Settings> => {
return await fetchApi<Settings>('/api/settings', {
method: 'POST',

View File

@@ -13,6 +13,7 @@ export {
renameItem,
runAiTask,
getSettings,
getSettingsConfig,
saveSettings,
uploadPdfForParsing,
parseLocalHtml,

6
src/lib/utils/cn.ts Normal file
View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -3,3 +3,5 @@ export { resolveImagePath } from './images'
export { stripMarkdown, generateHeadingId, extractLocalImagePathsFromMarkdown } from './markdown'
export type { TOCItem } from './markdown'
export { generatePrintHtml } from './print'
export { cn } from './cn'
export { generateUUID } from './uuid'

7
src/lib/utils/uuid.ts Normal file
View File

@@ -0,0 +1,7 @@
export function generateUUID(): string {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
const r = (Math.random() * 16) | 0
const v = c === 'x' ? r : (r & 0x3) | 0x8
return v.toString(16)
})
}

View File

@@ -1,62 +1,42 @@
import { useRef, useEffect, useState } from 'react'
import React, { useState, useEffect } from 'react'
import { Chat } from '@/components/chat/Chat'
const OPENCODE_API = '/api/opencode'
export const HomePage = () => {
const webviewRef = useRef<HTMLWebViewElement>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [sessionId, setSessionId] = useState<string | null>(null)
useEffect(() => {
const startOpencodeServer = async () => {
try {
const result = await window.electronAPI?.opencodeStartServer()
if (!result?.success) {
setError(result?.error || 'Failed to start opencode server')
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error')
const createNewSession = async () => {
try {
const res = await fetch(OPENCODE_API, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'create-session', title: 'New Chat' }),
})
const data = await res.json()
if (data.session?.id) {
setSessionId(data.session.id)
}
} catch (e) {
console.error('Failed to create session:', e)
}
startOpencodeServer()
return () => {
window.electronAPI?.opencodeStopServer()
}
}, [])
}
useEffect(() => {
const webview = webviewRef.current
if (!webview) return
webview.addEventListener('did-fail-load', (e) => {
console.error('[HomePage] Failed to load:', e)
setIsLoading(false)
})
webview.addEventListener('did-finish-load', () => {
setIsLoading(false)
})
createNewSession()
}, [])
if (!sessionId) {
return (
<div className="h-full w-full flex items-center justify-center">
<div className="text-muted-foreground">Loading...</div>
</div>
)
}
return (
<div className="h-full w-full relative">
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-white dark:bg-gray-900 z-10">
<div className="text-gray-500 dark:text-gray-400"> opencode ...</div>
</div>
)}
{error && (
<div className="absolute inset-0 flex items-center justify-center bg-white dark:bg-gray-900 z-10">
<div className="text-red-500">: {error}</div>
</div>
)}
<webview
ref={webviewRef}
src="http://127.0.0.1:4096"
className="w-full h-full"
nodeintegration={false}
webpreferences="contextIsolation=yes"
/>
<div className="h-full w-full">
<Chat key={sessionId} id={sessionId} initialMessages={[]} onClear={createNewSession} />
</div>
)
}

View File

@@ -0,0 +1,86 @@
import React, { useEffect, useState, useRef } from 'react'
export const OpenCodePage: React.FC = () => {
const [isHealthy, setIsHealthy] = useState(false)
const [port, setPort] = useState<number>(9999)
const startedRef = useRef(false)
const restartingRef = useRef(false)
const webviewRef = useRef<HTMLWebViewElement>(null)
useEffect(() => {
let mounted = true
const start = async () => {
try {
const portResult = await window.electronAPI.xcOpenCodeWebGetPort()
if (mounted) {
setPort(portResult.port)
}
const result = await window.electronAPI.xcOpenCodeWebStart()
if (!result.success && mounted) {
console.error('Failed to start XCOpenCodeWeb:', result.error)
}
restartingRef.current = false
} catch (err) {
console.error('Failed to start XCOpenCodeWeb:', err)
restartingRef.current = false
}
}
const checkStatus = async () => {
try {
const status = await window.electronAPI.xcOpenCodeWebGetStatus()
if (mounted) {
setIsHealthy(status.running)
if (!status.running && !restartingRef.current) {
restartingRef.current = true
start()
}
}
} catch (err) {
if (mounted) {
setIsHealthy(false)
if (!restartingRef.current) {
restartingRef.current = true
start()
}
}
}
}
if (!startedRef.current) {
startedRef.current = true
start()
}
const interval = setInterval(checkStatus, 2000)
return () => {
mounted = false
clearInterval(interval)
window.electronAPI.xcOpenCodeWebStop()
startedRef.current = false
}
}, [])
return (
<div className="h-full w-full relative">
<span className={`absolute bottom-2 right-2 z-10 w-1.5 h-1.5 rounded-full ${isHealthy ? 'bg-green-500' : 'bg-red-500'}`} />
{!isHealthy && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-8 h-8 border-2 border-gray-300 border-t-blue-500 rounded-full animate-spin" />
</div>
)}
{isHealthy && (
<webview
ref={webviewRef}
src={`http://localhost:${port}`}
style={{ width: '100%', height: '100%', border: 'none' }}
allowpopups={true}
webpreferences="contextIsolation=no"
/>
)}
</div>
)
}

View File

@@ -0,0 +1,11 @@
import { Code } from 'lucide-react'
import { OpenCodePage } from './OpenCodePage'
import { OPENCODE_MODULE } from '@shared/modules/opencode'
import { createFrontendModule } from '@/lib/module-registry'
export default createFrontendModule(OPENCODE_MODULE, {
icon: Code,
component: OpenCodePage,
})
export { OpenCodePage }

View File

@@ -0,0 +1,86 @@
import React, { useEffect, useState, useRef } from 'react'
export const SDDPage: React.FC = () => {
const [isHealthy, setIsHealthy] = useState(false)
const [port, setPort] = useState<number>(9998)
const startedRef = useRef(false)
const restartingRef = useRef(false)
const webviewRef = useRef<HTMLWebViewElement>(null)
useEffect(() => {
let mounted = true
const start = async () => {
try {
const portResult = await window.electronAPI.sddGetPort()
if (mounted) {
setPort(portResult.port)
}
const result = await window.electronAPI.sddStart()
if (!result.success && mounted) {
console.error('Failed to start SDD:', result.error)
}
restartingRef.current = false
} catch (err) {
console.error('Failed to start SDD:', err)
restartingRef.current = false
}
}
const checkStatus = async () => {
try {
const status = await window.electronAPI.sddGetStatus()
if (mounted) {
setIsHealthy(status.running)
if (!status.running && !restartingRef.current) {
restartingRef.current = true
start()
}
}
} catch (err) {
if (mounted) {
setIsHealthy(false)
if (!restartingRef.current) {
restartingRef.current = true
start()
}
}
}
}
if (!startedRef.current) {
startedRef.current = true
start()
}
const interval = setInterval(checkStatus, 2000)
return () => {
mounted = false
clearInterval(interval)
window.electronAPI.sddStop()
startedRef.current = false
}
}, [])
return (
<div className="h-full w-full relative">
<span className={`absolute bottom-2 right-2 z-10 w-1.5 h-1.5 rounded-full ${isHealthy ? 'bg-green-500' : 'bg-red-500'}`} />
{!isHealthy && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-8 h-8 border-2 border-gray-300 border-t-blue-500 rounded-full animate-spin" />
</div>
)}
{isHealthy && (
<webview
ref={webviewRef}
src={`http://localhost:${port}`}
style={{ width: '100%', height: '100%', border: 'none' }}
allowpopups={true}
webpreferences="contextIsolation=no"
/>
)}
</div>
)
}

11
src/modules/sdd/index.tsx Normal file
View File

@@ -0,0 +1,11 @@
import { FileCode } from 'lucide-react'
import { SDDPage } from './SDDPage'
import { SDD_MODULE } from '@shared/modules/sdd'
import { createFrontendModule } from '@/lib/module-registry'
export default createFrontendModule(SDD_MODULE, {
icon: FileCode,
component: SDDPage,
})
export { SDDPage }

View File

@@ -0,0 +1,86 @@
import React, { useEffect, useState, useRef } from 'react'
export const TerminalPage: React.FC = () => {
const [isHealthy, setIsHealthy] = useState(false)
const [port, setPort] = useState<number>(9997)
const startedRef = useRef(false)
const restartingRef = useRef(false)
const webviewRef = useRef<HTMLWebViewElement>(null)
useEffect(() => {
let mounted = true
const start = async () => {
try {
const portResult = await window.electronAPI.terminalGetPort()
if (mounted) {
setPort(portResult.port)
}
const result = await window.electronAPI.terminalStart()
if (!result.success && mounted) {
console.error('Failed to start Terminal:', result.error)
}
restartingRef.current = false
} catch (err) {
console.error('Failed to start Terminal:', err)
restartingRef.current = false
}
}
const checkStatus = async () => {
try {
const status = await window.electronAPI.terminalGetStatus()
if (mounted) {
setIsHealthy(status.running)
if (!status.running && !restartingRef.current) {
restartingRef.current = true
start()
}
}
} catch (err) {
if (mounted) {
setIsHealthy(false)
if (!restartingRef.current) {
restartingRef.current = true
start()
}
}
}
}
if (!startedRef.current) {
startedRef.current = true
start()
}
const interval = setInterval(checkStatus, 2000)
return () => {
mounted = false
clearInterval(interval)
window.electronAPI.terminalStop()
startedRef.current = false
}
}, [])
return (
<div className="h-full w-full relative">
<span className={`absolute bottom-2 right-2 z-10 w-1.5 h-1.5 rounded-full ${isHealthy ? 'bg-green-500' : 'bg-red-500'}`} />
{!isHealthy && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-8 h-8 border-2 border-gray-300 border-t-blue-500 rounded-full animate-spin" />
</div>
)}
{isHealthy && (
<webview
ref={webviewRef}
src={`http://localhost:${port}`}
style={{ width: '100%', height: '100%', border: 'none' }}
allowpopups={true}
webpreferences="contextIsolation=no"
/>
)}
</div>
)
}

View File

@@ -0,0 +1,11 @@
import { Terminal } from 'lucide-react'
import { TerminalPage } from './TerminalPage'
import { TERMINAL_MODULE } from '@shared/modules/terminal'
import { createFrontendModule } from '@/lib/module-registry'
export default createFrontendModule(TERMINAL_MODULE, {
icon: Terminal,
component: TerminalPage,
})
export { TerminalPage }

View File

@@ -118,6 +118,20 @@ export const NoteBrowser = () => {
}
}, [selectFile])
const handlePopOut = useCallback(async (file: FileItem) => {
const tabState = tabs.get(file.path)
if (!tabState) return
const content = tabState.content || ''
const unsavedContent = tabState.unsavedContent || content
const isEditing = tabState.isEditing
const route = `/popout?path=${encodeURIComponent(file.path)}&name=${encodeURIComponent(file.name)}&content=${encodeURIComponent(content)}&unsaved=${encodeURIComponent(unsavedContent)}&editing=${isEditing}`
await window.electronAPI?.createWindow({ route, title: file.name })
closeFile(file)
}, [tabs, closeFile])
useEffect(() => {
if (!isMarkdown && showTOC) {
handleTOCClose()
@@ -146,6 +160,7 @@ export const NoteBrowser = () => {
onTabClose={handleTabClose}
onCloseOther={closeOtherFiles}
onCloseAll={closeAllFiles}
onPopOut={handlePopOut}
opacity={opacity}
/>

179
src/pages/PopoutPage.tsx Normal file
View File

@@ -0,0 +1,179 @@
import { useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import type { FileItemDTO } from '@shared/types/file'
import { matchModule } from '@/lib/module-registry'
import { MarkdownTabPage } from '@/components/tabs/MarkdownTabPage'
import { RemoteTabPage } from '@/modules/remote/RemoteTabPage'
import { FileTransferPage } from '@/modules/remote/components/file-transfer/FileTransferPage'
import { Minus, Square, X, Maximize2 } from 'lucide-react'
import { useTabStore } from '@/stores'
export const PopoutPage = () => {
const [searchParams] = useSearchParams()
const [file, setFile] = useState<FileItemDTO | null>(null)
const [content, setContent] = useState<string>('')
const [unsavedContent, setUnsavedContent] = useState<string>('')
const [isEditing, setIsEditing] = useState(false)
const [isMaximized, setIsMaximized] = useState(false)
const [ready, setReady] = useState(false)
const { selectFile } = useTabStore()
useEffect(() => {
const path = searchParams.get('path')
const name = searchParams.get('name')
const contentParam = searchParams.get('content')
const unsavedParam = searchParams.get('unsaved')
const editingParam = searchParams.get('editing')
if (!path || !name) {
return
}
const decodedPath = decodeURIComponent(path)
const decodedName = decodeURIComponent(name)
const decodedContent = contentParam ? decodeURIComponent(contentParam) : ''
const decodedUnsaved = unsavedParam ? decodeURIComponent(unsavedParam) : decodedContent
const decodedEditing = editingParam === 'true'
setFile({
name: decodedName,
path: decodedPath,
type: 'file',
size: 0,
modified: new Date().toISOString(),
})
setContent(decodedContent)
setUnsavedContent(decodedUnsaved)
setIsEditing(decodedEditing)
useTabStore.setState((state) => {
const newTabs = new Map(state.tabs)
newTabs.set(decodedPath, {
file: {
name: decodedName,
path: decodedPath,
type: 'file' as const,
size: 0,
modified: new Date().toISOString(),
},
content: decodedContent,
unsavedContent: decodedUnsaved,
isEditing: decodedEditing,
loading: false,
loaded: true,
})
return {
tabs: newTabs,
activeTabId: decodedPath,
}
})
selectFile({
name: decodedName,
path: decodedPath,
type: 'file',
size: 0,
modified: new Date().toISOString(),
})
setReady(true)
window.electronAPI?.windowIsMaximized().then((result) => {
if (result.success) {
setIsMaximized(result.isMaximized)
}
})
}, [searchParams, selectFile])
const handleMinimize = () => {
window.electronAPI?.windowMinimize()
}
const handleMaximize = async () => {
const result = await window.electronAPI?.windowMaximize()
if (result?.success && result.isMaximized !== undefined) {
setIsMaximized(result.isMaximized)
}
}
const handleClose = () => {
window.electronAPI?.windowClose()
}
if (!file || !ready) {
return (
<div className="flex items-center justify-center h-screen bg-white dark:bg-gray-900">
<div className="text-gray-500">Loading...</div>
</div>
)
}
const renderContent = () => {
if (file.path.startsWith('file-transfer-panel')) {
const queryString = file.path.includes('?') ? file.path.split('?')[1] : ''
const urlParams = new URLSearchParams(queryString)
const serverHost = urlParams.get('host') || ''
const port = parseInt(urlParams.get('port') || '3000', 10)
const password = urlParams.get('password') || undefined
return (
<FileTransferPage
serverHost={serverHost}
port={port}
password={password}
onClose={() => window.close()}
/>
)
}
if (file.path.startsWith('remote-desktop://') || file.path.startsWith('remote-git://')) {
const urlParams = new URLSearchParams(file.path.split('?')[1])
const url = urlParams.get('url') || 'https://www.baidu.com'
const deviceName = urlParams.get('device') || ''
return <RemoteTabPage url={url} title={file.name} deviceName={deviceName} />
}
const module = matchModule(file)
if (module) {
const Component = module.component
return <Component />
}
return <MarkdownTabPage file={file} onTocUpdated={() => {}} />
}
return (
<div className="h-screen w-screen flex flex-col bg-white dark:bg-gray-900">
<div className="titlebar-drag-region h-8 flex items-center justify-between px-3 bg-gray-100 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 shrink-0">
<span className="text-sm text-gray-700 dark:text-gray-200 truncate">{file.name}</span>
<div className="flex items-center gap-1 titlebar-no-drag">
<button
onClick={handleMinimize}
className="p-1.5 hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors"
>
<Minus size={14} className="text-gray-600 dark:text-gray-300" />
</button>
<button
onClick={handleMaximize}
className="p-1.5 hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors"
>
{isMaximized ? (
<Square size={12} className="text-gray-600 dark:text-gray-300" />
) : (
<Maximize2 size={12} className="text-gray-600 dark:text-gray-300" />
)}
</button>
<button
onClick={handleClose}
className="p-1.5 hover:bg-red-500 hover:text-white rounded transition-colors"
>
<X size={14} className="text-gray-600 dark:text-gray-300" />
</button>
</div>
</div>
<div className="flex-1 min-h-0 overflow-hidden">
{renderContent()}
</div>
</div>
)
}

View File

@@ -331,7 +331,9 @@ export const useTabStore = create<TabStore>((set, get) => {
const isAsyncImportProcessing = isAsyncImportProcessingContent(tab.content)
if (isAsyncImportProcessing) return
await saveFileContent(filePath, tab.unsavedContent)
const contentToSave = tab.unsavedContent
await saveFileContent(filePath, contentToSave)
set((state) => {
const newTabs = new Map(state.tabs)
@@ -339,7 +341,8 @@ export const useTabStore = create<TabStore>((set, get) => {
if (currentTab) {
newTabs.set(filePath, {
...currentTab,
content: currentTab.unsavedContent,
content: contentToSave,
unsavedContent: contentToSave,
isEditing: false,
})
}

View File

@@ -40,6 +40,26 @@ export interface ElectronAPI {
}>
opencodeStartServer: () => Promise<{ success: boolean; port?: number; error?: string }>
opencodeStopServer: () => Promise<{ success: boolean; error?: string }>
xcOpenCodeWebStart: () => Promise<{ success: boolean; error?: string }>
xcOpenCodeWebStop: () => Promise<{ success: boolean; error?: string }>
xcOpenCodeWebGetStatus: () => Promise<{ running: boolean; port: number }>
xcOpenCodeWebGetPort: () => Promise<{ port: number }>
xcOpenCodeWebCheckReady: () => Promise<{ ready: boolean }>
sddStart: () => Promise<{ success: boolean; error?: string }>
sddStop: () => Promise<{ success: boolean; error?: string }>
sddGetStatus: () => Promise<{ running: boolean; port: number }>
sddGetPort: () => Promise<{ port: number }>
terminalStart: () => Promise<{ success: boolean; error?: string }>
terminalStop: () => Promise<{ success: boolean; error?: string }>
terminalGetStatus: () => Promise<{ running: boolean; port: number }>
terminalGetPort: () => Promise<{ port: number }>
createWindow: (tabData: { route: string; title: string }) => Promise<{ success: boolean; windowId?: number; error?: string }>
transferTabData: (windowId: number, tabData: any) => Promise<{ success: boolean; error?: string }>
onTabDataReceived: (callback: (tabData: any) => void) => () => void
windowMinimize: () => Promise<{ success: boolean }>
windowMaximize: () => Promise<{ success: boolean; isMaximized?: boolean }>
windowClose: () => Promise<{ success: boolean }>
windowIsMaximized: () => Promise<{ success: boolean; isMaximized: boolean }>
}
declare global {