feat: 添加语音模块支持,优化服务启动方式
This commit is contained in:
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 = {
|
||||
|
||||
@@ -63,6 +63,13 @@ async function getStaticModules(): Promise<ApiModule[]> {
|
||||
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'
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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<void>((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');
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ function getBasePath() {
|
||||
if (process.pkg) {
|
||||
return path.dirname(process.execPath);
|
||||
}
|
||||
return path.join(__dirname, '../..');
|
||||
return process.cwd();
|
||||
}
|
||||
|
||||
function getPublicPath() {
|
||||
|
||||
Binary file not shown.
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',
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user