@mmmbuto/nexuscrew 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +79 -66
- package/bin/nexuscrew.js +467 -264
- package/frontend/dist/apple-touch-icon.png +0 -0
- package/frontend/dist/assets/{index-8pw4eMB-.js → index-BG1N7bSL.js} +268 -273
- package/frontend/dist/assets/index-CiAtinNP.css +1 -0
- package/frontend/dist/favicon.svg +20 -0
- package/frontend/dist/icon-192.png +0 -0
- package/frontend/dist/icon-512.png +0 -0
- package/frontend/dist/index.html +11 -3
- package/frontend/dist/site.webmanifest +29 -0
- package/lib/config/hosts.js +67 -0
- package/lib/config/manager.js +23 -6
- package/lib/server/routes/hosts.js +11 -20
- package/lib/server/routes/keys.js +13 -0
- package/lib/server/routes/send.js +52 -12
- package/lib/server/routes/sessions.js +9 -12
- package/lib/server/routes/speech.js +22 -51
- package/lib/server/routes/status.js +8 -6
- package/lib/server/routes/upload.js +2 -1
- package/lib/server/server.js +21 -20
- package/lib/server/services/attachment-manager.js +57 -0
- package/lib/server/services/speech-manager.js +76 -0
- package/lib/services/log-watcher.js +40 -22
- package/lib/services/remote-pane-watcher.js +155 -0
- package/lib/services/session-store.js +3 -3
- package/lib/services/tmux-manager.js +127 -116
- package/lib/setup/postinstall.js +38 -0
- package/lib/utils/paths.js +18 -1
- package/lib/utils/termux.js +38 -1
- package/package.json +3 -3
- package/frontend/dist/assets/index-BF0tdvNT.css +0 -1
|
@@ -13,17 +13,16 @@ const router = Router();
|
|
|
13
13
|
// List conversations (optionally grouped by date)
|
|
14
14
|
router.get('/', (req, res) => {
|
|
15
15
|
const { store, tmux } = req;
|
|
16
|
-
const { workspace, groupBy } = req.query;
|
|
16
|
+
const { workspace, groupBy, host } = req.query;
|
|
17
17
|
|
|
18
18
|
if (groupBy === 'date') {
|
|
19
|
-
const grouped = store.listGroupedByDate({ workspace });
|
|
19
|
+
const grouped = store.listGroupedByDate({ workspace, host });
|
|
20
20
|
|
|
21
21
|
// Enrich with tmux_alive status
|
|
22
22
|
Object.keys(grouped).forEach(dateGroup => {
|
|
23
23
|
grouped[dateGroup].forEach(conv => {
|
|
24
24
|
if (conv.tmux_window) {
|
|
25
|
-
|
|
26
|
-
conv.tmux_alive = tmux.hasWindow(conv.tmux_window, host);
|
|
25
|
+
conv.tmux_alive = tmux.hasWindow(conv.tmux_window, conv.host || 'local');
|
|
27
26
|
} else {
|
|
28
27
|
conv.tmux_alive = false;
|
|
29
28
|
}
|
|
@@ -33,13 +32,12 @@ router.get('/', (req, res) => {
|
|
|
33
32
|
return res.json(grouped);
|
|
34
33
|
}
|
|
35
34
|
|
|
36
|
-
const convs = store.listConversations({ workspace });
|
|
35
|
+
const convs = store.listConversations({ workspace, host });
|
|
37
36
|
|
|
38
37
|
// Enrich with tmux_alive status
|
|
39
38
|
convs.forEach(conv => {
|
|
40
39
|
if (conv.tmux_window) {
|
|
41
|
-
|
|
42
|
-
conv.tmux_alive = tmux.hasWindow(conv.tmux_window, host);
|
|
40
|
+
conv.tmux_alive = tmux.hasWindow(conv.tmux_window, conv.host || 'local');
|
|
43
41
|
} else {
|
|
44
42
|
conv.tmux_alive = false;
|
|
45
43
|
}
|
|
@@ -58,8 +56,7 @@ router.get('/:id', (req, res) => {
|
|
|
58
56
|
}
|
|
59
57
|
|
|
60
58
|
// Check tmux window status
|
|
61
|
-
const
|
|
62
|
-
const alive = conv.tmux_window ? tmux.hasWindow(conv.tmux_window, host) : false;
|
|
59
|
+
const alive = conv.tmux_window ? tmux.hasWindow(conv.tmux_window, conv.host || 'local') : false;
|
|
63
60
|
|
|
64
61
|
res.json({
|
|
65
62
|
session: {
|
|
@@ -113,21 +110,21 @@ router.post('/:id/pin', (req, res) => {
|
|
|
113
110
|
|
|
114
111
|
// Delete conversation (kills tmux window too)
|
|
115
112
|
router.delete('/:id', (req, res) => {
|
|
116
|
-
const { store, tmux, logWatcher } = req;
|
|
113
|
+
const { store, tmux, logWatcher, remotePaneWatcher } = req;
|
|
117
114
|
const conv = store.getConversation(req.params.id);
|
|
118
115
|
|
|
119
116
|
if (!conv) return res.status(404).json({ error: 'Not found' });
|
|
120
117
|
|
|
121
118
|
// Kill tmux window if active
|
|
122
119
|
if (conv.tmux_window) {
|
|
123
|
-
|
|
124
|
-
tmux.killWindow(conv.tmux_window, host);
|
|
120
|
+
tmux.killWindow(conv.tmux_window, conv.host || 'local');
|
|
125
121
|
}
|
|
126
122
|
|
|
127
123
|
// Stop log watcher
|
|
128
124
|
if (conv.log_path) {
|
|
129
125
|
logWatcher.unwatch(conv.log_path);
|
|
130
126
|
}
|
|
127
|
+
remotePaneWatcher?.unwatchByConversation?.(req.params.id);
|
|
131
128
|
|
|
132
129
|
store.deleteConversation(req.params.id);
|
|
133
130
|
res.json({ success: true });
|
|
@@ -1,74 +1,45 @@
|
|
|
1
1
|
const express = require('express');
|
|
2
2
|
const router = express.Router();
|
|
3
3
|
const multer = require('multer');
|
|
4
|
-
const
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const { PATHS } = require('../../utils/paths');
|
|
5
8
|
|
|
6
9
|
const upload = multer({
|
|
7
10
|
storage: multer.memoryStorage(),
|
|
8
|
-
limits: { fileSize: 25 * 1024 * 1024 }
|
|
11
|
+
limits: { fileSize: 25 * 1024 * 1024 }
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
router.get('/status', (req, res) => {
|
|
15
|
+
res.json(req.speech.getStatus());
|
|
9
16
|
});
|
|
10
17
|
|
|
11
|
-
/**
|
|
12
|
-
* POST /api/v1/speech/transcribe
|
|
13
|
-
* Transcribe audio using OpenAI Whisper API
|
|
14
|
-
* Body: multipart/form-data with 'audio' file and 'language' (optional)
|
|
15
|
-
* Returns: { text: string }
|
|
16
|
-
*
|
|
17
|
-
* Supported languages (aligned with UI): it, en, es, ja, ru, zh
|
|
18
|
-
*/
|
|
19
18
|
router.post('/transcribe', upload.single('audio'), async (req, res) => {
|
|
20
|
-
|
|
21
|
-
const apiKey = getApiKey('openai');
|
|
22
|
-
if (!apiKey) {
|
|
23
|
-
return res.status(400).json({ error: 'OpenAI API key not configured' });
|
|
24
|
-
}
|
|
19
|
+
let tempPath = null;
|
|
25
20
|
|
|
21
|
+
try {
|
|
26
22
|
if (!req.file) {
|
|
27
23
|
return res.status(400).json({ error: 'No audio file provided' });
|
|
28
24
|
}
|
|
29
25
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
type: req.file.mimetype || 'audio/webm'
|
|
34
|
-
});
|
|
35
|
-
formData.append('file', audioFile);
|
|
36
|
-
formData.append('model', 'whisper-1');
|
|
37
|
-
|
|
38
|
-
// Use language from request - extract base language (it-IT -> it)
|
|
39
|
-
if (req.body.language) {
|
|
40
|
-
const lang = req.body.language.split('-')[0]; // it-IT -> it
|
|
41
|
-
formData.append('language', lang);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
console.log('[Speech] Transcribing audio:', {
|
|
45
|
-
size: req.file.size,
|
|
46
|
-
mimetype: req.file.mimetype,
|
|
47
|
-
language: req.body.language || 'auto-detect'
|
|
48
|
-
});
|
|
26
|
+
fs.mkdirSync(PATHS.DATA_DIR, { recursive: true });
|
|
27
|
+
tempPath = path.join(PATHS.DATA_DIR, `stt-${Date.now()}-${Math.random().toString(16).slice(2)}.webm`);
|
|
28
|
+
fs.writeFileSync(tempPath, req.file.buffer);
|
|
49
29
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
headers: {
|
|
54
|
-
'Authorization': `Bearer ${apiKey}`
|
|
55
|
-
},
|
|
56
|
-
body: formData
|
|
30
|
+
const result = await req.speech.transcribe({
|
|
31
|
+
audioPath: tempPath,
|
|
32
|
+
language: req.body.language
|
|
57
33
|
});
|
|
58
34
|
|
|
59
|
-
|
|
60
|
-
const error = await response.json();
|
|
61
|
-
console.error('[Speech] OpenAI error:', error);
|
|
62
|
-
return res.status(response.status).json(error);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const data = await response.json();
|
|
66
|
-
console.log('[Speech] Transcription success:', data.text?.substring(0, 50) + '...');
|
|
67
|
-
res.json(data);
|
|
68
|
-
|
|
35
|
+
res.json(result);
|
|
69
36
|
} catch (error) {
|
|
70
37
|
console.error('[Speech] Transcription error:', error);
|
|
71
38
|
res.status(500).json({ error: error.message });
|
|
39
|
+
} finally {
|
|
40
|
+
if (tempPath && fs.existsSync(tempPath)) {
|
|
41
|
+
fs.unlinkSync(tempPath);
|
|
42
|
+
}
|
|
72
43
|
}
|
|
73
44
|
});
|
|
74
45
|
|
|
@@ -10,13 +10,14 @@ const router = Router();
|
|
|
10
10
|
// Overall status
|
|
11
11
|
router.get('/', (req, res) => {
|
|
12
12
|
const { tmux, store } = req;
|
|
13
|
-
const
|
|
13
|
+
const { host = 'local' } = req.query;
|
|
14
|
+
const windows = tmux.listWindows(host);
|
|
14
15
|
const hosts = tmux.getAllHosts();
|
|
15
16
|
|
|
16
17
|
res.json({
|
|
17
18
|
tmux: {
|
|
18
19
|
session: tmux.sessionName,
|
|
19
|
-
alive: tmux.hasSession(),
|
|
20
|
+
alive: tmux.hasSession(host),
|
|
20
21
|
windowCount: windows.length,
|
|
21
22
|
windows
|
|
22
23
|
},
|
|
@@ -30,7 +31,8 @@ router.get('/', (req, res) => {
|
|
|
30
31
|
// Detailed tmux windows
|
|
31
32
|
router.get('/tmux', (req, res) => {
|
|
32
33
|
const { tmux, store } = req;
|
|
33
|
-
const
|
|
34
|
+
const { host = 'local' } = req.query;
|
|
35
|
+
const windows = tmux.listWindows(host);
|
|
34
36
|
|
|
35
37
|
// Enrich with DB data
|
|
36
38
|
const enriched = windows.map(w => {
|
|
@@ -40,7 +42,7 @@ router.get('/tmux', (req, res) => {
|
|
|
40
42
|
conversationId: conv?.id || null,
|
|
41
43
|
engine: conv?.engine || null,
|
|
42
44
|
model: conv?.model || null,
|
|
43
|
-
host: conv?.host || 'local',
|
|
45
|
+
host: conv?.host || host || 'local',
|
|
44
46
|
title: conv?.title || w.name
|
|
45
47
|
};
|
|
46
48
|
});
|
|
@@ -51,8 +53,8 @@ router.get('/tmux', (req, res) => {
|
|
|
51
53
|
// Capture pane content
|
|
52
54
|
router.get('/pane/:window', (req, res) => {
|
|
53
55
|
const { tmux } = req;
|
|
54
|
-
const { host } = req.query;
|
|
55
|
-
const content = tmux.capturePane(req.params.window, host
|
|
56
|
+
const { host = 'local' } = req.query;
|
|
57
|
+
const content = tmux.capturePane(req.params.window, host);
|
|
56
58
|
|
|
57
59
|
res.json({ window: req.params.window, content });
|
|
58
60
|
});
|
|
@@ -2,11 +2,12 @@ const express = require('express');
|
|
|
2
2
|
const multer = require('multer');
|
|
3
3
|
const path = require('path');
|
|
4
4
|
const fs = require('fs');
|
|
5
|
+
const { PATHS } = require('../../utils/paths');
|
|
5
6
|
|
|
6
7
|
const router = express.Router();
|
|
7
8
|
|
|
8
9
|
// NexusCrew: attachments in ~/.nexuscrew/attachments
|
|
9
|
-
const ATTACHMENTS_DIR = path.join(
|
|
10
|
+
const ATTACHMENTS_DIR = path.join(PATHS.ATTACHMENTS_DIR, 'incoming');
|
|
10
11
|
|
|
11
12
|
// Ensure directory exists
|
|
12
13
|
if (!fs.existsSync(ATTACHMENTS_DIR)) {
|
package/lib/server/server.js
CHANGED
|
@@ -16,6 +16,10 @@ const TmuxManager = require('../services/tmux-manager');
|
|
|
16
16
|
const EngineDiscovery = require('../services/engine-discovery');
|
|
17
17
|
const SessionStore = require('../services/session-store');
|
|
18
18
|
const LogWatcher = require('../services/log-watcher');
|
|
19
|
+
const RemotePaneWatcher = require('../services/remote-pane-watcher');
|
|
20
|
+
const AttachmentManager = require('./services/attachment-manager');
|
|
21
|
+
const SpeechManager = require('./services/speech-manager');
|
|
22
|
+
const { getConfig } = require('../config/manager');
|
|
19
23
|
|
|
20
24
|
// Routes
|
|
21
25
|
const sendRouter = require('./routes/send');
|
|
@@ -36,27 +40,15 @@ const { authMiddleware } = require('./middleware/auth');
|
|
|
36
40
|
|
|
37
41
|
class NexusCrewServer {
|
|
38
42
|
constructor(config = {}) {
|
|
39
|
-
this.port = config.port || process.env.PORT || 41820;
|
|
40
43
|
this.configDir = config.configDir || path.join(process.env.HOME, '.nexuscrew');
|
|
41
44
|
this.config = this._loadConfig();
|
|
45
|
+
this.port = config.port || process.env.PORT || this.config.server?.port || 41820;
|
|
42
46
|
this.app = express();
|
|
43
47
|
this.server = null;
|
|
44
48
|
}
|
|
45
49
|
|
|
46
50
|
_loadConfig() {
|
|
47
|
-
|
|
48
|
-
if (fs.existsSync(configPath)) {
|
|
49
|
-
return JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
50
|
-
}
|
|
51
|
-
return { port: this.port, tmuxSession: 'nexuscrew', autoDiscovery: true };
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
_loadHosts() {
|
|
55
|
-
const hostsPath = path.join(this.configDir, 'hosts.json');
|
|
56
|
-
if (fs.existsSync(hostsPath)) {
|
|
57
|
-
return JSON.parse(fs.readFileSync(hostsPath, 'utf8'));
|
|
58
|
-
}
|
|
59
|
-
return [{ name: 'local', type: 'local', default: true }];
|
|
51
|
+
return getConfig();
|
|
60
52
|
}
|
|
61
53
|
|
|
62
54
|
async start() {
|
|
@@ -67,21 +59,24 @@ class NexusCrewServer {
|
|
|
67
59
|
const User = require('./models/User');
|
|
68
60
|
if (User.count() === 0) {
|
|
69
61
|
User.create('admin', 'admin', 'admin');
|
|
70
|
-
console.log(' Created
|
|
62
|
+
console.log(' Created initial admin account from local defaults');
|
|
71
63
|
}
|
|
72
64
|
|
|
73
65
|
// 3. Init services
|
|
74
|
-
const hosts = this._loadHosts();
|
|
75
66
|
const tmuxManager = new TmuxManager({
|
|
76
67
|
tmuxSession: this.config.tmuxSession || 'nexuscrew',
|
|
77
|
-
logDir: this.config.logDir || path.join(this.configDir, 'logs')
|
|
78
|
-
hosts
|
|
68
|
+
logDir: this.config.logDir || path.join(this.configDir, 'logs')
|
|
79
69
|
});
|
|
80
70
|
|
|
81
71
|
const engineDiscovery = new EngineDiscovery(this.config.providersPath);
|
|
82
72
|
const sessionStore = new SessionStore();
|
|
83
73
|
await sessionStore.init();
|
|
84
74
|
const logWatcher = new LogWatcher();
|
|
75
|
+
const remotePaneWatcher = new RemotePaneWatcher(tmuxManager);
|
|
76
|
+
const attachmentManager = new AttachmentManager(tmuxManager);
|
|
77
|
+
const speechManager = new SpeechManager();
|
|
78
|
+
this.app.locals.logWatcher = logWatcher;
|
|
79
|
+
this.app.locals.remotePaneWatcher = remotePaneWatcher;
|
|
85
80
|
|
|
86
81
|
// Ensure tmux master session
|
|
87
82
|
tmuxManager.ensureSession();
|
|
@@ -106,7 +101,7 @@ class NexusCrewServer {
|
|
|
106
101
|
res.json({
|
|
107
102
|
status: 'ok',
|
|
108
103
|
service: 'nexuscrew',
|
|
109
|
-
version: '0.2.
|
|
104
|
+
version: '0.2.1',
|
|
110
105
|
tmux: tmuxManager.hasSession(),
|
|
111
106
|
windows: tmuxManager.listWindows().length,
|
|
112
107
|
uptime: process.uptime()
|
|
@@ -119,6 +114,9 @@ class NexusCrewServer {
|
|
|
119
114
|
req.engines = engineDiscovery;
|
|
120
115
|
req.store = sessionStore;
|
|
121
116
|
req.logWatcher = logWatcher;
|
|
117
|
+
req.remotePaneWatcher = remotePaneWatcher;
|
|
118
|
+
req.attachments = attachmentManager;
|
|
119
|
+
req.speech = speechManager;
|
|
122
120
|
req.config = this.config;
|
|
123
121
|
next();
|
|
124
122
|
});
|
|
@@ -168,7 +166,7 @@ class NexusCrewServer {
|
|
|
168
166
|
console.log('');
|
|
169
167
|
console.log(` HTTP: http://localhost:${this.port}`);
|
|
170
168
|
console.log(` tmux: session "${tmuxManager.sessionName}" (${tmuxManager.listWindows().length} windows)`);
|
|
171
|
-
console.log(` Hosts: ${
|
|
169
|
+
console.log(` Hosts: ${tmuxManager.getAllHosts().map(h => h.name).join(', ')}`);
|
|
172
170
|
console.log('');
|
|
173
171
|
|
|
174
172
|
const engines = engineDiscovery.discover();
|
|
@@ -184,6 +182,9 @@ class NexusCrewServer {
|
|
|
184
182
|
|
|
185
183
|
stop() {
|
|
186
184
|
if (this.server) {
|
|
185
|
+
const appLocals = this.app?.locals || {};
|
|
186
|
+
appLocals.logWatcher?.unwatchAll?.();
|
|
187
|
+
appLocals.remotePaneWatcher?.unwatchAll?.();
|
|
187
188
|
saveDb();
|
|
188
189
|
this.server.close();
|
|
189
190
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { PATHS } = require('../../utils/paths');
|
|
4
|
+
|
|
5
|
+
class AttachmentManager {
|
|
6
|
+
constructor(tmux) {
|
|
7
|
+
this.tmux = tmux;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
getLocalAttachmentDir(conversationId = 'shared') {
|
|
11
|
+
return path.join(PATHS.ATTACHMENTS_DIR, 'local', conversationId);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
getRemoteAttachmentDir(conversationId = 'shared') {
|
|
15
|
+
return path.posix.join('.nexuscrew', 'attachments', conversationId);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
stageForConversation(file, conversationId, hostRef) {
|
|
19
|
+
if (!file?.path) {
|
|
20
|
+
throw new Error('Attachment path is required');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const host = this.tmux.resolveHost(hostRef);
|
|
24
|
+
const baseName = path.basename(file.path);
|
|
25
|
+
|
|
26
|
+
if (!host || host.type === 'local') {
|
|
27
|
+
const localDir = this.getLocalAttachmentDir(conversationId);
|
|
28
|
+
fs.mkdirSync(localDir, { recursive: true });
|
|
29
|
+
const localPath = path.join(localDir, baseName);
|
|
30
|
+
if (file.path !== localPath) {
|
|
31
|
+
fs.copyFileSync(file.path, localPath);
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
...file,
|
|
35
|
+
host: 'local',
|
|
36
|
+
localPath,
|
|
37
|
+
promptPath: localPath,
|
|
38
|
+
ready: true
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const remoteDir = this.getRemoteAttachmentDir(conversationId);
|
|
43
|
+
const remotePath = path.posix.join(remoteDir, baseName);
|
|
44
|
+
this.tmux.copyToHost(file.path, remotePath, host);
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
...file,
|
|
48
|
+
host: host.name,
|
|
49
|
+
localPath: file.path,
|
|
50
|
+
remotePath,
|
|
51
|
+
promptPath: `~/${remotePath}`,
|
|
52
|
+
ready: true
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = AttachmentManager;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { execFile } = require('child_process');
|
|
4
|
+
const { promisify } = require('util');
|
|
5
|
+
const { getConfig } = require('../../config/manager');
|
|
6
|
+
const { PATHS } = require('../../utils/paths');
|
|
7
|
+
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
|
|
10
|
+
function resolveBundledWhisper() {
|
|
11
|
+
const candidates = [
|
|
12
|
+
path.join(PATHS.BIN_DIR, 'whisper-cli'),
|
|
13
|
+
path.join(__dirname, '../../../bin/android-arm64/whisper-cli'),
|
|
14
|
+
path.join(__dirname, '../../../bin/linux-x64/whisper-cli')
|
|
15
|
+
];
|
|
16
|
+
return candidates.find((candidate) => fs.existsSync(candidate)) || 'whisper-cli';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
class SpeechManager {
|
|
20
|
+
getStatus() {
|
|
21
|
+
const config = getConfig();
|
|
22
|
+
const whisperBin = config.stt?.whisper?.bin || resolveBundledWhisper();
|
|
23
|
+
const whisperModel = config.stt?.whisper?.model || '';
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
provider: config.stt?.provider || 'whispercpp',
|
|
27
|
+
fallback: config.stt?.fallback || 'browser',
|
|
28
|
+
whisper: {
|
|
29
|
+
bin: whisperBin,
|
|
30
|
+
model: whisperModel,
|
|
31
|
+
configured: Boolean(whisperModel),
|
|
32
|
+
available: this.checkBinary(whisperBin)
|
|
33
|
+
},
|
|
34
|
+
tts: {
|
|
35
|
+
provider: config.tts?.provider || 'system'
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
checkBinary(binPath) {
|
|
41
|
+
if (!binPath) return false;
|
|
42
|
+
if (binPath === 'whisper-cli') return true;
|
|
43
|
+
return fs.existsSync(binPath);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async transcribe({ audioPath, language }) {
|
|
47
|
+
const config = getConfig();
|
|
48
|
+
const bin = config.stt?.whisper?.bin || resolveBundledWhisper();
|
|
49
|
+
const model = config.stt?.whisper?.model;
|
|
50
|
+
const threads = String(config.stt?.whisper?.threads || 2);
|
|
51
|
+
const lang = language || config.stt?.whisper?.language || 'it';
|
|
52
|
+
|
|
53
|
+
if (!model) {
|
|
54
|
+
throw new Error('whisper.cpp model not configured');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const args = ['-m', model, '-f', audioPath, '-l', lang, '-t', threads, '--no-timestamps'];
|
|
58
|
+
const { stdout, stderr } = await execFileAsync(bin, args, {
|
|
59
|
+
timeout: 120000,
|
|
60
|
+
maxBuffer: 8 * 1024 * 1024
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const text = [stdout, stderr]
|
|
64
|
+
.filter(Boolean)
|
|
65
|
+
.join('\n')
|
|
66
|
+
.split('\n')
|
|
67
|
+
.map((line) => line.trim())
|
|
68
|
+
.filter((line) => line && !line.startsWith('['))
|
|
69
|
+
.join(' ')
|
|
70
|
+
.trim();
|
|
71
|
+
|
|
72
|
+
return { text };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
module.exports = SpeechManager;
|
|
@@ -27,10 +27,7 @@ class LogWatcher extends EventEmitter {
|
|
|
27
27
|
|
|
28
28
|
const state = {
|
|
29
29
|
offset: 0,
|
|
30
|
-
engine,
|
|
31
|
-
conversationId,
|
|
32
|
-
buffer: '',
|
|
33
|
-
parser: this._getParser(engine)
|
|
30
|
+
...this.createParserState(engine, conversationId)
|
|
34
31
|
};
|
|
35
32
|
|
|
36
33
|
// Read existing content (in case log already has data)
|
|
@@ -98,39 +95,60 @@ class LogWatcher extends EventEmitter {
|
|
|
98
95
|
|
|
99
96
|
state.offset = stat.size;
|
|
100
97
|
const chunk = buf.toString('utf8');
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
// Process complete lines
|
|
104
|
-
this._processBuffer(state);
|
|
98
|
+
this.processTextChunk(chunk, state);
|
|
105
99
|
} catch (err) {
|
|
106
100
|
// File might be temporarily unavailable
|
|
107
101
|
}
|
|
108
102
|
}
|
|
109
103
|
|
|
104
|
+
createParserState(engine, conversationId) {
|
|
105
|
+
return {
|
|
106
|
+
engine,
|
|
107
|
+
conversationId,
|
|
108
|
+
buffer: '',
|
|
109
|
+
parser: this._getParser(engine)
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
processTextChunk(chunk, state) {
|
|
114
|
+
state.buffer += chunk;
|
|
115
|
+
this._processBuffer(state);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
flushState(state) {
|
|
119
|
+
if (!state.buffer?.trim()) return;
|
|
120
|
+
const line = state.buffer;
|
|
121
|
+
state.buffer = '';
|
|
122
|
+
this._emitParsedLine(line, state);
|
|
123
|
+
}
|
|
124
|
+
|
|
110
125
|
_processBuffer(state) {
|
|
111
126
|
const lines = state.buffer.split('\n');
|
|
112
127
|
state.buffer = lines.pop() || ''; // Keep incomplete line in buffer
|
|
113
128
|
|
|
114
129
|
for (const line of lines) {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
}
|
|
125
|
-
} catch {
|
|
126
|
-
// Emit raw text if parser fails
|
|
130
|
+
this._emitParsedLine(line, state);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
_emitParsedLine(line, state) {
|
|
135
|
+
if (!line.trim()) return;
|
|
136
|
+
try {
|
|
137
|
+
const events = state.parser(line, state);
|
|
138
|
+
for (const event of events) {
|
|
127
139
|
this.emit('data', {
|
|
128
|
-
|
|
129
|
-
content: line,
|
|
140
|
+
...event,
|
|
130
141
|
conversationId: state.conversationId,
|
|
131
142
|
engine: state.engine
|
|
132
143
|
});
|
|
133
144
|
}
|
|
145
|
+
} catch {
|
|
146
|
+
this.emit('data', {
|
|
147
|
+
type: 'raw',
|
|
148
|
+
content: line,
|
|
149
|
+
conversationId: state.conversationId,
|
|
150
|
+
engine: state.engine
|
|
151
|
+
});
|
|
134
152
|
}
|
|
135
153
|
}
|
|
136
154
|
|