diff --git a/api/.env.example b/api/.env.example new file mode 100644 index 0000000..7cc9099 --- /dev/null +++ b/api/.env.example @@ -0,0 +1,2 @@ +MINIMAX_API_KEY=your_api_key_here +MINIMAX_GROUP_ID=your_group_id_here diff --git a/api/app.ts b/api/app.ts index 718e6a7..60080e9 100644 --- a/api/app.ts +++ b/api/app.ts @@ -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() diff --git a/api/config/index.ts b/api/config/index.ts index 78b27b4..cc7f1a6 100644 --- a/api/config/index.ts +++ b/api/config/index.ts @@ -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 = { diff --git a/api/modules/index.ts b/api/modules/index.ts index 19641c1..117e59a 100644 --- a/api/modules/index.ts +++ b/api/modules/index.ts @@ -63,6 +63,13 @@ async function getStaticModules(): Promise { 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 @@ -82,3 +89,4 @@ 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' diff --git a/api/modules/voice/index.ts b/api/modules/voice/index.ts new file mode 100644 index 0000000..9658ad6 --- /dev/null +++ b/api/modules/voice/index.ts @@ -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 diff --git a/api/modules/voice/routes.ts b/api/modules/voice/routes.ts new file mode 100644 index 0000000..c632b9f --- /dev/null +++ b/api/modules/voice/routes.ts @@ -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 +} diff --git a/electron/services/opencodeService.ts b/electron/services/opencodeService.ts index f28ff0e..d6ede19 100644 --- a/electron/services/opencodeService.ts +++ b/electron/services/opencodeService.ts @@ -150,7 +150,8 @@ class OpenCodeService { try { log.info('[OpenCodeService] Stopping...'); - const pid = this.process.pid; + const pid = this.process?.pid; + const processRef = this.process; this.process = null; this._isRunning = false; @@ -165,11 +166,11 @@ class OpenCodeService { resolve({ success: true }); }); }); - } else if (pid) { - this.process?.kill('SIGTERM'); + } else if (pid && processRef) { + processRef.kill('SIGTERM'); await new Promise((resolve) => setTimeout(resolve, 1000)); - if (this.process && !this.process.killed) { - this.process.kill('SIGKILL'); + if (!processRef.killed) { + processRef.kill('SIGKILL'); } this.restartAttempts = 0; log.info('[OpenCodeService] Stopped'); diff --git a/package.json b/package.json index cca8b5c..adb8ea0 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "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", diff --git a/remote/config/default.json b/remote/config/default.json index e70e913..6cfd798 100644 --- a/remote/config/default.json +++ b/remote/config/default.json @@ -23,12 +23,10 @@ "tokenExpiry": 3600 }, "frp": { - "enabled": true, - "frpcPath": "./frp/frpc.exe", - "configPath": "./frp/frpc.toml" + "enabled": true }, "opencode": { - "enabled": true + "enabled": false }, "xcopencodeweb": { "enabled": true, diff --git a/remote/src/core/App.js b/remote/src/core/App.js index 1d96925..2025b7c 100644 --- a/remote/src/core/App.js +++ b/remote/src/core/App.js @@ -118,16 +118,6 @@ class App { }); }); - this.container.register('openCodeService', (c) => { - const OpenCodeService = require('../services/opencode/OpenCodeService'); - const config = c.resolve('config'); - const opencodeConfig = config.getSection('opencode') || {}; - return new OpenCodeService({ - enabled: opencodeConfig.enabled !== false, - port: opencodeConfig.port - }); - }); - this.container.register('xcOpenCodeWebService', (c) => { const XCOpenCodeWebService = require('../services/opencode/XCOpenCodeWebService'); const config = c.resolve('config'); @@ -212,10 +202,6 @@ 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'); @@ -498,10 +484,6 @@ 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'); diff --git a/remote/src/services/network/FRPService.js b/remote/src/services/network/FRPService.js index 65ede32..d404f01 100644 --- a/remote/src/services/network/FRPService.js +++ b/remote/src/services/network/FRPService.js @@ -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; diff --git a/remote/src/services/opencode/OpenCodeService.js b/remote/src/services/opencode/OpenCodeService.js index b3aa66f..ed2c61a 100644 --- a/remote/src/services/opencode/OpenCodeService.js +++ b/remote/src/services/opencode/OpenCodeService.js @@ -11,9 +11,17 @@ class OpenCodeService { 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; } @@ -49,11 +57,10 @@ class OpenCodeService { OPENCODE_SERVER_PASSWORD: password }; - const portArg = this.port ? ` --port ${this.port}` : ''; - this.process = spawn('powershell.exe', [ - '-NoProfile', - '-Command', - `& '${this.opencodePath}' serve${portArg}` + 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, @@ -63,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}`); } diff --git a/remote/src/services/opencode/XCOpenCodeWebService.js b/remote/src/services/opencode/XCOpenCodeWebService.js index 07d047d..f66eec4 100644 --- a/remote/src/services/opencode/XCOpenCodeWebService.js +++ b/remote/src/services/opencode/XCOpenCodeWebService.js @@ -15,7 +15,7 @@ class XCOpenCodeWebService { getExePath() { const exeName = 'XCOpenCodeWeb.exe'; - const basePath = path.join(__dirname, '../../xcopencodeweb'); + const basePath = path.join(__dirname, '../../../xcopencodeweb'); return path.join(basePath, exeName); } diff --git a/remote/src/utils/paths.js b/remote/src/utils/paths.js index 4536ece..1fd5484 100644 --- a/remote/src/utils/paths.js +++ b/remote/src/utils/paths.js @@ -5,7 +5,7 @@ function getBasePath() { if (process.pkg) { return path.dirname(process.execPath); } - return path.join(__dirname, '../..'); + return process.cwd(); } function getPublicPath() { diff --git a/services/xcopencodeweb/XCOpenCodeWeb.exe b/services/xcopencodeweb/XCOpenCodeWeb.exe index fa52d8a..fc00a40 100644 Binary files a/services/xcopencodeweb/XCOpenCodeWeb.exe and b/services/xcopencodeweb/XCOpenCodeWeb.exe differ diff --git a/shared/modules/voice/index.ts b/shared/modules/voice/index.ts new file mode 100644 index 0000000..b5f766f --- /dev/null +++ b/shared/modules/voice/index.ts @@ -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', +} diff --git a/src/components/chat/MultimodalInput.tsx b/src/components/chat/MultimodalInput.tsx index 651865d..6eb7e26 100644 --- a/src/components/chat/MultimodalInput.tsx +++ b/src/components/chat/MultimodalInput.tsx @@ -174,4 +174,4 @@ export function MultimodalInput({ ) -} \ No newline at end of file +} diff --git a/src/components/chat/icons.tsx b/src/components/chat/icons.tsx index 2f7736f..58b727f 100644 --- a/src/components/chat/icons.tsx +++ b/src/components/chat/icons.tsx @@ -9,6 +9,7 @@ import { Trash, PanelLeftClose, PanelLeft, + Mic, } from 'lucide-react' export { @@ -22,4 +23,5 @@ export { Trash as TrashIcon, PanelLeftClose as PanelLeftCloseIcon, PanelLeft as PanelLeftOpenIcon, + Mic as MicIcon, } \ No newline at end of file