feat: 添加语音模块支持,优化服务启动方式

This commit is contained in:
2026-03-17 04:03:39 +08:00
parent 308df54a15
commit 90517f2289
18 changed files with 221 additions and 43 deletions

2
api/.env.example Normal file
View File

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

View File

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

View File

@@ -38,6 +38,18 @@ export const config = {
get isDev(): boolean { get isDev(): boolean {
return !this.isElectron && !this.isVercel 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 = { export const PATHS = {

View File

@@ -63,6 +63,13 @@ async function getStaticModules(): Promise<ApiModule[]> {
console.warn('[ModuleLoader] Failed to load opencode module:', 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) => { modules.sort((a, b) => {
const orderA = a.metadata.order ?? 0 const orderA = a.metadata.order ?? 0
const orderB = b.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 './ai/index.js'
export * from './remote/index.js' export * from './remote/index.js'
export * from './opencode/index.js' export * from './opencode/index.js'
export * from './voice/index.js'

View File

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

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

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

View File

@@ -150,7 +150,8 @@ class OpenCodeService {
try { try {
log.info('[OpenCodeService] Stopping...'); log.info('[OpenCodeService] Stopping...');
const pid = this.process.pid; const pid = this.process?.pid;
const processRef = this.process;
this.process = null; this.process = null;
this._isRunning = false; this._isRunning = false;
@@ -165,11 +166,11 @@ class OpenCodeService {
resolve({ success: true }); resolve({ success: true });
}); });
}); });
} else if (pid) { } else if (pid && processRef) {
this.process?.kill('SIGTERM'); processRef.kill('SIGTERM');
await new Promise<void>((resolve) => setTimeout(resolve, 1000)); await new Promise<void>((resolve) => setTimeout(resolve, 1000));
if (this.process && !this.process.killed) { if (!processRef.killed) {
this.process.kill('SIGKILL'); processRef.kill('SIGKILL');
} }
this.restartAttempts = 0; this.restartAttempts = 0;
log.info('[OpenCodeService] Stopped'); log.info('[OpenCodeService] Stopped');

View File

@@ -65,6 +65,7 @@
"dotenv": "^17.2.1", "dotenv": "^17.2.1",
"electron-log": "^5.4.3", "electron-log": "^5.4.3",
"express": "^4.21.2", "express": "^4.21.2",
"form-data": "^4.0.5",
"github-slugger": "^2.0.0", "github-slugger": "^2.0.0",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"lucide-react": "^0.511.0", "lucide-react": "^0.511.0",

View File

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

View File

@@ -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) => { this.container.register('xcOpenCodeWebService', (c) => {
const XCOpenCodeWebService = require('../services/opencode/XCOpenCodeWebService'); const XCOpenCodeWebService = require('../services/opencode/XCOpenCodeWebService');
const config = c.resolve('config'); const config = c.resolve('config');
@@ -212,10 +202,6 @@ class App {
frpService.start(); frpService.start();
logger.info('FRP service started'); logger.info('FRP service started');
const openCodeService = this.container.resolve('openCodeService');
openCodeService.start();
logger.info('OpenCode service started');
const xcOpenCodeWebService = this.container.resolve('xcOpenCodeWebService'); const xcOpenCodeWebService = this.container.resolve('xcOpenCodeWebService');
xcOpenCodeWebService.start(); xcOpenCodeWebService.start();
logger.info('XCOpenCodeWeb service started'); logger.info('XCOpenCodeWeb service started');
@@ -498,10 +484,6 @@ class App {
frpService.stop(); frpService.stop();
logger.info('FRP service stopped'); logger.info('FRP service stopped');
const openCodeService = this.container.resolve('openCodeService');
openCodeService.stop();
logger.info('OpenCode service stopped');
const xcOpenCodeWebService = this.container.resolve('xcOpenCodeWebService'); const xcOpenCodeWebService = this.container.resolve('xcOpenCodeWebService');
xcOpenCodeWebService.stop(); xcOpenCodeWebService.stop();
logger.info('XCOpenCodeWeb service stopped'); logger.info('XCOpenCodeWeb service stopped');

View File

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

View File

@@ -11,9 +11,17 @@ class OpenCodeService {
this.enabled = options.enabled !== false; this.enabled = options.enabled !== false;
try { try {
const result = execSync('where opencode', { encoding: 'utf8', windowsHide: true }); const result = execSync('powershell -Command "Get-Command opencode -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source"', { encoding: 'utf8', windowsHide: true, maxBuffer: 1024 * 1024 });
const firstLine = result.split('\n')[0].trim(); const opencodePath = result.trim();
this.opencodePath = firstLine.endsWith('.cmd') ? firstLine : firstLine + '.cmd'; 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) { } catch (e) {
this.opencodePath = null; this.opencodePath = null;
} }
@@ -49,11 +57,10 @@ class OpenCodeService {
OPENCODE_SERVER_PASSWORD: password OPENCODE_SERVER_PASSWORD: password
}; };
const portArg = this.port ? ` --port ${this.port}` : ''; const portArg = this.port ? ' --port ' + this.port : '';
this.process = spawn('powershell.exe', [ this.process = spawn('cmd.exe', [
'-NoProfile', '/c',
'-Command', 'chcp 65001 >nul && ' + this.opencodePath + ' serve' + portArg
`& '${this.opencodePath}' serve${portArg}`
], { ], {
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
env, env,
@@ -63,14 +70,14 @@ class OpenCodeService {
this.isRunning = true; this.isRunning = true;
this.process.stdout.on('data', (data) => { this.process.stdout.on('data', (data) => {
const output = data.toString().trim(); const output = data.toString('utf8').trim();
if (output) { if (output) {
logger.info(`[OpenCode] ${output}`); logger.info(`[OpenCode] ${output}`);
} }
}); });
this.process.stderr.on('data', (data) => { this.process.stderr.on('data', (data) => {
const output = data.toString().trim(); const output = data.toString('utf8').trim();
if (output) { if (output) {
logger.error(`[OpenCode] ${output}`); logger.error(`[OpenCode] ${output}`);
} }

View File

@@ -15,7 +15,7 @@ class XCOpenCodeWebService {
getExePath() { getExePath() {
const exeName = 'XCOpenCodeWeb.exe'; const exeName = 'XCOpenCodeWeb.exe';
const basePath = path.join(__dirname, '../../xcopencodeweb'); const basePath = path.join(__dirname, '../../../xcopencodeweb');
return path.join(basePath, exeName); return path.join(basePath, exeName);
} }

View File

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

View File

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

View File

@@ -9,6 +9,7 @@ import {
Trash, Trash,
PanelLeftClose, PanelLeftClose,
PanelLeft, PanelLeft,
Mic,
} from 'lucide-react' } from 'lucide-react'
export { export {
@@ -22,4 +23,5 @@ export {
Trash as TrashIcon, Trash as TrashIcon,
PanelLeftClose as PanelLeftCloseIcon, PanelLeftClose as PanelLeftCloseIcon,
PanelLeft as PanelLeftOpenIcon, PanelLeft as PanelLeftOpenIcon,
Mic as MicIcon,
} }