Compare commits
48 Commits
cd1b541427
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9caa43d4a2 | |||
| dff4674c54 | |||
| 19e7a51b61 | |||
| 69bd91797d | |||
| 3b1f99951e | |||
| 2d87c267cf | |||
| d0e286e4bb | |||
| aa5895873b | |||
| c37e6ab4f2 | |||
| f160adbdb1 | |||
| 43828a87f0 | |||
| 2eb5a167b3 | |||
| 8ee86c7b0f | |||
| 9effdcd070 | |||
| 7c7b334f5c | |||
| 8716f6f684 | |||
| 1d33a8e14f | |||
| 7db1f9162b | |||
| d54510a864 | |||
| 28df633b00 | |||
| c83f23c319 | |||
| 90517f2289 | |||
| 308df54a15 | |||
| dcd1fcd709 | |||
| 18c02053da | |||
| 3c353cb701 | |||
| 0d5cd329ca | |||
| e950484af6 | |||
| cbc1af7348 | |||
| 88d42b37a6 | |||
| b5343bcd9d | |||
| 9b22b647f2 | |||
| 50cd1e29c9 | |||
| ba02eb10a7 | |||
| 7c656785c8 | |||
| f692961823 | |||
| 1be470f45b | |||
| 96c709f109 | |||
| fd77455f5b | |||
| 72d79ae214 | |||
| 53c1045406 | |||
| fd2255c83a | |||
| 986ecb2561 | |||
| 8d4a9a3704 | |||
| e6c41491b3 | |||
| cd70b50180 | |||
| 96390df254 | |||
| 371d4ce327 |
12
.gitignore
vendored
12
.gitignore
vendored
@@ -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/
|
||||
@@ -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
3
api/.env
Normal file
@@ -0,0 +1,3 @@
|
||||
MINIMAX_API_KEY=sk-api-Ip6OsHSvDJlnbl7L9sRsK277vx1W6VfM8xXfbjjEcdsnT_91kZmciWDiNHzMkDMTEsxirTZdA-shZ-oYS0Qo70m3raWeO7_1Zr8rmM9D5QFWKgkLya60HrA
|
||||
MINIMAX_GROUP_ID=1982508094420165122
|
||||
OPENAI_API_KEY=
|
||||
2
api/.env.example
Normal file
2
api/.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
MINIMAX_API_KEY=your_api_key_here
|
||||
MINIMAX_GROUP_ID=your_group_id_here
|
||||
@@ -23,8 +23,12 @@ import { apiModules } from './modules/index.js'
|
||||
import { validateModuleConsistency } from './infra/moduleValidator.js'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
dotenv.config()
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
dotenv.config({ path: path.resolve(__dirname, './.env') })
|
||||
|
||||
const app: express.Application = express()
|
||||
export const container = new ServiceContainer()
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -51,4 +51,13 @@ router.post(
|
||||
}),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/config',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
successResponse(res, {
|
||||
notebookRoot: NOTEBOOK_ROOT,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export default router
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import express, { type Request, type Response } from 'express'
|
||||
import { spawn } from 'child_process'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import fs from 'fs/promises'
|
||||
import fsSync from 'fs'
|
||||
import { asyncHandler } from '../../utils/asyncHandler.js'
|
||||
import { successResponse } from '../../utils/response.js'
|
||||
import { resolveNotebookPath } from '../../utils/pathSafety.js'
|
||||
import { ValidationError, NotFoundError, InternalError } from '../../../shared/errors/index.js'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
import { PROJECT_ROOT } from '../../config/paths.js'
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
@@ -89,8 +86,7 @@ router.post(
|
||||
|
||||
const content = await fs.readFile(fullPath, 'utf-8')
|
||||
|
||||
const projectRoot = path.resolve(__dirname, '..', '..', '..')
|
||||
const scriptPath = path.join(projectRoot, 'tools', 'doubao', 'main.py')
|
||||
const scriptPath = path.join(PROJECT_ROOT, 'tools', 'doubao', 'main.py')
|
||||
|
||||
if (!fsSync.existsSync(scriptPath)) {
|
||||
throw new InternalError(`Python script not found: ${scriptPath}`)
|
||||
|
||||
@@ -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'
|
||||
|
||||
20
api/modules/opencode/index.ts
Normal file
20
api/modules/opencode/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { Router } from 'express'
|
||||
import type { ServiceContainer } from '../../infra/container.js'
|
||||
import { createApiModule } from '../../infra/createModule.js'
|
||||
import { OPENCODE_MODULE } from '../../../shared/modules/opencode/index.js'
|
||||
import { createOpencodeRoutes } from './routes.js'
|
||||
|
||||
export * from './routes.js'
|
||||
|
||||
export const createOpencodeModule = () => {
|
||||
return createApiModule({
|
||||
...OPENCODE_MODULE,
|
||||
version: OPENCODE_MODULE.version || '1.0.0',
|
||||
}, {
|
||||
routes: (_container: ServiceContainer): Router => {
|
||||
return createOpencodeRoutes()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export default createOpencodeModule
|
||||
223
api/modules/opencode/routes.ts
Normal file
223
api/modules/opencode/routes.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { Router } from 'express'
|
||||
import { createOpencodeClient } from '@opencode-ai/sdk'
|
||||
|
||||
const OPENCODE_URL = 'http://localhost:4096'
|
||||
|
||||
async function getClient() {
|
||||
console.log('[OpenCode] Creating client for URL:', OPENCODE_URL)
|
||||
return createOpencodeClient({
|
||||
baseUrl: OPENCODE_URL,
|
||||
})
|
||||
}
|
||||
|
||||
export function createOpencodeRoutes(): Router {
|
||||
const router = Router()
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
const action = req.query.action as string | undefined
|
||||
|
||||
try {
|
||||
const client = await getClient()
|
||||
|
||||
if (action === 'list-sessions') {
|
||||
const response = await client.session.list()
|
||||
const sessions = (response.data || []).map((session: { id: string; title?: string; createdAt?: string }) => ({
|
||||
id: session.id,
|
||||
title: session.title || 'New Chat',
|
||||
createdAt: session.createdAt ? new Date(session.createdAt).getTime() : Date.now(),
|
||||
}))
|
||||
res.json({ sessions })
|
||||
return
|
||||
}
|
||||
|
||||
res.status(400).json({ error: 'Unknown action' })
|
||||
} catch (error: unknown) {
|
||||
console.error('API error:', error)
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
res.status(500).json({ error: errorMessage })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const body = req.body
|
||||
const { action, sessionId, title, text, attachments } = body
|
||||
console.log('[OpenCode] Received action:', action, 'sessionId:', sessionId)
|
||||
|
||||
const client = await getClient()
|
||||
console.log('[OpenCode] Client created successfully')
|
||||
|
||||
if (action === 'create-session') {
|
||||
try {
|
||||
const response = await client.session.create({
|
||||
body: { title: title || 'New Chat' },
|
||||
})
|
||||
|
||||
if (response.error) {
|
||||
res.status(500).json({ error: 'OpenCode error', details: response.error })
|
||||
return
|
||||
}
|
||||
|
||||
const sessionData = response.data as { id: string; title?: string; createdAt?: string } | undefined
|
||||
if (sessionData) {
|
||||
res.json({
|
||||
session: {
|
||||
id: sessionData.id,
|
||||
title: sessionData.title,
|
||||
createdAt: sessionData.createdAt ? new Date(sessionData.createdAt).getTime() : Date.now(),
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
res.status(500).json({ error: 'Failed to create session - no data' })
|
||||
return
|
||||
} catch (e: unknown) {
|
||||
console.error('Create session exception:', e)
|
||||
const errorMessage = e instanceof Error ? e.message : 'Unknown error'
|
||||
res.status(500).json({ error: errorMessage, exception: true })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (action === 'delete-session') {
|
||||
const response = await client.session.delete({
|
||||
path: { id: sessionId },
|
||||
})
|
||||
res.json({ success: response.data })
|
||||
return
|
||||
}
|
||||
|
||||
if (action === 'get-session') {
|
||||
const response = await client.session.get({
|
||||
path: { id: sessionId },
|
||||
})
|
||||
|
||||
if (response.data) {
|
||||
res.json({
|
||||
title: response.data.title || 'New Chat',
|
||||
})
|
||||
return
|
||||
}
|
||||
res.status(404).json({ error: 'Session not found' })
|
||||
return
|
||||
}
|
||||
|
||||
if (action === 'get-messages') {
|
||||
const limit = body.limit || 20
|
||||
|
||||
const response = await client.session.messages({
|
||||
path: { id: sessionId },
|
||||
query: { limit }
|
||||
})
|
||||
|
||||
const messages = (response.data || []).map((item: { info?: { id?: string; role?: string; content?: string; createdAt?: string }; content?: string; parts?: unknown[]; id?: string; role?: string }) => {
|
||||
let content = item.info?.content || item.content || ''
|
||||
|
||||
const parts = item.parts as Array<{ type: string; text?: string }> | undefined
|
||||
if (parts && parts.length > 0) {
|
||||
const textParts = parts
|
||||
.filter((p) => p.type === 'text')
|
||||
.map((p) => p.text)
|
||||
.join('')
|
||||
if (textParts) {
|
||||
content = textParts
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: item.info?.id || item.id,
|
||||
role: item.info?.role || item.role,
|
||||
content: content,
|
||||
parts: item.parts || [],
|
||||
createdAt: item.info?.createdAt,
|
||||
}
|
||||
})
|
||||
|
||||
const hasMore = messages.length >= limit
|
||||
|
||||
res.json({ messages, hasMore })
|
||||
return
|
||||
}
|
||||
|
||||
if (action === 'prompt') {
|
||||
const parts: Array<{ type: 'text'; text: string } | { type: 'file'; url: string; name: string; mime: string }> = []
|
||||
|
||||
if (attachments && attachments.length > 0) {
|
||||
for (const att of attachments as Array<{ url: string; name: string; mediaType: string }>) {
|
||||
parts.push({
|
||||
type: 'file',
|
||||
url: att.url,
|
||||
name: att.name,
|
||||
mime: att.mediaType,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
parts.push({ type: 'text', text })
|
||||
|
||||
try {
|
||||
const response = await client.session.prompt({
|
||||
path: { id: sessionId },
|
||||
body: { parts },
|
||||
})
|
||||
|
||||
if (response.data) {
|
||||
res.json({
|
||||
message: {
|
||||
id: (response.data.info as { id?: string })?.id || crypto.randomUUID(),
|
||||
role: (response.data.info as { role?: string })?.role || 'assistant',
|
||||
content: '',
|
||||
parts: response.data.parts || [],
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
res.status(500).json({ error: 'Failed to get response', details: response.error })
|
||||
return
|
||||
} catch (e: unknown) {
|
||||
console.error('Prompt exception:', e)
|
||||
const errorMessage = e instanceof Error ? e.message : 'Unknown error'
|
||||
res.status(500).json({ error: errorMessage })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (action === 'summarize-session') {
|
||||
try {
|
||||
await client.session.summarize({
|
||||
path: { id: sessionId },
|
||||
})
|
||||
|
||||
const response = await client.session.get({
|
||||
path: { id: sessionId },
|
||||
})
|
||||
|
||||
if (response.data) {
|
||||
res.json({
|
||||
title: response.data.title || 'New Chat',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
res.status(500).json({ error: 'Failed to get session' })
|
||||
return
|
||||
} catch (e: unknown) {
|
||||
console.error('Summarize error:', e)
|
||||
const errorMessage = e instanceof Error ? e.message : 'Unknown error'
|
||||
res.status(500).json({ error: errorMessage })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
res.status(400).json({ error: 'Unknown action' })
|
||||
} catch (error: unknown) {
|
||||
console.error('API error:', error)
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
res.status(500).json({ error: errorMessage })
|
||||
}
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
25
api/modules/voice/index.ts
Normal file
25
api/modules/voice/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { Router } from 'express'
|
||||
import type { ServiceContainer } from '../../infra/container.js'
|
||||
import { createApiModule } from '../../infra/createModule.js'
|
||||
import { voiceModule, VOICE_MODULE } from '../../../shared/modules/voice/index.js'
|
||||
import { createVoiceRoutes } from './routes.js'
|
||||
|
||||
export * from './routes.js'
|
||||
|
||||
export const createVoiceModule = () => {
|
||||
return createApiModule(
|
||||
{
|
||||
...voiceModule,
|
||||
basePath: '/voice',
|
||||
version: '1.0.0',
|
||||
order: 100,
|
||||
},
|
||||
{
|
||||
routes: (_container: ServiceContainer): Router => {
|
||||
return createVoiceRoutes()
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export default createVoiceModule
|
||||
117
api/modules/voice/routes.ts
Normal file
117
api/modules/voice/routes.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { Router } from 'express'
|
||||
import { config } from '../../config/index.js'
|
||||
|
||||
interface AIMLAPISTTCreateResponse {
|
||||
generation_id?: string
|
||||
status?: string
|
||||
error?: {
|
||||
message: string
|
||||
}
|
||||
}
|
||||
|
||||
interface AIMLAPISTTQueryResponse {
|
||||
status?: string
|
||||
result?: {
|
||||
results?: {
|
||||
channels?: Array<{
|
||||
alternatives?: Array<{
|
||||
transcript?: string
|
||||
}>
|
||||
}>
|
||||
}
|
||||
}
|
||||
error?: {
|
||||
message: string
|
||||
}
|
||||
}
|
||||
|
||||
export function createVoiceRoutes(): Router {
|
||||
const router = Router()
|
||||
|
||||
router.post('/stt', async (req, res) => {
|
||||
console.log('[Voice] Request received, body keys:', Object.keys(req.body || {}))
|
||||
try {
|
||||
const apiKey = config.minimaxApiKey
|
||||
|
||||
console.log('[Voice] API Key exists:', !!apiKey)
|
||||
|
||||
if (!apiKey) {
|
||||
res.status(500).json({ error: 'MiniMax API key not configured' })
|
||||
return
|
||||
}
|
||||
|
||||
if (!req.body.audio) {
|
||||
res.status(400).json({ error: 'No audio data provided' })
|
||||
return
|
||||
}
|
||||
|
||||
const audioBuffer = Buffer.from(req.body.audio, 'base64')
|
||||
console.log('[Voice] Audio buffer size:', audioBuffer.length)
|
||||
|
||||
const formData = new FormData()
|
||||
const blob = new Blob([audioBuffer], { type: 'audio/webm' })
|
||||
formData.append('file', blob, 'audio.webm')
|
||||
formData.append('model', '#g1_whisper-large')
|
||||
|
||||
console.log('[Voice] Creating STT job via MiniMax...')
|
||||
|
||||
const createResponse = await fetch('https://api.minimax.chat/v1/stt/create', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
},
|
||||
body: formData,
|
||||
})
|
||||
|
||||
console.log('[Voice] Create response status:', createResponse.status)
|
||||
const createData: AIMLAPISTTCreateResponse = await createResponse.json()
|
||||
console.log('[Voice] Create response:', createData)
|
||||
|
||||
if (!createResponse.ok || !createData.generation_id) {
|
||||
console.error('[Voice] Failed to create STT job:', createData.error?.message)
|
||||
res.status(500).json({ error: createData.error?.message || 'Failed to create STT job' })
|
||||
return
|
||||
}
|
||||
|
||||
const jobId = createData.generation_id
|
||||
console.log('[Voice] Job ID:', jobId)
|
||||
|
||||
console.log('[Voice] Polling for result...')
|
||||
let resultText = ''
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
const queryResponse = await fetch(`https://api.minimax.chat/v1/stt/${jobId}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
},
|
||||
})
|
||||
|
||||
const queryData: AIMLAPISTTQueryResponse = await queryResponse.json()
|
||||
console.log('[Voice] Query response:', queryData)
|
||||
|
||||
if (queryData.status === 'succeeded') {
|
||||
resultText = queryData.result?.results?.channels?.[0]?.alternatives?.[0]?.transcript || ''
|
||||
break
|
||||
} else if (queryData.status === 'failed') {
|
||||
console.error('[Voice] STT job failed:', queryData.error?.message)
|
||||
res.status(500).json({ error: queryData.error?.message || 'STT processing failed' })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!resultText) {
|
||||
res.status(500).json({ error: 'STT processing timeout' })
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[Voice] Final result:', resultText)
|
||||
res.json({ text: resultText })
|
||||
} catch (error) {
|
||||
console.error('[Voice] STT error:', error)
|
||||
res.status(500).json({ error: 'Failed to process audio' })
|
||||
}
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
266
electron/main.ts
266
electron/main.ts
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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'),
|
||||
})
|
||||
|
||||
196
electron/services/opencodeService.ts
Normal file
196
electron/services/opencodeService.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { spawn, exec, ChildProcess } from 'child_process';
|
||||
import log from 'electron-log';
|
||||
|
||||
const OPENCODE_PORT = 4096;
|
||||
const HEALTH_CHECK_INTERVAL = 10000;
|
||||
const MAX_RESTART_ATTEMPTS = 3;
|
||||
const HEALTH_CHECK_TIMEOUT = 2000;
|
||||
|
||||
class OpenCodeService {
|
||||
private process: ChildProcess | null = null;
|
||||
private healthCheckTimer: NodeJS.Timeout | null = null;
|
||||
private restartAttempts = 0;
|
||||
private _isRunning = false;
|
||||
|
||||
get port(): number {
|
||||
return OPENCODE_PORT;
|
||||
}
|
||||
|
||||
isRunning(): boolean {
|
||||
return this._isRunning;
|
||||
}
|
||||
|
||||
getStatus(): { running: boolean; port: number; restartAttempts: number } {
|
||||
return {
|
||||
running: this._isRunning,
|
||||
port: this.port,
|
||||
restartAttempts: this.restartAttempts,
|
||||
};
|
||||
}
|
||||
|
||||
private getAuthHeaders(): Record<string, string> {
|
||||
const password = 'xc_opencode_password';
|
||||
const encoded = Buffer.from(`:${password}`).toString('base64');
|
||||
return {
|
||||
'Authorization': `Basic ${encoded}`,
|
||||
};
|
||||
}
|
||||
|
||||
private async checkHealth(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${this.port}/session`, {
|
||||
method: 'GET',
|
||||
headers: this.getAuthHeaders(),
|
||||
signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT),
|
||||
});
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
log.warn('[OpenCodeService] Health check failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async restart(): Promise<void> {
|
||||
if (this.restartAttempts >= MAX_RESTART_ATTEMPTS) {
|
||||
log.error('[OpenCodeService] Max restart attempts reached, giving up');
|
||||
this._isRunning = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.restartAttempts++;
|
||||
log.info(`[OpenCodeService] Attempting restart (${this.restartAttempts}/${MAX_RESTART_ATTEMPTS})...`);
|
||||
|
||||
await this.stop();
|
||||
await this.start();
|
||||
}
|
||||
|
||||
private startHealthCheck(): void {
|
||||
if (this.healthCheckTimer) {
|
||||
clearInterval(this.healthCheckTimer);
|
||||
}
|
||||
|
||||
this.healthCheckTimer = setInterval(async () => {
|
||||
const isHealthy = await this.checkHealth();
|
||||
if (!isHealthy && this._isRunning) {
|
||||
log.warn('[OpenCodeService] Health check failed, attempting restart...');
|
||||
await this.restart();
|
||||
}
|
||||
}, HEALTH_CHECK_INTERVAL);
|
||||
}
|
||||
|
||||
async start(): Promise<{ success: boolean; error?: string }> {
|
||||
if (this._isRunning && this.process) {
|
||||
log.info('[OpenCodeService] Already running');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
try {
|
||||
log.info('[OpenCodeService] Starting OpenCode server...');
|
||||
|
||||
this.process = spawn('opencode', ['serve'], {
|
||||
stdio: 'pipe',
|
||||
shell: true,
|
||||
detached: false,
|
||||
});
|
||||
|
||||
this.process.stdout?.on('data', (data) => {
|
||||
log.info(`[OpenCode] ${data}`);
|
||||
});
|
||||
|
||||
this.process.stderr?.on('data', (data) => {
|
||||
log.error(`[OpenCode error] ${data}`);
|
||||
});
|
||||
|
||||
this.process.on('error', (err) => {
|
||||
log.error('[OpenCodeService] Process error:', err);
|
||||
this._isRunning = false;
|
||||
this.process = null;
|
||||
});
|
||||
|
||||
this.process.on('exit', (code) => {
|
||||
log.info(`[OpenCodeService] Process exited with code ${code}`);
|
||||
this._isRunning = false;
|
||||
this.process = null;
|
||||
});
|
||||
|
||||
const maxWaitTime = 30000;
|
||||
const checkInterval = 500;
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < maxWaitTime) {
|
||||
const isHealthy = await this.checkHealth();
|
||||
if (isHealthy) {
|
||||
break;
|
||||
}
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, checkInterval));
|
||||
}
|
||||
|
||||
const finalHealth = await this.checkHealth();
|
||||
if (!finalHealth) {
|
||||
log.warn('[OpenCodeService] Health check failed after max wait time');
|
||||
}
|
||||
|
||||
this._isRunning = true;
|
||||
this.restartAttempts = 0;
|
||||
this.startHealthCheck();
|
||||
|
||||
log.info(`[OpenCodeService] Started successfully on port ${this.port}`);
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
log.error('[OpenCodeService] Failed to start:', error);
|
||||
this._isRunning = false;
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<{ success: boolean; error?: string }> {
|
||||
if (this.healthCheckTimer) {
|
||||
clearInterval(this.healthCheckTimer);
|
||||
this.healthCheckTimer = null;
|
||||
}
|
||||
|
||||
if (!this.process) {
|
||||
log.info('[OpenCodeService] Not running');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
try {
|
||||
log.info('[OpenCodeService] Stopping...');
|
||||
|
||||
const pid = this.process?.pid;
|
||||
const processRef = this.process;
|
||||
this.process = null;
|
||||
this._isRunning = false;
|
||||
|
||||
if (pid && process.platform === 'win32') {
|
||||
return new Promise((resolve) => {
|
||||
exec(`taskkill /F /T /PID ${pid}`, (error) => {
|
||||
if (error) {
|
||||
log.warn('[OpenCodeService] taskkill failed, process may already be dead:', error.message);
|
||||
}
|
||||
this.restartAttempts = 0;
|
||||
log.info('[OpenCodeService] Stopped');
|
||||
resolve({ success: true });
|
||||
});
|
||||
});
|
||||
} else if (pid && processRef) {
|
||||
processRef.kill('SIGTERM');
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 1000));
|
||||
if (!processRef.killed) {
|
||||
processRef.kill('SIGKILL');
|
||||
}
|
||||
this.restartAttempts = 0;
|
||||
log.info('[OpenCodeService] Stopped');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
this.restartAttempts = 0;
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
log.error('[OpenCodeService] Failed to stop:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const opencodeService = new OpenCodeService();
|
||||
190
electron/services/sddService.ts
Normal file
190
electron/services/sddService.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { spawn, exec, ChildProcess } from 'child_process';
|
||||
import { app } from 'electron';
|
||||
import path from 'path';
|
||||
import log from 'electron-log';
|
||||
|
||||
const SDD_PORT = 9998;
|
||||
const HEALTH_CHECK_INTERVAL = 10000;
|
||||
const HEALTH_CHECK_TIMEOUT = 2000;
|
||||
|
||||
class SDDService {
|
||||
private process: ChildProcess | null = null;
|
||||
private processPid: number | null = null;
|
||||
private healthCheckTimer: NodeJS.Timeout | null = null;
|
||||
private _isRunning = false;
|
||||
private _isStarting = false;
|
||||
|
||||
get port(): number {
|
||||
return SDD_PORT;
|
||||
}
|
||||
|
||||
isRunning(): boolean {
|
||||
return this._isRunning;
|
||||
}
|
||||
|
||||
getStatus(): { running: boolean; port: number } {
|
||||
return {
|
||||
running: this._isRunning,
|
||||
port: this.port,
|
||||
};
|
||||
}
|
||||
|
||||
private getExePath(): string {
|
||||
const exeName = 'XCSDD.exe';
|
||||
const isPackaged = app.isPackaged;
|
||||
|
||||
let basePath: string;
|
||||
if (isPackaged) {
|
||||
basePath = path.join(process.resourcesPath, 'app.asar.unpacked');
|
||||
} else {
|
||||
basePath = process.cwd();
|
||||
}
|
||||
|
||||
return path.join(basePath, 'services', 'xcsdd', exeName);
|
||||
}
|
||||
|
||||
private getExeArgs(): string[] {
|
||||
return ['--port', this.port.toString(), '--headless'];
|
||||
}
|
||||
|
||||
private async checkHealth(): Promise<boolean> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), HEALTH_CHECK_TIMEOUT);
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${this.port}`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
return response.ok || response.status === 401;
|
||||
} catch (error) {
|
||||
log.warn('[SDDService] Health check failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private startHealthCheck(): void {
|
||||
if (this.healthCheckTimer) {
|
||||
clearInterval(this.healthCheckTimer);
|
||||
}
|
||||
|
||||
this.healthCheckTimer = setInterval(async () => {
|
||||
const isHealthy = await this.checkHealth();
|
||||
if (!isHealthy && this._isRunning) {
|
||||
log.warn('[SDDService] Service not responding');
|
||||
}
|
||||
}, HEALTH_CHECK_INTERVAL);
|
||||
}
|
||||
|
||||
async start(): Promise<{ success: boolean; error?: string }> {
|
||||
if (this._isRunning && this.process) {
|
||||
log.info('[SDDService] Already running');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
if (this._isStarting) {
|
||||
log.info('[SDDService] Already starting');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
this._isStarting = true;
|
||||
|
||||
try {
|
||||
const exePath = this.getExePath();
|
||||
const exeArgs = this.getExeArgs();
|
||||
log.info(`[SDDService] Starting from: ${exePath} with args: ${exeArgs.join(' ')}`);
|
||||
|
||||
this.process = spawn(exePath, exeArgs, {
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
this.processPid = this.process.pid ?? null;
|
||||
|
||||
this.process.stdout?.on('data', (data) => {
|
||||
log.info(`[SDD] ${data}`);
|
||||
});
|
||||
|
||||
this.process.stderr?.on('data', (data) => {
|
||||
log.error(`[SDD error] ${data}`);
|
||||
});
|
||||
|
||||
this.process.on('error', (err) => {
|
||||
log.error('[SDDService] Process error:', err);
|
||||
this._isRunning = false;
|
||||
this._isStarting = false;
|
||||
this.process = null;
|
||||
this.processPid = null;
|
||||
});
|
||||
|
||||
this.process.on('exit', (code) => {
|
||||
log.info(`[SDDService] Process exited with code ${code}`);
|
||||
this._isRunning = false;
|
||||
this._isStarting = false;
|
||||
this.process = null;
|
||||
this.processPid = null;
|
||||
});
|
||||
|
||||
const maxWaitTime = 30000;
|
||||
const checkInterval = 500;
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < maxWaitTime) {
|
||||
const isHealthy = await this.checkHealth();
|
||||
if (isHealthy) {
|
||||
break;
|
||||
}
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, checkInterval));
|
||||
}
|
||||
|
||||
const finalHealth = await this.checkHealth();
|
||||
if (!finalHealth) {
|
||||
log.warn('[SDDService] Health check failed after max wait time');
|
||||
}
|
||||
|
||||
this._isRunning = true;
|
||||
this._isStarting = false;
|
||||
this.startHealthCheck();
|
||||
|
||||
log.info(`[SDDService] Started successfully on port ${this.port}`);
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
log.error('[SDDService] Failed to start:', error);
|
||||
this._isRunning = false;
|
||||
this._isStarting = false;
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<{ success: boolean; error?: string }> {
|
||||
if (this.healthCheckTimer) {
|
||||
clearInterval(this.healthCheckTimer);
|
||||
this.healthCheckTimer = null;
|
||||
}
|
||||
|
||||
if (!this.processPid) {
|
||||
log.info('[SDDService] Not running');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
log.info(`[SDDService] Stopping process ${this.processPid}...`);
|
||||
|
||||
exec(`taskkill /F /T /PID ${this.processPid}`, (error) => {
|
||||
this.process = null;
|
||||
this.processPid = null;
|
||||
this._isRunning = false;
|
||||
|
||||
if (error) {
|
||||
log.error('[SDDService] Failed to stop:', error);
|
||||
resolve({ success: false, error: error.message });
|
||||
} else {
|
||||
log.info('[SDDService] Stopped');
|
||||
resolve({ success: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const sddService = new SDDService();
|
||||
190
electron/services/terminalService.ts
Normal file
190
electron/services/terminalService.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { spawn, exec, ChildProcess } from 'child_process';
|
||||
import { app } from 'electron';
|
||||
import path from 'path';
|
||||
import log from 'electron-log';
|
||||
|
||||
const TERMINAL_PORT = 9997;
|
||||
const HEALTH_CHECK_INTERVAL = 10000;
|
||||
const HEALTH_CHECK_TIMEOUT = 2000;
|
||||
|
||||
class TerminalService {
|
||||
private process: ChildProcess | null = null;
|
||||
private processPid: number | null = null;
|
||||
private healthCheckTimer: NodeJS.Timeout | null = null;
|
||||
private _isRunning = false;
|
||||
private _isStarting = false;
|
||||
|
||||
get port(): number {
|
||||
return TERMINAL_PORT;
|
||||
}
|
||||
|
||||
isRunning(): boolean {
|
||||
return this._isRunning;
|
||||
}
|
||||
|
||||
getStatus(): { running: boolean; port: number } {
|
||||
return {
|
||||
running: this._isRunning,
|
||||
port: this.port,
|
||||
};
|
||||
}
|
||||
|
||||
private getExePath(): string {
|
||||
const exeName = 'XCTerminal.exe';
|
||||
const isPackaged = app.isPackaged;
|
||||
|
||||
let basePath: string;
|
||||
if (isPackaged) {
|
||||
basePath = path.join(process.resourcesPath, 'app.asar.unpacked');
|
||||
} else {
|
||||
basePath = process.cwd();
|
||||
}
|
||||
|
||||
return path.join(basePath, 'services', 'xcterminal', exeName);
|
||||
}
|
||||
|
||||
private getExeArgs(): string[] {
|
||||
return ['--port', this.port.toString()];
|
||||
}
|
||||
|
||||
private async checkHealth(): Promise<boolean> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), HEALTH_CHECK_TIMEOUT);
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${this.port}`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
return response.ok || response.status === 401;
|
||||
} catch (error) {
|
||||
log.warn('[TerminalService] Health check failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private startHealthCheck(): void {
|
||||
if (this.healthCheckTimer) {
|
||||
clearInterval(this.healthCheckTimer);
|
||||
}
|
||||
|
||||
this.healthCheckTimer = setInterval(async () => {
|
||||
const isHealthy = await this.checkHealth();
|
||||
if (!isHealthy && this._isRunning) {
|
||||
log.warn('[TerminalService] Service not responding');
|
||||
}
|
||||
}, HEALTH_CHECK_INTERVAL);
|
||||
}
|
||||
|
||||
async start(): Promise<{ success: boolean; error?: string }> {
|
||||
if (this._isRunning && this.process) {
|
||||
log.info('[TerminalService] Already running');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
if (this._isStarting) {
|
||||
log.info('[TerminalService] Already starting');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
this._isStarting = true;
|
||||
|
||||
try {
|
||||
const exePath = this.getExePath();
|
||||
const exeArgs = this.getExeArgs();
|
||||
log.info(`[TerminalService] Starting from: ${exePath} with args: ${exeArgs.join(' ')}`);
|
||||
|
||||
this.process = spawn(exePath, exeArgs, {
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
this.processPid = this.process.pid ?? null;
|
||||
|
||||
this.process.stdout?.on('data', (data) => {
|
||||
log.info(`[Terminal] ${data}`);
|
||||
});
|
||||
|
||||
this.process.stderr?.on('data', (data) => {
|
||||
log.error(`[Terminal error] ${data}`);
|
||||
});
|
||||
|
||||
this.process.on('error', (err) => {
|
||||
log.error('[TerminalService] Process error:', err);
|
||||
this._isRunning = false;
|
||||
this._isStarting = false;
|
||||
this.process = null;
|
||||
this.processPid = null;
|
||||
});
|
||||
|
||||
this.process.on('exit', (code) => {
|
||||
log.info(`[TerminalService] Process exited with code ${code}`);
|
||||
this._isRunning = false;
|
||||
this._isStarting = false;
|
||||
this.process = null;
|
||||
this.processPid = null;
|
||||
});
|
||||
|
||||
const maxWaitTime = 30000;
|
||||
const checkInterval = 500;
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < maxWaitTime) {
|
||||
const isHealthy = await this.checkHealth();
|
||||
if (isHealthy) {
|
||||
break;
|
||||
}
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, checkInterval));
|
||||
}
|
||||
|
||||
const finalHealth = await this.checkHealth();
|
||||
if (!finalHealth) {
|
||||
log.warn('[TerminalService] Health check failed after max wait time');
|
||||
}
|
||||
|
||||
this._isRunning = true;
|
||||
this._isStarting = false;
|
||||
this.startHealthCheck();
|
||||
|
||||
log.info(`[TerminalService] Started successfully on port ${this.port}`);
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
log.error('[TerminalService] Failed to start:', error);
|
||||
this._isRunning = false;
|
||||
this._isStarting = false;
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<{ success: boolean; error?: string }> {
|
||||
if (this.healthCheckTimer) {
|
||||
clearInterval(this.healthCheckTimer);
|
||||
this.healthCheckTimer = null;
|
||||
}
|
||||
|
||||
if (!this.processPid) {
|
||||
log.info('[TerminalService] Not running');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
log.info(`[TerminalService] Stopping process ${this.processPid}...`);
|
||||
|
||||
exec(`taskkill /F /T /PID ${this.processPid}`, (error) => {
|
||||
this.process = null;
|
||||
this.processPid = null;
|
||||
this._isRunning = false;
|
||||
|
||||
if (error) {
|
||||
log.error('[TerminalService] Failed to stop:', error);
|
||||
resolve({ success: false, error: error.message });
|
||||
} else {
|
||||
log.info('[TerminalService] Stopped');
|
||||
resolve({ success: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const terminalService = new TerminalService();
|
||||
190
electron/services/xcOpenCodeWebService.ts
Normal file
190
electron/services/xcOpenCodeWebService.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { spawn, exec, ChildProcess } from 'child_process';
|
||||
import { app } from 'electron';
|
||||
import path from 'path';
|
||||
import log from 'electron-log';
|
||||
|
||||
const XCOPENCODEWEB_PORT = 9999;
|
||||
const HEALTH_CHECK_INTERVAL = 10000;
|
||||
const HEALTH_CHECK_TIMEOUT = 2000;
|
||||
|
||||
class XCOpenCodeWebService {
|
||||
private process: ChildProcess | null = null;
|
||||
private processPid: number | null = null;
|
||||
private healthCheckTimer: NodeJS.Timeout | null = null;
|
||||
private _isRunning = false;
|
||||
private _isStarting = false;
|
||||
|
||||
get port(): number {
|
||||
return XCOPENCODEWEB_PORT;
|
||||
}
|
||||
|
||||
isRunning(): boolean {
|
||||
return this._isRunning;
|
||||
}
|
||||
|
||||
getStatus(): { running: boolean; port: number } {
|
||||
return {
|
||||
running: this._isRunning,
|
||||
port: this.port,
|
||||
};
|
||||
}
|
||||
|
||||
private getExePath(): string {
|
||||
const exeName = 'XCOpenCodeWeb.exe';
|
||||
const isPackaged = app.isPackaged;
|
||||
|
||||
let basePath: string;
|
||||
if (isPackaged) {
|
||||
basePath = path.join(process.resourcesPath, 'app.asar.unpacked');
|
||||
} else {
|
||||
basePath = process.cwd();
|
||||
}
|
||||
|
||||
return path.join(basePath, 'services', 'xcopencodeweb', exeName);
|
||||
}
|
||||
|
||||
private getExeArgs(): string[] {
|
||||
return ['--port', this.port.toString()];
|
||||
}
|
||||
|
||||
private async checkHealth(): Promise<boolean> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), HEALTH_CHECK_TIMEOUT);
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${this.port}`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
return response.ok || response.status === 401;
|
||||
} catch (error) {
|
||||
log.warn('[XCOpenCodeWebService] Health check failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private startHealthCheck(): void {
|
||||
if (this.healthCheckTimer) {
|
||||
clearInterval(this.healthCheckTimer);
|
||||
}
|
||||
|
||||
this.healthCheckTimer = setInterval(async () => {
|
||||
const isHealthy = await this.checkHealth();
|
||||
if (!isHealthy && this._isRunning) {
|
||||
log.warn('[XCOpenCodeWebService] Service not responding');
|
||||
}
|
||||
}, HEALTH_CHECK_INTERVAL);
|
||||
}
|
||||
|
||||
async start(): Promise<{ success: boolean; error?: string }> {
|
||||
if (this._isRunning && this.process) {
|
||||
log.info('[XCOpenCodeWebService] Already running');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
if (this._isStarting) {
|
||||
log.info('[XCOpenCodeWebService] Already starting');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
this._isStarting = true;
|
||||
|
||||
try {
|
||||
const exePath = this.getExePath();
|
||||
const exeArgs = this.getExeArgs();
|
||||
log.info(`[XCOpenCodeWebService] Starting from: ${exePath} with args: ${exeArgs.join(' ')}`);
|
||||
|
||||
this.process = spawn(exePath, exeArgs, {
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
this.processPid = this.process.pid ?? null;
|
||||
|
||||
this.process.stdout?.on('data', (data) => {
|
||||
log.info(`[XCOpenCodeWeb] ${data}`);
|
||||
});
|
||||
|
||||
this.process.stderr?.on('data', (data) => {
|
||||
log.error(`[XCOpenCodeWeb error] ${data}`);
|
||||
});
|
||||
|
||||
this.process.on('error', (err) => {
|
||||
log.error('[XCOpenCodeWebService] Process error:', err);
|
||||
this._isRunning = false;
|
||||
this._isStarting = false;
|
||||
this.process = null;
|
||||
this.processPid = null;
|
||||
});
|
||||
|
||||
this.process.on('exit', (code) => {
|
||||
log.info(`[XCOpenCodeWebService] Process exited with code ${code}`);
|
||||
this._isRunning = false;
|
||||
this._isStarting = false;
|
||||
this.process = null;
|
||||
this.processPid = null;
|
||||
});
|
||||
|
||||
const maxWaitTime = 30000;
|
||||
const checkInterval = 500;
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < maxWaitTime) {
|
||||
const isHealthy = await this.checkHealth();
|
||||
if (isHealthy) {
|
||||
break;
|
||||
}
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, checkInterval));
|
||||
}
|
||||
|
||||
const finalHealth = await this.checkHealth();
|
||||
if (!finalHealth) {
|
||||
log.warn('[XCOpenCodeWebService] Health check failed after max wait time');
|
||||
}
|
||||
|
||||
this._isRunning = true;
|
||||
this._isStarting = false;
|
||||
this.startHealthCheck();
|
||||
|
||||
log.info(`[XCOpenCodeWebService] Started successfully on port ${this.port}`);
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
log.error('[XCOpenCodeWebService] Failed to start:', error);
|
||||
this._isRunning = false;
|
||||
this._isStarting = false;
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<{ success: boolean; error?: string }> {
|
||||
if (this.healthCheckTimer) {
|
||||
clearInterval(this.healthCheckTimer);
|
||||
this.healthCheckTimer = null;
|
||||
}
|
||||
|
||||
if (!this.processPid) {
|
||||
log.info('[XCOpenCodeWebService] Not running');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
log.info(`[XCOpenCodeWebService] Stopping process ${this.processPid}...`);
|
||||
|
||||
exec(`taskkill /F /PID ${this.processPid}`, (error) => {
|
||||
this.process = null;
|
||||
this.processPid = null;
|
||||
this._isRunning = false;
|
||||
|
||||
if (error) {
|
||||
log.error('[XCOpenCodeWebService] Failed to stop:', error);
|
||||
resolve({ success: false, error: error.message });
|
||||
} else {
|
||||
log.info('[XCOpenCodeWebService] Stopped');
|
||||
resolve({ success: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const xcOpenCodeWebService = new XCOpenCodeWebService();
|
||||
@@ -13,6 +13,8 @@ class ElectronState {
|
||||
isDev: false,
|
||||
}
|
||||
|
||||
private windows = new Map<number, BrowserWindow>()
|
||||
|
||||
getMainWindow(): BrowserWindow | null {
|
||||
return this.state.mainWindow
|
||||
}
|
||||
@@ -37,7 +39,24 @@ class ElectronState {
|
||||
this.state.isDev = isDev
|
||||
}
|
||||
|
||||
addWindow(window: BrowserWindow): void {
|
||||
this.windows.set(window.id, window)
|
||||
}
|
||||
|
||||
removeWindow(id: number): void {
|
||||
this.windows.delete(id)
|
||||
}
|
||||
|
||||
getWindow(id: number): BrowserWindow | undefined {
|
||||
return this.windows.get(id)
|
||||
}
|
||||
|
||||
getAllWindows(): BrowserWindow[] {
|
||||
return Array.from(this.windows.values())
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.windows.clear()
|
||||
this.state = {
|
||||
mainWindow: null,
|
||||
serverPort: 3001,
|
||||
|
||||
591
package-lock.json
generated
591
package-lock.json
generated
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "xcnote",
|
||||
"name": "xcdesktop",
|
||||
"version": "0.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "xcnote",
|
||||
"name": "xcdesktop",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
@@ -20,11 +20,15 @@
|
||||
"@milkdown/preset-commonmark": "^7.18.0",
|
||||
"@milkdown/preset-gfm": "^7.18.0",
|
||||
"@milkdown/react": "^7.18.0",
|
||||
"@opencode-ai/sdk": "^1.2.26",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@types/jszip": "^3.4.0",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/prismjs": "^1.26.5",
|
||||
"axios": "^1.13.5",
|
||||
"chokidar": "^5.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.1",
|
||||
@@ -40,6 +44,7 @@
|
||||
"react-router-dom": "^7.3.0",
|
||||
"recharts": "^3.7.0",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
@@ -3305,6 +3310,12 @@
|
||||
"url": "https://github.com/sponsors/ocavue"
|
||||
}
|
||||
},
|
||||
"node_modules/@opencode-ai/sdk": {
|
||||
"version": "1.2.26",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.2.26.tgz",
|
||||
"integrity": "sha512-HPB+0pfvTMPj2KEjNLF3oqgldKW8koTJ7ssqXwzndazqxS+gUynzvdIKIQP4+QIInNcc5nJMG9JtfLcePGgTLQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@pkgjs/parseargs": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||
@@ -3323,6 +3334,419 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/primitive": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
||||
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/react-alert-dialog": {
|
||||
"version": "1.1.15",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz",
|
||||
"integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-dialog": "1.1.15",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
|
||||
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-context": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
|
||||
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dialog": {
|
||||
"version": "1.1.15",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
|
||||
"integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.11",
|
||||
"@radix-ui/react-focus-guards": "1.1.3",
|
||||
"@radix-ui/react-focus-scope": "1.1.7",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-portal": "1.1.9",
|
||||
"@radix-ui/react-presence": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-slot": "1.2.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"aria-hidden": "^1.2.4",
|
||||
"react-remove-scroll": "^2.6.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dismissable-layer": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
|
||||
"integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||
"@radix-ui/react-use-escape-keydown": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-focus-guards": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
|
||||
"integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-focus-scope": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz",
|
||||
"integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-id": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
|
||||
"integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-portal": {
|
||||
"version": "1.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
|
||||
"integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-presence": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
|
||||
"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
|
||||
"integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
||||
"integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-controllable-state": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
|
||||
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-effect-event": "0.0.2",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-effect-event": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
|
||||
"integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-escape-keydown": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
|
||||
"integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-layout-effect": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
|
||||
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
||||
@@ -4308,7 +4732,7 @@
|
||||
"version": "18.3.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
@@ -5464,6 +5888,18 @@
|
||||
"dev": true,
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/aria-hidden": {
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
|
||||
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/aria-query": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
|
||||
@@ -6313,6 +6749,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/class-variance-authority": {
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
|
||||
"integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://polar.sh/cva"
|
||||
}
|
||||
},
|
||||
"node_modules/cli-cursor": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz",
|
||||
@@ -7132,6 +7580,12 @@
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/detect-node-es": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
|
||||
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/devlop": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
|
||||
@@ -8602,6 +9056,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-nonce": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
|
||||
"integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
@@ -12680,6 +13143,53 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-remove-scroll": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
||||
"integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-remove-scroll-bar": "^2.3.7",
|
||||
"react-style-singleton": "^2.2.3",
|
||||
"tslib": "^2.1.0",
|
||||
"use-callback-ref": "^1.3.3",
|
||||
"use-sidecar": "^1.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-remove-scroll-bar": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
|
||||
"integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-style-singleton": "^2.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.13.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.0.tgz",
|
||||
@@ -12731,6 +13241,28 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/react-style-singleton": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
||||
"integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"get-nonce": "^1.0.0",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/read-binary-file-arch": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz",
|
||||
@@ -13943,6 +14475,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tailwind-merge": {
|
||||
"version": "3.5.0",
|
||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz",
|
||||
"integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/dcastil"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "3.4.19",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
||||
@@ -14901,6 +15443,49 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-callback-ref": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
|
||||
"integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-sidecar": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
|
||||
"integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"detect-node-es": "^1.1.0",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
|
||||
10
package.json
10
package.json
@@ -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"
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -1,77 +1,2 @@
|
||||
APP_NAME = Xuanchi Git
|
||||
RUN_USER = xuanchi
|
||||
WORK_PATH = C:\Users\xuanchi\Desktop\remote\gitea
|
||||
RUN_MODE = prod
|
||||
|
||||
[database]
|
||||
DB_TYPE = sqlite3
|
||||
HOST = 127.0.0.1:5432
|
||||
NAME = gitea
|
||||
USER = gitea
|
||||
PASSWD =
|
||||
SCHEMA =
|
||||
SSL_MODE = disable
|
||||
PATH = C:\Users\xuanchi\Desktop\remote\gitea\data\gitea.db
|
||||
LOG_SQL = false
|
||||
|
||||
[repository]
|
||||
ROOT = C:/Users/xuanchi/Desktop/remote/gitea/data/gitea-repositories
|
||||
|
||||
[server]
|
||||
SSH_DOMAIN = localhost
|
||||
DOMAIN = localhost
|
||||
HTTP_PORT = 3001
|
||||
ROOT_URL = http://localhost:3001/
|
||||
APP_DATA_PATH = C:\Users\xuanchi\Desktop\remote\gitea\data
|
||||
DISABLE_SSH = false
|
||||
SSH_PORT = 22
|
||||
LFS_START_SERVER = true
|
||||
LFS_JWT_SECRET = R7kPD0XqG0zTeGhLu1r8t4h-Y3FfofYW1_6GPhNnVZg
|
||||
OFFLINE_MODE = true
|
||||
|
||||
[lfs]
|
||||
PATH = C:/Users/xuanchi/Desktop/remote/gitea/data/lfs
|
||||
|
||||
[mailer]
|
||||
ENABLED = false
|
||||
|
||||
[service]
|
||||
REGISTER_EMAIL_CONFIRM = false
|
||||
ENABLE_NOTIFY_MAIL = false
|
||||
DISABLE_REGISTRATION = false
|
||||
ALLOW_ONLY_EXTERNAL_REGISTRATION = false
|
||||
ENABLE_CAPTCHA = false
|
||||
REQUIRE_SIGNIN_VIEW = false
|
||||
DEFAULT_KEEP_EMAIL_PRIVATE = false
|
||||
DEFAULT_ALLOW_CREATE_ORGANIZATION = true
|
||||
DEFAULT_ENABLE_TIMETRACKING = true
|
||||
NO_REPLY_ADDRESS = noreply.localhost
|
||||
|
||||
[openid]
|
||||
ENABLE_OPENID_SIGNIN = true
|
||||
ENABLE_OPENID_SIGNUP = true
|
||||
|
||||
[cron.update_checker]
|
||||
ENABLED = false
|
||||
|
||||
[session]
|
||||
PROVIDER = file
|
||||
|
||||
[log]
|
||||
MODE = console
|
||||
LEVEL = info
|
||||
ROOT_PATH = C:/Users/xuanchi/Desktop/remote/gitea/log
|
||||
|
||||
[repository.pull-request]
|
||||
DEFAULT_MERGE_STYLE = merge
|
||||
|
||||
[repository.signing]
|
||||
DEFAULT_TRUST_MODEL = committer
|
||||
|
||||
[security]
|
||||
INSTALL_LOCK = true
|
||||
INTERNAL_TOKEN = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE3NzI2Mzc5MjN9.PnM7mBbYjj8dz5dHYDwncvicVHOllHb0cWM9lSUdqIM
|
||||
PASSWORD_HASH_ALGO = pbkdf2
|
||||
|
||||
[oauth2]
|
||||
JWT_SECRET = aLiiycJwXwTxX9ZaE2By40OGAxkVcPsOwz-WzJWHfzA
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.0 KiB |
Binary file not shown.
@@ -1,23 +0,0 @@
|
||||
[diff]
|
||||
algorithm = histogram
|
||||
[core]
|
||||
logallrefupdates = true
|
||||
quotePath = false
|
||||
commitGraph = true
|
||||
longpaths = true
|
||||
[gc]
|
||||
reflogexpire = 90
|
||||
writeCommitGraph = true
|
||||
[user]
|
||||
name = Gitea
|
||||
email = gitea@fake.local
|
||||
[receive]
|
||||
advertisePushOptions = true
|
||||
procReceiveRefs = refs/for
|
||||
[fetch]
|
||||
writeCommitGraph = true
|
||||
[safe]
|
||||
directory = *
|
||||
[uploadpack]
|
||||
allowfilter = true
|
||||
allowAnySHA1InWant = true
|
||||
@@ -1 +0,0 @@
|
||||
{"storage":"boltdb","index_type":"scorch"}
|
||||
@@ -1 +0,0 @@
|
||||
{"version":5}
|
||||
Binary file not shown.
@@ -1,52 +0,0 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIJQQIBADANBgkqhkiG9w0BAQEFAASCCSswggknAgEAAoICAQDE959xE4ZOnhPD
|
||||
4iDxlW5dVyXkzpbqADw9+N1D8vzCpvG2937zuQsMQ5BL1Qhzb6fQ8+Mo77TnDFLQ
|
||||
zXSW2r/zs1QNHZeCuPN9etrlkNKQT6zLFXobRXDeHf/CDvI2l6gJI1s9sMHgurm2
|
||||
XBRefVFqJUkeR3CLw2vNn09FxnwGQoTMyUejuA+n6ITII/0RDEgXOdZwKDOEXZom
|
||||
N7J25YPhsYZ1BGj38tysb/gWNhsFrxVCHPOGiuDll5x5fGMRmb436PvvWktX0Yfg
|
||||
4NTa6Re5IHpVCSAQR64HD8j0FsGsMDvXhrja5mdRWWjgnbuprtr/f8ei2AEj8CCe
|
||||
mh7OKb6y1pryLq12hins4cw3PdzS/3+I0VSa5uUpdvZRymjMLJr9tD2UdfJWr6lB
|
||||
Jp0U0wQ0vBl08kHsTIBOPGJlFtSZtwzP8+C2eAEw20C15qh7sE/NkbS5oyV0XZfB
|
||||
gtVB0W+FCJNfC5HRuBl3x0Ei0jpFG/QncuHXXripgF/bNcDp+K5iGyJAWMSb9aJp
|
||||
3lmijC99FGPMLiA4j5QkcR82yIWEVlrgYVEnP9dqaBEt4Rs2Gl1618uj0zdNq6Nj
|
||||
DyBB6zihf0PnBjvGSG2uqQKz2236MvhxYmPn8vxDuvq7/xdax0So+OGuptbHXhDR
|
||||
aLNqYCw24Vqg2sXhiuHbdDMrGQUlgQIDAQABAoICAFtlM3y6vJV6UF2Sbgrrddyl
|
||||
9ZVoLyrBlTKEadd/xr1jzcFCsovRD0lPiINHhLZb1xjkMkHqiJy1YTA2RaVCN9OT
|
||||
IKs9UfJ8c6+D9FKVkr8X2WwauSAyZp8KeITJfqbKVFR5LXtAq5XlzwrJS0JVEBQa
|
||||
3QTJzXVs5nbxN01/PkmBUDHeK/nSDKGzCPn9iQ4CDumIEvLUFKOU81RMf+kfssRl
|
||||
JajitPponPD+u7VCweMvTMPyvyVtB9JBOYA9sZKXLmavG0gDM/a56Tz11o8FndZv
|
||||
NZSDuXcUa7InJu3sKU4Gy53Ei743LzXWMQ4Y4t1nn4Ly/eWYKV9NqzUs+qJbSHrO
|
||||
cmFqYr6ypG86TbGk1ji3eDRjmvsUYM/lTNurAh9BPzGz0mTlrUGXc+OPmAU1LD6T
|
||||
EeArMLHVxunON27T6ytHUV4VDbd8oa1KzsdiKEW71/WsK09kFLkOcGVR30OzLni5
|
||||
pQRxUukVX7twcU4hmrheSNB+CMtxACfHRxPalczVCkX/xp2jxwfPGjqr4aSQqQfK
|
||||
+hMDy5/jc6PrhPd7RSadWPVnFYJaYofWXgi/uU+26jrRmNswetNdvPv9zu5nS1Hs
|
||||
VFK8Q5zgqsdG+QLEyNWtnz5fwkNMx49cB154V8AR1ZI6XLgbRUL069gNpvHzLl/N
|
||||
eTb9MKj2d1/b6f3XZxEpAoIBAQDz7tEI0YC1Q5M0EQC0j8zvZy5FLP4NtwE9ZbHt
|
||||
NsjsHC5xIb3ujhtIje66X/5cnwGy0Bh2QbMDeFXWKlHcw+CiFe1/13kLLUoGetMN
|
||||
yHXLPCVx3iYNIopZgG8Db7qM7rYkmgVyE6VWyyMzVrFbO/rvtX8BK9jjrDVNRzsF
|
||||
g1MvGpqSPZu4O3j5BK13U0+eBhTQIvVijvUVCc50ftQHXjhwGfvUsXoggemG0Q89
|
||||
UpcKHvsAZbPOtP16HHcTuWk1SyF4qeVN+z1O+gOqebXW8qdzIBsDE2kUcq9bube5
|
||||
5dJ8CknbKNYc/dTIMEdKu2zXDK57eL8OUZcT3csuI001d7JfAoIBAQDOtgfRub07
|
||||
xNAeH9zK/4/5Smj2D+Ja6C6a37s5fsS1m1KhG0JvD7f4dhABxIf7PY0mvyxSpJt9
|
||||
LzyYPzR8B/DoaM5L3aDxcIhsp7JQ+STj1V2SKsNW8M4kRTiEPZh81jzZYtBCxe1o
|
||||
sLu3lhICusrLtfSSbix2ijMdPDEe1aDMT/P3TVrHUk2u5fMrvZHDB6VqvidXqLyG
|
||||
R4ifF4eblfbWPtlnNIh4V2vCX4+dfH25oyaO/KKTMuV+0h2+h6AjTOcV2ntXlyZK
|
||||
/oUDWRPbM6m019DEBoG9VBawyY6aGTM44RHjKBBz6itj938TlseF2qm4BPctfIF+
|
||||
gVvKqwSM/PQfAoIBAHCEmlts9+ek0gPUS/T919QeThOOm2mMHsBJZnc7LBbtMOby
|
||||
X3/ogOFIxvOlT9k9ZzUqE/6Ic6CII1/0iWpB2B4r6y9rHuRu8ZRnl27mJp+mkMcj
|
||||
Z33rjtGWEp8NLInRmqbrfNOQCFYuwX/u30RsOGXV3E2YAiWSy8tnrevvFbHGncIT
|
||||
NP8YP8btx24hObp0p6kSVwotUxNvQJIv5nG3nmTnN2h2rRTNmACd8l+g9xauD62x
|
||||
O+1/QuTOuIaaodL5YukbxS/hUfhaDtLV7XDG4UKTbqJOk8vg0s6Grh7Lyfl5bXPw
|
||||
NEOPOlVVH61zItNXgCxoGAjszblWN2CC3BxrqBUCggEASt9QMbzvOAjvwRmVZcnv
|
||||
okI7hnT2bisPRnWnYQnzwjwCT+yJwaSV5F8PKTTAdFY1HEW+jiilUVCcyCCMqChQ
|
||||
MD5WCtC6DPnP0FtlkULNA+EyxVDL9F/Aqw6PjAarhvwqiirqeGUsuvDY7YRj/a0e
|
||||
6256qddSL5WbMgmtWRfT6G1FVtwj93JuRN1xmPRPKa9JUUKTCYNK1fBvIgDp04cc
|
||||
IzockO9MRxqTI5JteIOxHl5kBwKm+F5FFgyRTYPekyq1wQqkBnPvINbT4wSO1qT9
|
||||
4U0Shw48TBF7LomzJ0ndbcrIKdlHLFUzZkAtPTEuD+PF+auCxG0GkoXUc7JCMbcl
|
||||
zQKCAQBuyk/G/9G458QPfvp4if0TNMPuX+yzcmjEcOf4x2vgTA1t6me4nPm4xEF2
|
||||
TUO/hECYiNG1DyeN/rRdW6lce9CZMaP9D1Dc1g4fAGfxBBhrBvV7o4h0dffpOTNh
|
||||
wyKx08C8rmImmvlt3aWyH+2sTe43GScbchSkEid8M/0RpaKp4b61zBhGUgYspKNS
|
||||
QL+7dTq9iosBTaR+zWKBv6cfOahxXFdVQYkh1woDCLdWX5FCKjV/gD46UFEtMTYf
|
||||
ABcF+5u2Pbxsf49gUN1wiJBx0o5+81IB34lIscp8wA+3ZRUeDnAbaEPTeoVIP+Of
|
||||
ohu0miobcXoUkKle/P8TFqStcnW6
|
||||
-----END PRIVATE KEY-----
|
||||
Binary file not shown.
@@ -1 +0,0 @@
|
||||
MANIFEST-000050
|
||||
@@ -1 +0,0 @@
|
||||
MANIFEST-000048
|
||||
@@ -1,223 +0,0 @@
|
||||
=============== Mar 4, 2026 (CST) ===============
|
||||
23:25:27.295431 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
||||
23:25:27.296428 db@open opening
|
||||
23:25:27.297425 version@stat F·[] S·0B[] Sc·[]
|
||||
23:25:27.297425 db@janitor F·2 G·0
|
||||
23:25:27.297425 db@open done T·997.1µs
|
||||
=============== Mar 5, 2026 (CST) ===============
|
||||
00:58:48.117008 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
||||
00:58:48.118039 version@stat F·[] S·0B[] Sc·[]
|
||||
00:58:48.118039 db@open opening
|
||||
00:58:48.118039 journal@recovery F·1
|
||||
00:58:48.118039 journal@recovery recovering @1
|
||||
00:58:48.119011 memdb@flush created L0@2 N·28 S·574B "act..igh,v28":"web..low,v19"
|
||||
00:58:48.120008 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
00:58:48.122002 db@janitor F·3 G·0
|
||||
00:58:48.122002 db@open done T·3.9631ms
|
||||
=============== Mar 5, 2026 (CST) ===============
|
||||
16:44:08.552284 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
||||
16:44:08.552284 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
16:44:08.552284 db@open opening
|
||||
16:44:08.552284 journal@recovery F·1
|
||||
16:44:08.552284 journal@recovery recovering @3
|
||||
16:44:08.553282 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
16:44:08.555630 db@janitor F·3 G·0
|
||||
16:44:08.555630 db@open done T·3.3467ms
|
||||
=============== Mar 5, 2026 (CST) ===============
|
||||
16:58:44.704290 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
||||
16:58:44.704290 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
16:58:44.704290 db@open opening
|
||||
16:58:44.704290 journal@recovery F·1
|
||||
16:58:44.704290 journal@recovery recovering @5
|
||||
16:58:44.705289 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
16:58:44.709300 db@janitor F·3 G·0
|
||||
16:58:44.709300 db@open done T·5.0092ms
|
||||
=============== Mar 5, 2026 (CST) ===============
|
||||
16:58:55.013041 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
||||
16:58:55.014038 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
16:58:55.014038 db@open opening
|
||||
16:58:55.014038 journal@recovery F·1
|
||||
16:58:55.014038 journal@recovery recovering @7
|
||||
16:58:55.014038 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
16:58:55.019025 db@janitor F·3 G·0
|
||||
16:58:55.019025 db@open done T·4.9869ms
|
||||
=============== Mar 5, 2026 (CST) ===============
|
||||
17:01:48.341808 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
||||
17:01:48.341808 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
17:01:48.341808 db@open opening
|
||||
17:01:48.341808 journal@recovery F·1
|
||||
17:01:48.341808 journal@recovery recovering @9
|
||||
17:01:48.342805 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
17:01:48.346766 db@janitor F·3 G·0
|
||||
17:01:48.346766 db@open done T·4.9576ms
|
||||
=============== Mar 5, 2026 (CST) ===============
|
||||
17:05:22.244372 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
||||
17:05:22.245370 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
17:05:22.245370 db@open opening
|
||||
17:05:22.245370 journal@recovery F·1
|
||||
17:05:22.245370 journal@recovery recovering @11
|
||||
17:05:22.245370 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
17:05:22.250356 db@janitor F·3 G·0
|
||||
17:05:22.250356 db@open done T·4.9865ms
|
||||
=============== Mar 5, 2026 (CST) ===============
|
||||
17:15:26.696181 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
||||
17:15:26.696181 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
17:15:26.696181 db@open opening
|
||||
17:15:26.696181 journal@recovery F·1
|
||||
17:15:26.696181 journal@recovery recovering @13
|
||||
17:15:26.696181 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
17:15:26.701109 db@janitor F·3 G·0
|
||||
17:15:26.701109 db@open done T·4.9277ms
|
||||
=============== Mar 5, 2026 (CST) ===============
|
||||
17:42:26.549391 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
||||
17:42:26.550343 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
17:42:26.550343 db@open opening
|
||||
17:42:26.550343 journal@recovery F·1
|
||||
17:42:26.550343 journal@recovery recovering @15
|
||||
17:42:26.552338 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
17:42:26.554337 db@janitor F·3 G·0
|
||||
17:42:26.554337 db@open done T·3.9934ms
|
||||
=============== Mar 5, 2026 (CST) ===============
|
||||
17:53:41.022244 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
||||
17:53:41.022244 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
17:53:41.022244 db@open opening
|
||||
17:53:41.022244 journal@recovery F·1
|
||||
17:53:41.022244 journal@recovery recovering @17
|
||||
17:53:41.023231 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
17:53:41.026274 db@janitor F·3 G·0
|
||||
17:53:41.026274 db@open done T·4.0304ms
|
||||
=============== Mar 5, 2026 (CST) ===============
|
||||
18:06:44.931577 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
||||
18:06:44.932090 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
18:06:44.932090 db@open opening
|
||||
18:06:44.932090 journal@recovery F·1
|
||||
18:06:44.932602 journal@recovery recovering @19
|
||||
18:06:44.933113 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
18:06:44.935926 db@janitor F·3 G·0
|
||||
18:06:44.935926 db@open done T·3.8362ms
|
||||
=============== Mar 5, 2026 (CST) ===============
|
||||
18:17:10.357320 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
||||
18:17:10.357320 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
18:17:10.357833 db@open opening
|
||||
18:17:10.357841 journal@recovery F·1
|
||||
18:17:10.357841 journal@recovery recovering @21
|
||||
18:17:10.357841 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
18:17:10.361450 db@janitor F·3 G·0
|
||||
18:17:10.361450 db@open done T·3.6088ms
|
||||
=============== Mar 5, 2026 (CST) ===============
|
||||
18:20:47.461717 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
||||
18:20:47.462247 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
18:20:47.462247 db@open opening
|
||||
18:20:47.462247 journal@recovery F·1
|
||||
18:20:47.462247 journal@recovery recovering @23
|
||||
18:20:47.464218 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
18:20:47.466420 db@janitor F·3 G·0
|
||||
18:20:47.466420 db@open done T·4.1729ms
|
||||
=============== Mar 5, 2026 (CST) ===============
|
||||
18:24:07.472215 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
||||
18:24:07.473212 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
18:24:07.473212 db@open opening
|
||||
18:24:07.473212 journal@recovery F·1
|
||||
18:24:07.473212 journal@recovery recovering @25
|
||||
18:24:07.473212 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
18:24:07.476910 db@janitor F·3 G·0
|
||||
18:24:07.476910 db@open done T·3.6976ms
|
||||
=============== Mar 5, 2026 (CST) ===============
|
||||
18:41:46.328705 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
||||
18:41:46.329704 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
18:41:46.329704 db@open opening
|
||||
18:41:46.329704 journal@recovery F·1
|
||||
18:41:46.329704 journal@recovery recovering @27
|
||||
18:41:46.329704 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
18:41:46.334694 db@janitor F·3 G·0
|
||||
18:41:46.334694 db@open done T·4.9906ms
|
||||
=============== Mar 5, 2026 (CST) ===============
|
||||
20:17:07.605654 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
||||
20:17:07.606653 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
20:17:07.606653 db@open opening
|
||||
20:17:07.606653 journal@recovery F·1
|
||||
20:17:07.606653 journal@recovery recovering @29
|
||||
20:17:07.606653 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
20:17:07.609645 db@janitor F·3 G·0
|
||||
20:17:07.609645 db@open done T·2.9923ms
|
||||
=============== Mar 5, 2026 (CST) ===============
|
||||
20:18:45.285721 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
||||
20:18:45.286227 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
20:18:45.286227 db@open opening
|
||||
20:18:45.286227 journal@recovery F·1
|
||||
20:18:45.286227 journal@recovery recovering @31
|
||||
20:18:45.286227 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
20:18:45.290050 db@janitor F·3 G·0
|
||||
20:18:45.290050 db@open done T·3.823ms
|
||||
=============== Mar 5, 2026 (CST) ===============
|
||||
20:24:14.214040 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
||||
20:24:14.215038 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
20:24:14.215038 db@open opening
|
||||
20:24:14.215038 journal@recovery F·1
|
||||
20:24:14.217038 journal@recovery recovering @33
|
||||
20:24:14.217038 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
20:24:14.221083 db@janitor F·3 G·0
|
||||
20:24:14.221083 db@open done T·6.0448ms
|
||||
=============== Mar 5, 2026 (CST) ===============
|
||||
20:27:37.450756 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
||||
20:27:37.451263 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
20:27:37.451263 db@open opening
|
||||
20:27:37.451263 journal@recovery F·1
|
||||
20:27:37.451263 journal@recovery recovering @35
|
||||
20:27:37.451263 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
20:27:37.454842 db@janitor F·3 G·0
|
||||
20:27:37.454842 db@open done T·3.579ms
|
||||
=============== Mar 5, 2026 (CST) ===============
|
||||
20:30:19.881992 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
||||
20:30:19.881992 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
20:30:19.881992 db@open opening
|
||||
20:30:19.881992 journal@recovery F·1
|
||||
20:30:19.881992 journal@recovery recovering @37
|
||||
20:30:19.882990 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
20:30:19.884984 db@janitor F·3 G·0
|
||||
20:30:19.884984 db@open done T·2.9921ms
|
||||
=============== Mar 5, 2026 (CST) ===============
|
||||
20:33:21.085745 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
||||
20:33:21.086742 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
20:33:21.086742 db@open opening
|
||||
20:33:21.086742 journal@recovery F·1
|
||||
20:33:21.086742 journal@recovery recovering @39
|
||||
20:33:21.088251 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
20:33:21.093539 db@janitor F·3 G·0
|
||||
20:33:21.093539 db@open done T·6.7975ms
|
||||
=============== Mar 5, 2026 (CST) ===============
|
||||
20:35:21.489745 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
||||
20:35:21.490699 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
20:35:21.490699 db@open opening
|
||||
20:35:21.490699 journal@recovery F·1
|
||||
20:35:21.490699 journal@recovery recovering @41
|
||||
20:35:21.491220 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
20:35:21.495194 db@janitor F·3 G·0
|
||||
20:35:21.495194 db@open done T·4.4953ms
|
||||
=============== Mar 5, 2026 (CST) ===============
|
||||
20:42:06.364290 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
||||
20:42:06.364290 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
20:42:06.364290 db@open opening
|
||||
20:42:06.364796 journal@recovery F·1
|
||||
20:42:06.364796 journal@recovery recovering @43
|
||||
20:42:06.364796 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
20:42:06.369434 db@janitor F·3 G·0
|
||||
20:42:06.369434 db@open done T·5.1446ms
|
||||
=============== Mar 5, 2026 (CST) ===============
|
||||
20:46:58.679386 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
||||
20:46:58.679386 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
20:46:58.679386 db@open opening
|
||||
20:46:58.679386 journal@recovery F·1
|
||||
20:46:58.679386 journal@recovery recovering @45
|
||||
20:46:58.680383 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
20:46:58.683452 db@janitor F·3 G·0
|
||||
20:46:58.683452 db@open done T·4.0659ms
|
||||
=============== Mar 5, 2026 (CST) ===============
|
||||
20:49:54.485040 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
|
||||
20:49:54.485040 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
20:49:54.485040 db@open opening
|
||||
20:49:54.485040 journal@recovery F·1
|
||||
20:49:54.485040 journal@recovery recovering @47
|
||||
20:49:54.486036 version@stat F·[1] S·574B[574B] Sc·[0.25]
|
||||
20:49:54.488644 db@janitor F·3 G·0
|
||||
20:49:54.488644 db@open done T·3.6037ms
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
149
remote/src/services/opencode/XCOpenCodeWebService.js
Normal file
149
remote/src/services/opencode/XCOpenCodeWebService.js
Normal file
@@ -0,0 +1,149 @@
|
||||
const { spawn } = require('child_process');
|
||||
const path = require('path');
|
||||
const logger = require('../../utils/logger');
|
||||
|
||||
class XCOpenCodeWebService {
|
||||
constructor(options = {}) {
|
||||
this.process = null;
|
||||
this.isRunning = false;
|
||||
this.port = options.port || 9999;
|
||||
this.enabled = options.enabled !== false;
|
||||
this.healthCheckInterval = 10000;
|
||||
this.healthCheckTimeout = 2000;
|
||||
this.healthCheckTimer = null;
|
||||
}
|
||||
|
||||
getExePath() {
|
||||
const exeName = 'XCOpenCodeWeb.exe';
|
||||
const basePath = path.join(__dirname, '../../../xcopencodeweb');
|
||||
return path.join(basePath, exeName);
|
||||
}
|
||||
|
||||
getExeArgs() {
|
||||
return ['--port', this.port.toString()];
|
||||
}
|
||||
|
||||
async checkHealth() {
|
||||
try {
|
||||
const timeoutMs = this.healthCheckTimeout;
|
||||
|
||||
const fetchPromise = fetch(`http://127.0.0.1:${this.port}`);
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Timeout')), timeoutMs)
|
||||
);
|
||||
|
||||
const response = await Promise.race([fetchPromise, timeoutPromise]);
|
||||
return response.ok || response.status === 401;
|
||||
} catch (error) {
|
||||
logger.warn('[XCOpenCodeWebService] Health check failed:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
startHealthCheck() {
|
||||
if (this.healthCheckTimer) {
|
||||
clearInterval(this.healthCheckTimer);
|
||||
}
|
||||
|
||||
this.healthCheckTimer = setInterval(async () => {
|
||||
const isHealthy = await this.checkHealth();
|
||||
if (!isHealthy && this.isRunning) {
|
||||
logger.warn('[XCOpenCodeWebService] Service not responding');
|
||||
}
|
||||
}, this.healthCheckInterval);
|
||||
}
|
||||
|
||||
stopHealthCheck() {
|
||||
if (this.healthCheckTimer) {
|
||||
clearInterval(this.healthCheckTimer);
|
||||
this.healthCheckTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
start() {
|
||||
if (!this.enabled) {
|
||||
logger.info('XCOpenCodeWeb service is disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isRunning && this.process) {
|
||||
logger.warn('XCOpenCodeWeb service is already running');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const exePath = this.getExePath();
|
||||
const exeArgs = this.getExeArgs();
|
||||
logger.info(`[XCOpenCodeWebService] Starting from: ${exePath} with args: ${exeArgs.join(' ')}`);
|
||||
|
||||
this.process = spawn(exePath, exeArgs, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: false,
|
||||
});
|
||||
|
||||
this.process.stdout.on('data', (data) => {
|
||||
const output = data.toString().trim();
|
||||
if (output) {
|
||||
logger.info(`[XCOpenCodeWeb] ${output}`);
|
||||
}
|
||||
});
|
||||
|
||||
this.process.stderr.on('data', (data) => {
|
||||
const output = data.toString().trim();
|
||||
if (output) {
|
||||
logger.error(`[XCOpenCodeWeb error] ${output}`);
|
||||
}
|
||||
});
|
||||
|
||||
this.process.on('error', (error) => {
|
||||
logger.error('[XCOpenCodeWebService] Process error', { error: error.message });
|
||||
this.isRunning = false;
|
||||
this.process = null;
|
||||
});
|
||||
|
||||
this.process.on('exit', (code) => {
|
||||
logger.info('[XCOpenCodeWebService] Process exited', { code });
|
||||
this.isRunning = false;
|
||||
this.process = null;
|
||||
});
|
||||
|
||||
this.isRunning = true;
|
||||
this.startHealthCheck();
|
||||
|
||||
logger.info(`[XCOpenCodeWebService] Started successfully on port ${this.port}`);
|
||||
} catch (error) {
|
||||
logger.error('[XCOpenCodeWebService] Failed to start', { error: error.message });
|
||||
this.isRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.stopHealthCheck();
|
||||
|
||||
if (!this.process) {
|
||||
logger.info('[XCOpenCodeWebService] Not running');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info('[XCOpenCodeWebService] Stopping...');
|
||||
|
||||
this.process.kill();
|
||||
this.process = null;
|
||||
this.isRunning = false;
|
||||
|
||||
logger.info('[XCOpenCodeWebService] Stopped');
|
||||
} catch (error) {
|
||||
logger.error('[XCOpenCodeWebService] Failed to stop', { error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
getStatus() {
|
||||
return {
|
||||
running: this.isRunning,
|
||||
port: this.port
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = XCOpenCodeWebService;
|
||||
@@ -5,7 +5,7 @@ function getBasePath() {
|
||||
if (process.pkg) {
|
||||
return path.dirname(process.execPath);
|
||||
}
|
||||
return path.join(__dirname, '../..');
|
||||
return process.cwd();
|
||||
}
|
||||
|
||||
function getPublicPath() {
|
||||
|
||||
99
remote/xcopencodeweb/README.md
Normal file
99
remote/xcopencodeweb/README.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# XCOpenCodeWeb
|
||||
|
||||
XCOpenCodeWeb 是一个基于 Web 的 AI 编程助手界面,用于与 OpenCode 服务器交互。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 使用单文件 exe(推荐)
|
||||
|
||||
直接下载 `XCOpenCodeWeb.exe`,双击运行:
|
||||
|
||||
```bash
|
||||
# 默认端口 3000
|
||||
XCOpenCodeWeb.exe
|
||||
|
||||
# 指定端口
|
||||
XCOpenCodeWeb.exe --port 8080
|
||||
|
||||
# 查看帮助
|
||||
XCOpenCodeWeb.exe --help
|
||||
```
|
||||
|
||||
启动后访问 http://localhost:3000
|
||||
|
||||
### 从源码运行
|
||||
|
||||
需要安装 [Bun](https://bun.sh) 或 Node.js 20+
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
bun install
|
||||
|
||||
# 构建前端
|
||||
bun run build
|
||||
|
||||
# 启动服务器
|
||||
bun server/index.js --port 3000
|
||||
```
|
||||
|
||||
## 构建单文件 exe
|
||||
|
||||
```bash
|
||||
cd web
|
||||
bun run build:exe
|
||||
```
|
||||
|
||||
输出:`web/XCOpenCodeWeb.exe`(约 320MB)
|
||||
|
||||
详细文档:[docs/single-file-exe-build.md](docs/single-file-exe-build.md)
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
├── ui/ # 前端组件库
|
||||
├── web/
|
||||
│ ├── src/ # 前端源码
|
||||
│ ├── server/ # 后端服务器
|
||||
│ ├── bin/ # CLI 工具
|
||||
│ └── dist/ # 构建输出
|
||||
├── docs/ # 文档
|
||||
└── AGENTS.md # AI Agent 参考文档
|
||||
```
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
# 开发模式
|
||||
bun run dev # 前端热更新
|
||||
bun run dev:server # 启动开发服务器
|
||||
|
||||
# 构建
|
||||
bun run build # 构建前端
|
||||
bun run build:exe # 构建单文件 exe
|
||||
|
||||
# 代码检查
|
||||
bun run type-check:web # TypeScript 类型检查
|
||||
bun run lint:web # ESLint 检查
|
||||
```
|
||||
|
||||
## 依赖
|
||||
|
||||
- [Bun](https://bun.sh) - 运行时和打包工具
|
||||
- [React](https://react.dev) - 前端框架
|
||||
- [Express](https://expressjs.com) - 后端服务器
|
||||
- [Tailwind CSS](https://tailwindcss.com) - 样式框架
|
||||
|
||||
## 配置
|
||||
|
||||
### 环境变量
|
||||
|
||||
| 变量 | 说明 |
|
||||
|------|------|
|
||||
| `XCOpenCodeWeb_PORT` | 服务器端口(默认 3000) |
|
||||
| `OPENCODE_HOST` | 外部 OpenCode 服务器地址 |
|
||||
| `OPENCODE_PORT` | 外部 OpenCode 端口 |
|
||||
| `OPENCODE_SKIP_START` | 跳过启动 OpenCode |
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT
|
||||
99
services/xcopencodeweb/README.md
Normal file
99
services/xcopencodeweb/README.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# XCOpenCodeWeb
|
||||
|
||||
XCOpenCodeWeb 是一个基于 Web 的 AI 编程助手界面,用于与 OpenCode 服务器交互。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 使用单文件 exe(推荐)
|
||||
|
||||
直接下载 `XCOpenCodeWeb.exe`,双击运行:
|
||||
|
||||
```bash
|
||||
# 默认端口 3000
|
||||
XCOpenCodeWeb.exe
|
||||
|
||||
# 指定端口
|
||||
XCOpenCodeWeb.exe --port 8080
|
||||
|
||||
# 查看帮助
|
||||
XCOpenCodeWeb.exe --help
|
||||
```
|
||||
|
||||
启动后访问 http://localhost:3000
|
||||
|
||||
### 从源码运行
|
||||
|
||||
需要安装 [Bun](https://bun.sh) 或 Node.js 20+
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
bun install
|
||||
|
||||
# 构建前端
|
||||
bun run build
|
||||
|
||||
# 启动服务器
|
||||
bun server/index.js --port 3000
|
||||
```
|
||||
|
||||
## 构建单文件 exe
|
||||
|
||||
```bash
|
||||
cd web
|
||||
bun run build:exe
|
||||
```
|
||||
|
||||
输出:`web/XCOpenCodeWeb.exe`(约 320MB)
|
||||
|
||||
详细文档:[docs/single-file-exe-build.md](docs/single-file-exe-build.md)
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
├── ui/ # 前端组件库
|
||||
├── web/
|
||||
│ ├── src/ # 前端源码
|
||||
│ ├── server/ # 后端服务器
|
||||
│ ├── bin/ # CLI 工具
|
||||
│ └── dist/ # 构建输出
|
||||
├── docs/ # 文档
|
||||
└── AGENTS.md # AI Agent 参考文档
|
||||
```
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
# 开发模式
|
||||
bun run dev # 前端热更新
|
||||
bun run dev:server # 启动开发服务器
|
||||
|
||||
# 构建
|
||||
bun run build # 构建前端
|
||||
bun run build:exe # 构建单文件 exe
|
||||
|
||||
# 代码检查
|
||||
bun run type-check:web # TypeScript 类型检查
|
||||
bun run lint:web # ESLint 检查
|
||||
```
|
||||
|
||||
## 依赖
|
||||
|
||||
- [Bun](https://bun.sh) - 运行时和打包工具
|
||||
- [React](https://react.dev) - 前端框架
|
||||
- [Express](https://expressjs.com) - 后端服务器
|
||||
- [Tailwind CSS](https://tailwindcss.com) - 样式框架
|
||||
|
||||
## 配置
|
||||
|
||||
### 环境变量
|
||||
|
||||
| 变量 | 说明 |
|
||||
|------|------|
|
||||
| `XCOpenCodeWeb_PORT` | 服务器端口(默认 3000) |
|
||||
| `OPENCODE_HOST` | 外部 OpenCode 服务器地址 |
|
||||
| `OPENCODE_PORT` | 外部 OpenCode 端口 |
|
||||
| `OPENCODE_SKIP_START` | 跳过启动 OpenCode |
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT
|
||||
BIN
services/xcopencodeweb/XCOpenCodeWeb.exe
Normal file
BIN
services/xcopencodeweb/XCOpenCodeWeb.exe
Normal file
Binary file not shown.
BIN
services/xcsdd/XCSDD.exe
Normal file
BIN
services/xcsdd/XCSDD.exe
Normal file
Binary file not shown.
12
shared/modules/opencode/index.ts
Normal file
12
shared/modules/opencode/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineModule } from '../types.js'
|
||||
|
||||
export const OPENCODE_MODULE = defineModule({
|
||||
id: 'opencode',
|
||||
name: 'OpenCode',
|
||||
basePath: '/opencode',
|
||||
order: 15,
|
||||
version: '1.0.0',
|
||||
backend: {
|
||||
enabled: true,
|
||||
},
|
||||
})
|
||||
12
shared/modules/sdd/index.ts
Normal file
12
shared/modules/sdd/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineModule } from '../types.js'
|
||||
|
||||
export const SDD_MODULE = defineModule({
|
||||
id: 'sdd',
|
||||
name: 'SDD',
|
||||
basePath: '/sdd',
|
||||
order: 16,
|
||||
version: '1.0.0',
|
||||
backend: {
|
||||
enabled: false,
|
||||
},
|
||||
})
|
||||
12
shared/modules/terminal/index.ts
Normal file
12
shared/modules/terminal/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineModule } from '../types.js'
|
||||
|
||||
export const TERMINAL_MODULE = defineModule({
|
||||
id: 'terminal',
|
||||
name: 'Terminal',
|
||||
basePath: '/terminal',
|
||||
order: 17,
|
||||
version: '1.0.0',
|
||||
backend: {
|
||||
enabled: false,
|
||||
},
|
||||
})
|
||||
13
shared/modules/voice/index.ts
Normal file
13
shared/modules/voice/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export const VOICE_MODULE = 'voice' as const
|
||||
|
||||
export interface VoiceModule {
|
||||
id: typeof VOICE_MODULE
|
||||
name: '语音'
|
||||
icon: 'mic'
|
||||
}
|
||||
|
||||
export const voiceModule: VoiceModule = {
|
||||
id: VOICE_MODULE,
|
||||
name: '语音',
|
||||
icon: 'mic',
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom'
|
||||
import { NoteBrowser } from '@/pages/NoteBrowser'
|
||||
import { PopoutPage } from '@/pages/PopoutPage'
|
||||
import { SettingsSync } from '@/components/settings/SettingsSync'
|
||||
import { ErrorBoundary } from '@/components/common/ErrorBoundary'
|
||||
|
||||
@@ -14,6 +15,7 @@ function App() {
|
||||
<TimeTrackerProvider>
|
||||
<SettingsSync />
|
||||
<Routes>
|
||||
<Route path="/popout" element={<PopoutPage />} />
|
||||
<Route path="/*" element={<NoteBrowser />} />
|
||||
</Routes>
|
||||
</TimeTrackerProvider>
|
||||
|
||||
290
src/components/chat/Chat.tsx
Normal file
290
src/components/chat/Chat.tsx
Normal file
@@ -0,0 +1,290 @@
|
||||
import { useRef, useState, useCallback, useEffect } from 'react'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from './ui/AlertDialog'
|
||||
import { Messages } from './Messages'
|
||||
import { MultimodalInput } from './MultimodalInput'
|
||||
import { generateUUID } from '@/lib/utils'
|
||||
import { Eraser } from 'lucide-react'
|
||||
import { useDragStore } from '@/stores/dragStore'
|
||||
import { getSettingsConfig } from '@/lib/api'
|
||||
|
||||
type Status = 'submitted' | 'streaming' | 'ready' | 'error'
|
||||
|
||||
interface Attachment {
|
||||
url: string
|
||||
name: string
|
||||
contentType: string
|
||||
}
|
||||
|
||||
interface ChatMessage {
|
||||
id: string
|
||||
role: 'user' | 'assistant' | 'system'
|
||||
content: string
|
||||
parts: Array<{ type: string; text?: string }>
|
||||
createdAt?: Date
|
||||
}
|
||||
|
||||
const OPENCODE_API = '/api/opencode'
|
||||
const INITIAL_MESSAGE_LIMIT = 20
|
||||
|
||||
export function Chat({
|
||||
id,
|
||||
initialMessages,
|
||||
onClear,
|
||||
}: {
|
||||
id: string
|
||||
initialMessages: ChatMessage[]
|
||||
onClear?: () => void
|
||||
}) {
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [messages, setMessages] = useState<ChatMessage[]>(initialMessages)
|
||||
const [hasMoreMessages, setHasMoreMessages] = useState(false)
|
||||
const [input, setInput] = useState<string>('')
|
||||
const [showCreditCardAlert, setShowCreditCardAlert] = useState(false)
|
||||
const [status, setStatus] = useState<Status>('ready')
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([])
|
||||
const [showInputAtBottom, setShowInputAtBottom] = useState(false)
|
||||
const [notebookRoot, setNotebookRoot] = useState<string>('')
|
||||
const { state: dragState } = useDragStore()
|
||||
|
||||
useEffect(() => {
|
||||
getSettingsConfig().then(config => {
|
||||
// 统一 notebookRoot 的斜杠
|
||||
const normalizedRoot = (config.notebookRoot || '').replace(/\\/g, '/')
|
||||
setNotebookRoot(normalizedRoot)
|
||||
}).catch(e => {
|
||||
console.error('Failed to get notebook root:', e)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
// 优先从 dragStore 获取
|
||||
let { draggedPath, draggedType } = dragState
|
||||
|
||||
// 如果 dragStore 为空,尝试从 dataTransfer 获取
|
||||
if (!draggedPath) {
|
||||
draggedPath = e.dataTransfer.getData('text/plain')
|
||||
}
|
||||
|
||||
if (draggedPath) {
|
||||
// 统一路径中的斜杠,并拼接完整路径,用【】包围
|
||||
const normalizedPath = draggedPath.replace(/\\/g, '/')
|
||||
const fullPath = notebookRoot ? `${notebookRoot}/${normalizedPath}` : normalizedPath
|
||||
const pathWithBrackets = `【${fullPath}】`
|
||||
setInput(prev => prev + pathWithBrackets)
|
||||
|
||||
// 延迟聚焦输入框
|
||||
setTimeout(() => {
|
||||
const textarea = document.querySelector('textarea') as HTMLTextAreaElement
|
||||
textarea?.focus()
|
||||
}, 0)
|
||||
}
|
||||
}, [dragState, notebookRoot])
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
}, [])
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'auto' })
|
||||
}, [])
|
||||
|
||||
const loadMessages = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(OPENCODE_API, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'get-messages',
|
||||
sessionId: id,
|
||||
limit: INITIAL_MESSAGE_LIMIT
|
||||
}),
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
const newMessages = data.messages || []
|
||||
setMessages(newMessages)
|
||||
setHasMoreMessages(data.hasMore ?? newMessages.length >= INITIAL_MESSAGE_LIMIT)
|
||||
setTimeout(scrollToBottom, 100)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load messages:', error)
|
||||
}
|
||||
}, [id, scrollToBottom])
|
||||
|
||||
const loadMoreMessages = useCallback(async () => {
|
||||
if (messages.length === 0) return
|
||||
|
||||
const currentCount = messages.length
|
||||
const newLimit = currentCount + INITIAL_MESSAGE_LIMIT
|
||||
|
||||
try {
|
||||
const res = await fetch(OPENCODE_API, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'get-messages',
|
||||
sessionId: id,
|
||||
limit: newLimit
|
||||
}),
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
const newMessages = data.messages || []
|
||||
|
||||
setMessages(prev => {
|
||||
const existingIds = new Set(prev.map(m => m.id))
|
||||
const uniqueNew = newMessages.filter((m: ChatMessage) => !existingIds.has(m.id))
|
||||
return [...uniqueNew, ...prev]
|
||||
})
|
||||
|
||||
setHasMoreMessages(data.hasMore ?? newMessages.length >= newLimit)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load more messages:', error)
|
||||
}
|
||||
}, [id, messages])
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
loadMessages()
|
||||
}
|
||||
}, [id, loadMessages])
|
||||
|
||||
const handleSendMessage = useCallback(async (text: string, atts: Attachment[]) => {
|
||||
const userMessage: ChatMessage = {
|
||||
id: generateUUID(),
|
||||
role: 'user',
|
||||
content: text,
|
||||
parts: [],
|
||||
createdAt: new Date(),
|
||||
}
|
||||
|
||||
setMessages(prev => [...prev, userMessage])
|
||||
setStatus('submitted')
|
||||
|
||||
try {
|
||||
const res = await fetch(OPENCODE_API, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'prompt',
|
||||
sessionId: id,
|
||||
text,
|
||||
attachments: atts.map(a => ({
|
||||
url: a.url,
|
||||
name: a.name,
|
||||
mediaType: a.contentType,
|
||||
})),
|
||||
}),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(await res.text())
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (data.message) {
|
||||
setMessages(prev => [...prev, data.message])
|
||||
setShowInputAtBottom(true)
|
||||
}
|
||||
|
||||
setStatus('ready')
|
||||
} catch (error: unknown) {
|
||||
console.error('Send message error:', error)
|
||||
setStatus('error')
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to send message'
|
||||
if (errorMessage.includes('credit card')) {
|
||||
setShowCreditCardAlert(true)
|
||||
}
|
||||
}
|
||||
}, [id])
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
setStatus('ready')
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="overscroll-behavior-contain flex h-full min-w-0 touch-pan-y flex-col bg-background">
|
||||
{onClear && messages.length > 0 && (
|
||||
<div className="absolute top-4 right-4 z-10">
|
||||
<button
|
||||
onClick={onClear}
|
||||
className="flex items-center rounded-md bg-primary p-2 text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
<Eraser className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<Messages
|
||||
messages={messages}
|
||||
status={status}
|
||||
hasMoreMessages={hasMoreMessages}
|
||||
loadMoreMessages={loadMoreMessages}
|
||||
/>
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
<div
|
||||
className={`mx-auto flex w-full max-w-4xl gap-2 px-2 pb-3 transition-all duration-300 md:px-4 md:pb-4 ${
|
||||
showInputAtBottom || messages.length > 0
|
||||
? 'sticky bottom-0'
|
||||
: 'absolute bottom-[40%] left-0 right-0'
|
||||
}`}
|
||||
>
|
||||
<MultimodalInput
|
||||
attachments={attachments}
|
||||
chatId={id}
|
||||
input={input}
|
||||
messages={messages}
|
||||
sendMessage={handleSendMessage}
|
||||
setAttachments={setAttachments}
|
||||
setInput={setInput}
|
||||
status={status}
|
||||
stop={handleStop}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertDialog
|
||||
onOpenChange={setShowCreditCardAlert}
|
||||
open={showCreditCardAlert}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>API 配置需要</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
请在环境变量中配置您的 AI API 密钥。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
setShowCreditCardAlert(false)
|
||||
}}
|
||||
>
|
||||
确定
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
9
src/components/chat/Greeting.tsx
Normal file
9
src/components/chat/Greeting.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
export function Greeting() {
|
||||
return (
|
||||
<div className="mx-auto mt-60 max-w-2xl px-4 text-center">
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
开始今天的工作吧!
|
||||
</h1>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
95
src/components/chat/Message.tsx
Normal file
95
src/components/chat/Message.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { memo, useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ArrowDownIcon, SparklesIcon, ChevronDown, ChevronRight } from 'lucide-react'
|
||||
|
||||
interface MessageProps {
|
||||
message: {
|
||||
id: string
|
||||
role: 'user' | 'assistant' | 'system'
|
||||
content: string
|
||||
parts?: Array<{ type: string; text?: string }>
|
||||
}
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
function PureMessage({ message, isLoading }: MessageProps) {
|
||||
const isUser = message.role === 'user'
|
||||
const [reasoningCollapsed, setReasoningCollapsed] = useState(true)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-full gap-4 px-4',
|
||||
isUser ? 'justify-end' : 'justify-start'
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
'flex max-w-[80%] flex-col gap-1 rounded-2xl px-4 py-3',
|
||||
isUser ? 'bg-gray-100 dark:bg-gray-800' : 'bg-muted/50'
|
||||
)}>
|
||||
{message.parts?.map((part: { type: string; text?: string }, i: number) => {
|
||||
const safeString = (val: unknown): string => {
|
||||
if (val === null || val === undefined) return ''
|
||||
if (typeof val === 'string') return val
|
||||
if (typeof val === 'object') return JSON.stringify(val, null, 2)
|
||||
return String(val)
|
||||
}
|
||||
|
||||
if (part.type === 'text') {
|
||||
return (
|
||||
<div key={i} className={cn('whitespace-pre-wrap text-sm rounded-lg px-3 py-2 bg-gray-100 dark:bg-gray-800', isUser && 'text-primary-foreground')}>
|
||||
{safeString(part.text)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (part.type === 'reasoning') {
|
||||
return (
|
||||
<div key={i} className="rounded border border-gray-500/30 bg-gray-500/10 p-2 text-xs text-gray-600 dark:text-gray-400">
|
||||
<div
|
||||
className="flex items-center gap-1 font-semibold cursor-pointer"
|
||||
onClick={() => setReasoningCollapsed(!reasoningCollapsed)}
|
||||
>
|
||||
{reasoningCollapsed ? <ChevronRight className="size-3" /> : <ChevronDown className="size-3" />}
|
||||
<SparklesIcon className="size-3" />
|
||||
Thinking
|
||||
</div>
|
||||
{!reasoningCollapsed && (
|
||||
<div className="mt-1 whitespace-pre-wrap">{safeString(part.text)}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
})}
|
||||
{(!message.parts || message.parts.length === 0) && message.content && (
|
||||
<div className={cn('whitespace-pre-wrap text-sm', isUser && 'text-primary-foreground')}>{message.content}</div>
|
||||
)}
|
||||
{isLoading && (
|
||||
<div className={cn('flex items-center gap-1 text-xs', isUser ? 'text-primary-foreground/70' : 'text-muted-foreground')}>
|
||||
<span className="animate-pulse">Thinking</span>
|
||||
<ArrowDownIcon className="size-3 animate-bounce" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const PreviewMessage = memo(PureMessage)
|
||||
|
||||
export function ThinkingMessage() {
|
||||
return (
|
||||
<div className="flex w-full gap-4 px-4">
|
||||
<div className="flex flex-col gap-1 rounded-lg bg-muted/50 p-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span className="animate-pulse">Thinking</span>
|
||||
<span className="size-1.5 animate-bounce rounded-full bg-muted-foreground" />
|
||||
<span className="size-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:0.2s]" />
|
||||
<span className="size-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:0.4s]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
103
src/components/chat/Messages.tsx
Normal file
103
src/components/chat/Messages.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { ArrowDownIcon, Loader2Icon } from 'lucide-react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useMessages } from '@/hooks/useMessages'
|
||||
import { Greeting } from './Greeting'
|
||||
import { PreviewMessage, ThinkingMessage } from './Message'
|
||||
|
||||
type Status = 'submitted' | 'streaming' | 'ready' | 'error'
|
||||
|
||||
interface ChatMessage {
|
||||
id: string
|
||||
role: 'user' | 'assistant' | 'system'
|
||||
content: string
|
||||
parts: Array<{ type: string; text?: string }>
|
||||
createdAt?: Date
|
||||
}
|
||||
|
||||
type MessagesProps = {
|
||||
messages: ChatMessage[]
|
||||
status: Status
|
||||
hasMoreMessages?: boolean
|
||||
loadMoreMessages?: () => void
|
||||
}
|
||||
|
||||
export function Messages({ messages, status, hasMoreMessages, loadMoreMessages }: MessagesProps) {
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false)
|
||||
|
||||
const {
|
||||
containerRef: messagesContainerRef,
|
||||
endRef: messagesEndRef,
|
||||
isAtBottom,
|
||||
scrollToBottom,
|
||||
} = useMessages({ status })
|
||||
|
||||
useEffect(() => {
|
||||
if (messages.length > 0) {
|
||||
const container = messagesContainerRef.current
|
||||
if (container) {
|
||||
container.scrollTop = container.scrollHeight
|
||||
}
|
||||
}
|
||||
}, [messages, messagesContainerRef])
|
||||
|
||||
const handleLoadMore = async () => {
|
||||
if (!loadMoreMessages || isLoadingMore) return
|
||||
setIsLoadingMore(true)
|
||||
await loadMoreMessages()
|
||||
setIsLoadingMore(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex-1 bg-background pt-2 pb-4">
|
||||
<div
|
||||
className="absolute inset-0 touch-pan-y overflow-y-auto bg-background"
|
||||
ref={messagesContainerRef}
|
||||
>
|
||||
<div className="mx-auto flex min-w-0 max-w-4xl flex-col gap-4 px-2 py-2 md:gap-6 md:px-4">
|
||||
{hasMoreMessages && (
|
||||
<div className="flex justify-center py-2">
|
||||
<button
|
||||
onClick={handleLoadMore}
|
||||
disabled={isLoadingMore}
|
||||
className="flex items-center gap-2 rounded-full border bg-background px-4 py-2 text-sm text-muted-foreground hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
{isLoadingMore && <Loader2Icon className="size-4 animate-spin" />}
|
||||
{isLoadingMore ? 'Loading...' : 'Load more messages'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.length === 0 && <Greeting />}
|
||||
|
||||
{messages.map((message, index) => (
|
||||
<PreviewMessage
|
||||
key={message.id}
|
||||
message={message}
|
||||
isLoading={status === 'streaming' && messages.length - 1 === index}
|
||||
/>
|
||||
))}
|
||||
|
||||
{status === 'submitted' && <ThinkingMessage />}
|
||||
|
||||
<div
|
||||
className="min-h-[24px] min-w-[24px] shrink-0"
|
||||
ref={messagesEndRef}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
aria-label="Scroll to bottom"
|
||||
className={`absolute bottom-4 left-1/2 z-10 -translate-x-1/2 rounded-full border bg-background p-2 shadow-lg transition-all hover:bg-muted ${
|
||||
isAtBottom
|
||||
? 'pointer-events-none scale-0 opacity-0'
|
||||
: 'pointer-events-auto scale-100 opacity-100'
|
||||
}`}
|
||||
onClick={() => scrollToBottom('smooth')}
|
||||
type="button"
|
||||
>
|
||||
<ArrowDownIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
177
src/components/chat/MultimodalInput.tsx
Normal file
177
src/components/chat/MultimodalInput.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ArrowUpIcon, StopIcon } from './icons'
|
||||
import { Button } from './ui/Button'
|
||||
|
||||
type Status = 'submitted' | 'streaming' | 'ready' | 'error'
|
||||
|
||||
interface Attachment {
|
||||
url: string
|
||||
name: string
|
||||
contentType: string
|
||||
}
|
||||
|
||||
interface ChatMessage {
|
||||
id: string
|
||||
role: 'user' | 'assistant' | 'system'
|
||||
content: string
|
||||
parts: unknown[]
|
||||
createdAt?: Date
|
||||
}
|
||||
|
||||
interface MultimodalInputProps {
|
||||
chatId: string
|
||||
input: string
|
||||
setInput: (value: string) => void
|
||||
status: Status
|
||||
stop: () => void
|
||||
attachments: Attachment[]
|
||||
setAttachments: (attachments: Attachment[]) => void
|
||||
messages: ChatMessage[]
|
||||
sendMessage: (text: string, attachments: Attachment[]) => Promise<void>
|
||||
className?: string
|
||||
onDrop?: (e: React.DragEvent) => void
|
||||
onDragOver?: (e: React.DragEvent) => void
|
||||
}
|
||||
|
||||
export function MultimodalInput({
|
||||
chatId,
|
||||
input,
|
||||
setInput,
|
||||
status,
|
||||
stop,
|
||||
attachments,
|
||||
setAttachments,
|
||||
sendMessage,
|
||||
className,
|
||||
onDrop,
|
||||
onDragOver,
|
||||
}: MultimodalInputProps) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const adjustHeight = useCallback(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = '44px'
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
adjustHeight()
|
||||
}
|
||||
}, [adjustHeight])
|
||||
|
||||
const resetHeight = useCallback(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = '44px'
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleInput = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setInput(event.target.value)
|
||||
}
|
||||
|
||||
const submitForm = useCallback(() => {
|
||||
if (!input.trim() && attachments.length === 0) return
|
||||
|
||||
sendMessage(input, attachments)
|
||||
|
||||
setAttachments([])
|
||||
resetHeight()
|
||||
setInput('')
|
||||
|
||||
textareaRef.current?.focus()
|
||||
}, [input, setInput, attachments, sendMessage, setAttachments, resetHeight])
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
if (!input.trim() && attachments.length === 0) {
|
||||
return
|
||||
}
|
||||
if (status !== 'ready') {
|
||||
console.warn('Please wait for the model to finish its response!')
|
||||
} else {
|
||||
submitForm()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
const target = textareaRef.current
|
||||
target.style.height = '44px'
|
||||
const scrollHeight = target.scrollHeight
|
||||
if (scrollHeight > 44) {
|
||||
target.style.height = `${Math.min(scrollHeight, 200)}px`
|
||||
}
|
||||
}
|
||||
}, [input])
|
||||
|
||||
return (
|
||||
<div className={cn('relative flex w-full flex-col gap-4', className)}>
|
||||
<div className="relative flex w-full items-end gap-2 rounded-xl border border-border bg-background p-3 shadow-xs transition-all duration-200 focus-within:border-border hover:border-muted-foreground/50">
|
||||
{attachments.length > 0 && (
|
||||
<div className="flex flex-row items-end gap-2 overflow-x-scroll">
|
||||
{attachments.map((attachment) => (
|
||||
<div
|
||||
key={attachment.url}
|
||||
className="relative flex items-center gap-1 rounded-md bg-muted px-2 py-1 text-xs"
|
||||
>
|
||||
<span className="max-w-[100px] truncate">{attachment.name}</span>
|
||||
<button
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
setAttachments(attachments.filter((a) => a.url !== attachment.url))
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="max-h-[200px] min-h-[44px] w-full resize-none border-0 bg-transparent p-2 text-base outline-none placeholder:text-muted-foreground [-ms-overflow-style:none] [scrollbar-width:none]"
|
||||
style={{ height: '44px' }}
|
||||
value={input}
|
||||
onChange={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
onDrop?.(e)
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
onDragOver?.(e)
|
||||
}}
|
||||
placeholder="发送消息..."
|
||||
rows={1}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{status === 'submitted' ? (
|
||||
<Button
|
||||
className="size-8 rounded-full bg-foreground p-1 text-background transition-colors duration-200 hover:bg-foreground/90"
|
||||
onClick={() => {
|
||||
stop()
|
||||
}}
|
||||
>
|
||||
<StopIcon size={14} />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="size-8 rounded-full bg-primary text-primary-foreground transition-colors duration-200 hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground"
|
||||
disabled={!input.trim() && attachments.length === 0}
|
||||
onClick={submitForm}
|
||||
>
|
||||
<ArrowUpIcon size={14} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
27
src/components/chat/icons.tsx
Normal file
27
src/components/chat/icons.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import {
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
Paperclip,
|
||||
StopCircle,
|
||||
ChevronDown,
|
||||
Check,
|
||||
Plus,
|
||||
Trash,
|
||||
PanelLeftClose,
|
||||
PanelLeft,
|
||||
Mic,
|
||||
} from 'lucide-react'
|
||||
|
||||
export {
|
||||
ArrowUp as ArrowUpIcon,
|
||||
ArrowDown as ArrowDownIcon,
|
||||
Paperclip as PaperclipIcon,
|
||||
StopCircle as StopIcon,
|
||||
ChevronDown as ChevronDownIcon,
|
||||
Check as CheckIcon,
|
||||
Plus as PlusIcon,
|
||||
Trash as TrashIcon,
|
||||
PanelLeftClose as PanelLeftCloseIcon,
|
||||
PanelLeft as PanelLeftOpenIcon,
|
||||
Mic as MicIcon,
|
||||
}
|
||||
132
src/components/chat/ui/AlertDialog.tsx
Normal file
132
src/components/chat/ui/AlertDialog.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import * as React from 'react'
|
||||
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { buttonVariants } from './Button'
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
))
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||
|
||||
const AlertDialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn('flex flex-col space-y-2 text-center sm:text-left', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogHeader.displayName = 'AlertDialogHeader'
|
||||
|
||||
const AlertDialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogFooter.displayName = 'AlertDialogFooter'
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
className={cn('text-lg font-semibold', className)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action
|
||||
className={cn(buttonVariants(), className)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
className={cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'mt-2 sm:mt-0',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
52
src/components/chat/ui/Button.tsx
Normal file
52
src/components/chat/ui/Button.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import * as React from 'react'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||
outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2',
|
||||
sm: 'h-8 rounded-md px-3 text-xs',
|
||||
lg: 'h-10 rounded-md px-8',
|
||||
icon: 'h-9 w-9',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button'
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = 'Button'
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -15,12 +15,13 @@ export interface TabBarProps {
|
||||
onTabClose: (file: FileItem, e: React.MouseEvent) => void
|
||||
onCloseOther?: (file: FileItem) => void
|
||||
onCloseAll?: () => void
|
||||
onPopOut?: (file: FileItem) => void
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
variant?: 'default' | 'titlebar'
|
||||
}
|
||||
|
||||
export const TabBar = ({ openFiles, activeFile, onTabClick, onTabClose, onCloseOther, onCloseAll, className, style, variant = 'default' }: TabBarProps) => {
|
||||
export const TabBar = ({ openFiles, activeFile, onTabClick, onTabClose, onCloseOther, onCloseAll, onPopOut, className, style, variant = 'default' }: TabBarProps) => {
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
const { opacity } = useWallpaper()
|
||||
const [contextMenu, setContextMenu] = useState<{
|
||||
@@ -61,7 +62,16 @@ export const TabBar = ({ openFiles, activeFile, onTabClick, onTabClose, onCloseO
|
||||
handleCloseContextMenu()
|
||||
}
|
||||
|
||||
const handlePopOut = () => {
|
||||
if (contextMenu.file && onPopOut) {
|
||||
onPopOut(contextMenu.file)
|
||||
}
|
||||
handleCloseContextMenu()
|
||||
}
|
||||
|
||||
const isHomeTab = contextMenu.file?.path === HOME_TAB_ID
|
||||
const contextMenuItems = [
|
||||
...(!isHomeTab ? [{ label: '在新窗口中打开', onClick: handlePopOut }] : []),
|
||||
{ label: '关闭其他标签页', onClick: handleCloseOther },
|
||||
{ label: '关闭所有标签页', onClick: handleCloseAll }
|
||||
]
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface TitleBarProps {
|
||||
onTabClose: (file: FileItem, e: React.MouseEvent) => void
|
||||
onCloseOther?: (file: FileItem) => void
|
||||
onCloseAll?: () => void
|
||||
onPopOut?: (file: FileItem) => void
|
||||
opacity: number
|
||||
}
|
||||
|
||||
@@ -18,6 +19,7 @@ export const TitleBar = ({
|
||||
onTabClose,
|
||||
onCloseOther,
|
||||
onCloseAll,
|
||||
onPopOut,
|
||||
opacity
|
||||
}: TitleBarProps) => (
|
||||
<div
|
||||
@@ -35,6 +37,7 @@ export const TitleBar = ({
|
||||
onTabClose={onTabClose}
|
||||
onCloseOther={onCloseOther}
|
||||
onCloseAll={onCloseAll}
|
||||
onPopOut={onPopOut}
|
||||
variant="titlebar"
|
||||
className="h-full border-b-0"
|
||||
style={{ backgroundColor: 'transparent' }}
|
||||
|
||||
@@ -3,6 +3,7 @@ export { useDialogState, useErrorDialogState } from './useDialogState'
|
||||
export { useNoteBrowser, type UseNoteBrowserReturn } from './useNoteBrowser'
|
||||
export { useNoteContent } from './useNoteContent'
|
||||
export { useAutoLoadTabContent } from './useAutoLoadTabContent'
|
||||
export { usePopOutTab } from './usePopOutTab'
|
||||
|
||||
export { useFileSystemController } from './useFileSystemController'
|
||||
export { useFileTabs } from './useFileTabs'
|
||||
|
||||
@@ -85,6 +85,8 @@ export const useMarkdownLogic = ({
|
||||
const readOnlyRef = useRef(readOnly)
|
||||
const ctxRef = useRef<Ctx | null>(null)
|
||||
|
||||
const lastContentRef = useRef<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange
|
||||
}, [])
|
||||
@@ -117,23 +119,28 @@ export const useMarkdownLogic = ({
|
||||
}
|
||||
}, [readOnly])
|
||||
|
||||
// 在只读模式下动态更新内容
|
||||
// 在只读模式下动态更新内容(仅当 content 真正变化时)
|
||||
useEffect(() => {
|
||||
if (!ctxRef.current || !readOnly) return
|
||||
|
||||
// 只有当 content 真正变化时才更新编辑器
|
||||
if (content === lastContentRef.current) return
|
||||
lastContentRef.current = content
|
||||
|
||||
try {
|
||||
const view = ctxRef.current.get(editorViewCtx)
|
||||
const parser = ctxRef.current.get(parserCtx)
|
||||
const doc = parser(content)
|
||||
if (!doc) return
|
||||
|
||||
const state = view.state
|
||||
view.dispatch(state.tr.replaceWith(0, state.doc.content.size, doc))
|
||||
view.dispatch(view.state.tr.replaceWith(0, view.state.doc.content.size, doc))
|
||||
} catch {
|
||||
// 编辑器可能尚未就绪
|
||||
}
|
||||
}, [content, readOnly])
|
||||
|
||||
return useEditor((root) => {
|
||||
lastContentRef.current = content
|
||||
return Editor.make()
|
||||
.config((ctx) => {
|
||||
ctxRef.current = ctx
|
||||
|
||||
58
src/hooks/domain/usePopOutTab.ts
Normal file
58
src/hooks/domain/usePopOutTab.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useTabStore, TabState } from '@/stores'
|
||||
|
||||
export interface PopOutTabData {
|
||||
file: {
|
||||
name: string
|
||||
path: string
|
||||
type: 'file' | 'dir'
|
||||
size: number
|
||||
modified: string
|
||||
}
|
||||
content: string
|
||||
unsavedContent: string
|
||||
isEditing: boolean
|
||||
loading: boolean
|
||||
loaded: boolean
|
||||
}
|
||||
|
||||
export function usePopOutTab() {
|
||||
const { selectFile } = useTabStore()
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electronAPI?.onTabDataReceived((tabData: PopOutTabData) => {
|
||||
console.log('[PopOut] Received tab data:', tabData)
|
||||
|
||||
if (!tabData?.file?.path) {
|
||||
console.error('[PopOut] Invalid tab data received')
|
||||
return
|
||||
}
|
||||
|
||||
const { file, content, unsavedContent, isEditing, loading, loaded } = tabData
|
||||
|
||||
const newTab: TabState = {
|
||||
file: file as any,
|
||||
content: content || '',
|
||||
unsavedContent: unsavedContent || content || '',
|
||||
isEditing: isEditing || false,
|
||||
loading: false,
|
||||
loaded: true,
|
||||
}
|
||||
|
||||
useTabStore.setState((state) => {
|
||||
const newTabs = new Map(state.tabs)
|
||||
newTabs.set(file.path, newTab)
|
||||
return {
|
||||
tabs: newTabs,
|
||||
activeTabId: file.path,
|
||||
}
|
||||
})
|
||||
|
||||
selectFile(file as any)
|
||||
})
|
||||
|
||||
return () => {
|
||||
unsubscribe?.()
|
||||
}
|
||||
}, [selectFile])
|
||||
}
|
||||
@@ -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
54
src/hooks/useMessages.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -13,6 +13,7 @@ export {
|
||||
renameItem,
|
||||
runAiTask,
|
||||
getSettings,
|
||||
getSettingsConfig,
|
||||
saveSettings,
|
||||
uploadPdfForParsing,
|
||||
parseLocalHtml,
|
||||
|
||||
6
src/lib/utils/cn.ts
Normal file
6
src/lib/utils/cn.ts
Normal 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))
|
||||
}
|
||||
@@ -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
7
src/lib/utils/uuid.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
86
src/modules/opencode/OpenCodePage.tsx
Normal file
86
src/modules/opencode/OpenCodePage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
11
src/modules/opencode/index.tsx
Normal file
11
src/modules/opencode/index.tsx
Normal 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 }
|
||||
86
src/modules/sdd/SDDPage.tsx
Normal file
86
src/modules/sdd/SDDPage.tsx
Normal 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
11
src/modules/sdd/index.tsx
Normal 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 }
|
||||
86
src/modules/terminal/TerminalPage.tsx
Normal file
86
src/modules/terminal/TerminalPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
11
src/modules/terminal/index.tsx
Normal file
11
src/modules/terminal/index.tsx
Normal 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 }
|
||||
@@ -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
179
src/pages/PopoutPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
20
src/types/electron.d.ts
vendored
20
src/types/electron.d.ts
vendored
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user