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 { 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()
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
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 {
|
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');
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
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,
|
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,
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user