@mmmbuto/nexuscrew 0.1.0-beta.1 → 0.2.0
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 +54 -120
- package/bin/nexuscrew.js +9 -8
- package/frontend/dist/assets/{index-C7bAndew.js → index-8pw4eMB-.js} +1715 -1702
- package/frontend/dist/assets/index-BF0tdvNT.css +1 -0
- package/frontend/dist/index.html +2 -2
- package/lib/config/manager.js +362 -0
- package/lib/config/models.js +408 -0
- package/lib/server/db/adapter.js +274 -0
- package/lib/server/db/drivers/sql-js.js +75 -0
- package/lib/server/db/migrate.js +174 -0
- package/lib/server/db/migrations/001_base_schema.sql +70 -0
- package/lib/server/middleware/auth.js +134 -0
- package/lib/server/middleware/rate-limit.js +63 -0
- package/lib/server/models/User.js +128 -0
- package/lib/server/routes/auth.js +168 -0
- package/lib/server/routes/keys.js +15 -0
- package/lib/server/routes/models.js +59 -4
- package/lib/server/routes/runtimes.js +34 -0
- package/lib/server/routes/send.js +28 -4
- package/lib/server/routes/sessions.js +34 -2
- package/lib/server/routes/speech.js +75 -0
- package/lib/server/routes/upload.js +134 -0
- package/lib/server/routes/wake-lock.js +95 -0
- package/lib/server/routes/workspaces.js +101 -0
- package/lib/server/server.js +48 -16
- package/lib/server/services/context-bridge.js +425 -0
- package/lib/server/services/runtime-manager.js +462 -0
- package/lib/server/services/summary-generator.js +309 -0
- package/lib/server/services/workspace-manager.js +79 -0
- package/lib/services/engine-discovery.js +198 -13
- package/lib/services/session-store.js +57 -61
- package/lib/utils/paths.js +107 -0
- package/lib/utils/termux.js +145 -0
- package/package.json +6 -2
- package/frontend/dist/assets/index-OENqI1_9.css +0 -1
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const rateLimit = require('express-rate-limit');
|
|
3
|
+
const bcrypt = require('bcryptjs');
|
|
4
|
+
const User = require('../models/User');
|
|
5
|
+
const { generateToken, authMiddleware } = require('../middleware/auth');
|
|
6
|
+
const { getConfig } = require('../../config/manager');
|
|
7
|
+
|
|
8
|
+
const router = express.Router();
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Check config user (admin from init)
|
|
12
|
+
* Returns user object compatible with DB user structure
|
|
13
|
+
*/
|
|
14
|
+
function findConfigUser(username) {
|
|
15
|
+
const config = getConfig();
|
|
16
|
+
if (config.auth && config.auth.user === username && config.auth.pass_hash) {
|
|
17
|
+
return {
|
|
18
|
+
id: 'config-admin',
|
|
19
|
+
username: config.auth.user,
|
|
20
|
+
password_hash: config.auth.pass_hash,
|
|
21
|
+
role: 'admin',
|
|
22
|
+
is_locked: false,
|
|
23
|
+
failed_attempts: 0,
|
|
24
|
+
created_at: Date.now()
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Verify password for config user
|
|
32
|
+
*/
|
|
33
|
+
function verifyConfigPassword(passHash, password) {
|
|
34
|
+
return bcrypt.compareSync(password, passHash);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Rate limiter: max 5 login attempts per 15 minutes per IP
|
|
38
|
+
const loginLimiter = rateLimit({
|
|
39
|
+
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
40
|
+
max: 10,
|
|
41
|
+
message: {
|
|
42
|
+
error: 'Too many login attempts, please try again later',
|
|
43
|
+
retry_after: 15 * 60
|
|
44
|
+
},
|
|
45
|
+
standardHeaders: true,
|
|
46
|
+
legacyHeaders: false
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// POST /api/v1/auth/login
|
|
50
|
+
router.post('/login', loginLimiter, async (req, res) => {
|
|
51
|
+
try {
|
|
52
|
+
const { username, password } = req.body;
|
|
53
|
+
const ipAddress = req.ip || req.connection.remoteAddress;
|
|
54
|
+
|
|
55
|
+
if (!username || !password) {
|
|
56
|
+
return res.status(400).json({ error: 'Username and password required' });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Check IP rate limiting (additional layer)
|
|
60
|
+
const recentAttempts = User.getRecentLoginAttempts(ipAddress);
|
|
61
|
+
if (recentAttempts > 20) {
|
|
62
|
+
return res.status(429).json({
|
|
63
|
+
error: 'Too many failed attempts from this IP',
|
|
64
|
+
retry_after: 15 * 60
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// First check config user (admin from init)
|
|
69
|
+
let user = findConfigUser(username);
|
|
70
|
+
let isConfigUser = !!user;
|
|
71
|
+
|
|
72
|
+
// If not config user, check database
|
|
73
|
+
if (!user) {
|
|
74
|
+
user = User.findByUsername(username);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!user) {
|
|
78
|
+
// Log failed attempt even for non-existent user
|
|
79
|
+
User.logLoginAttempt(ipAddress, username, false);
|
|
80
|
+
return res.status(401).json({ error: 'Invalid credentials' });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Check if account is locked (only for DB users)
|
|
84
|
+
if (!isConfigUser && User.isAccountLocked(user)) {
|
|
85
|
+
User.logLoginAttempt(ipAddress, username, false);
|
|
86
|
+
const remainingMs = user.locked_until - Date.now();
|
|
87
|
+
return res.status(403).json({
|
|
88
|
+
error: 'Account locked due to failed login attempts',
|
|
89
|
+
locked_until: user.locked_until,
|
|
90
|
+
retry_after: Math.ceil(remainingMs / 1000)
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Verify password
|
|
95
|
+
let isValid;
|
|
96
|
+
if (isConfigUser) {
|
|
97
|
+
isValid = verifyConfigPassword(user.password_hash, password);
|
|
98
|
+
} else {
|
|
99
|
+
isValid = User.verifyPassword(user, password);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (!isValid) {
|
|
103
|
+
User.logLoginAttempt(ipAddress, username, false);
|
|
104
|
+
if (!isConfigUser) {
|
|
105
|
+
User.incrementFailedAttempts(user.id);
|
|
106
|
+
}
|
|
107
|
+
return res.status(401).json({ error: 'Invalid credentials' });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Success
|
|
111
|
+
User.logLoginAttempt(ipAddress, username, true);
|
|
112
|
+
if (!isConfigUser) {
|
|
113
|
+
User.resetFailedAttempts(user.id);
|
|
114
|
+
User.updateLastLogin(user.id);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const token = generateToken(user);
|
|
118
|
+
|
|
119
|
+
res.json({
|
|
120
|
+
token,
|
|
121
|
+
user: {
|
|
122
|
+
id: user.id,
|
|
123
|
+
username: user.username,
|
|
124
|
+
role: user.role
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
} catch (error) {
|
|
128
|
+
console.error('Login error:', error);
|
|
129
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// GET /api/v1/auth/me
|
|
134
|
+
router.get('/me', authMiddleware, (req, res) => {
|
|
135
|
+
// Config user is already validated by authMiddleware
|
|
136
|
+
// Just return the user info from req.user
|
|
137
|
+
if (req.user.id === 'config-admin') {
|
|
138
|
+
const config = getConfig();
|
|
139
|
+
return res.json({
|
|
140
|
+
id: req.user.id,
|
|
141
|
+
username: req.user.username,
|
|
142
|
+
role: req.user.role,
|
|
143
|
+
created_at: Date.now(),
|
|
144
|
+
last_login: null
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const user = User.findById(req.user.id);
|
|
149
|
+
if (!user) {
|
|
150
|
+
return res.status(404).json({ error: 'User not found' });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
res.json({
|
|
154
|
+
id: user.id,
|
|
155
|
+
username: user.username,
|
|
156
|
+
role: user.role,
|
|
157
|
+
created_at: user.created_at,
|
|
158
|
+
last_login: user.last_login
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// POST /api/v1/auth/logout
|
|
163
|
+
router.post('/logout', authMiddleware, (req, res) => {
|
|
164
|
+
// With JWT, logout is handled client-side by removing token
|
|
165
|
+
res.json({ message: 'Logged out successfully' });
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
module.exports = router;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const router = express.Router();
|
|
3
|
+
const { getApiKey } = require('../db/adapter');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* GET /api/v1/keys/check/:provider
|
|
7
|
+
* Check if an API key is configured for a provider
|
|
8
|
+
* Returns: { configured: boolean }
|
|
9
|
+
*/
|
|
10
|
+
router.get('/check/:provider', (req, res) => {
|
|
11
|
+
const key = getApiKey(req.params.provider);
|
|
12
|
+
res.json({ configured: !!key });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
module.exports = router;
|
|
@@ -1,14 +1,69 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* GET /api/v1/models — List available models and engines
|
|
3
|
+
*
|
|
4
|
+
* Merges engine-discovery results with runtime-manager status
|
|
3
5
|
*/
|
|
4
6
|
|
|
5
7
|
const { Router } = require('express');
|
|
8
|
+
const RuntimeManager = require('../services/runtime-manager');
|
|
6
9
|
const router = Router();
|
|
7
10
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
11
|
+
const runtimeManager = new RuntimeManager();
|
|
12
|
+
|
|
13
|
+
router.get('/', async (req, res) => {
|
|
14
|
+
try {
|
|
15
|
+
// Get engine discovery results
|
|
16
|
+
const { engines } = req;
|
|
17
|
+
const discoveryData = engines.getForFrontend();
|
|
18
|
+
|
|
19
|
+
// Get runtime-aware inventory
|
|
20
|
+
const runtimeInventory = await runtimeManager.getRuntimeInventory();
|
|
21
|
+
const runtimeMap = Object.fromEntries(
|
|
22
|
+
runtimeInventory.map(r => [r.runtimeId, r])
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
// Merge data
|
|
26
|
+
const merged = {};
|
|
27
|
+
|
|
28
|
+
for (const [engineName, engineData] of Object.entries(discoveryData)) {
|
|
29
|
+
merged[engineName] = {
|
|
30
|
+
...engineData,
|
|
31
|
+
models: engineData.models.map(model => {
|
|
32
|
+
const runtimeId = model.lane === 'custom'
|
|
33
|
+
? `${engineName}-custom`
|
|
34
|
+
: `${engineName}-native`;
|
|
35
|
+
|
|
36
|
+
const runtime = runtimeMap[runtimeId];
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
id: model.id,
|
|
40
|
+
label: model.name,
|
|
41
|
+
name: model.name,
|
|
42
|
+
lane: model.lane,
|
|
43
|
+
available: runtime?.available ?? false,
|
|
44
|
+
runtimeStatus: runtime?.status ?? 'unknown',
|
|
45
|
+
runtimeCommand: runtime?.command || null,
|
|
46
|
+
runtimeSource: runtime?.source || null,
|
|
47
|
+
providerId: model.providerId || null,
|
|
48
|
+
...(model.alias && { alias: model.alias }),
|
|
49
|
+
...(model.modelHint && { modelHint: model.modelHint }),
|
|
50
|
+
};
|
|
51
|
+
}),
|
|
52
|
+
aliases: engineData.aliases.map(alias => {
|
|
53
|
+
const runtime = runtimeMap[`${engineName}-custom`];
|
|
54
|
+
return {
|
|
55
|
+
...alias,
|
|
56
|
+
available: runtime?.available ?? false,
|
|
57
|
+
};
|
|
58
|
+
})
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
res.json(merged);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
console.error('[Models] Error merging discovery and runtime data:', error);
|
|
65
|
+
res.status(500).json({ error: 'Failed to fetch models' });
|
|
66
|
+
}
|
|
12
67
|
});
|
|
13
68
|
|
|
14
69
|
module.exports = router;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const RuntimeManager = require('../services/runtime-manager');
|
|
3
|
+
|
|
4
|
+
const router = express.Router();
|
|
5
|
+
const runtimeManager = new RuntimeManager();
|
|
6
|
+
|
|
7
|
+
router.get('/', async (_req, res) => {
|
|
8
|
+
try {
|
|
9
|
+
const inventory = await runtimeManager.getRuntimeInventory();
|
|
10
|
+
res.json({
|
|
11
|
+
platform: runtimeManager.platformId,
|
|
12
|
+
runtimes: inventory,
|
|
13
|
+
});
|
|
14
|
+
} catch (error) {
|
|
15
|
+
console.error('[Runtimes] Inventory error:', error);
|
|
16
|
+
res.status(500).json({ error: 'Failed to fetch runtime inventory' });
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
router.post('/check', async (_req, res) => {
|
|
21
|
+
try {
|
|
22
|
+
const inventory = await runtimeManager.getRuntimeInventory();
|
|
23
|
+
res.json({
|
|
24
|
+
platform: runtimeManager.platformId,
|
|
25
|
+
runtimes: inventory,
|
|
26
|
+
checkedAt: new Date().toISOString(),
|
|
27
|
+
});
|
|
28
|
+
} catch (error) {
|
|
29
|
+
console.error('[Runtimes] Check error:', error);
|
|
30
|
+
res.status(500).json({ error: 'Failed to check runtimes' });
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
module.exports = router;
|
|
@@ -68,11 +68,32 @@ router.post('/', async (req, res) => {
|
|
|
68
68
|
});
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
// Context Bridge: if engine changed, add context to message
|
|
72
|
+
let fullMessage = message;
|
|
73
|
+
if (!isNew && conversationId && conversation.engine && conversation.engine !== resolvedEngine) {
|
|
74
|
+
try {
|
|
75
|
+
const { default: ContextBridge } = require('../services/context-bridge');
|
|
76
|
+
const ctx = await ContextBridge.buildContext({
|
|
77
|
+
conversationId: conversation.id,
|
|
78
|
+
fromEngine: conversation.engine,
|
|
79
|
+
toEngine: resolvedEngine,
|
|
80
|
+
userMessage: message
|
|
81
|
+
});
|
|
82
|
+
if (ctx.prompt) {
|
|
83
|
+
fullMessage = ctx.prompt;
|
|
84
|
+
console.log(`[send] Applied context bridge: ${conversation.engine} -> ${resolvedEngine} (${ctx.contextTokens} tokens)`);
|
|
85
|
+
}
|
|
86
|
+
} catch (err) {
|
|
87
|
+
console.warn('[send] Context bridge failed, proceeding without context:', err.message);
|
|
88
|
+
// Continue without context - non-fatal
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
71
92
|
// Save user message
|
|
72
93
|
store.addMessage({
|
|
73
94
|
conversationId: conversation.id,
|
|
74
95
|
role: 'user',
|
|
75
|
-
content:
|
|
96
|
+
content: fullMessage,
|
|
76
97
|
engine: resolvedEngine,
|
|
77
98
|
model: model
|
|
78
99
|
});
|
|
@@ -92,6 +113,7 @@ router.post('/', async (req, res) => {
|
|
|
92
113
|
} else if (engineInfo?.available) {
|
|
93
114
|
command = buildEngineCommand(resolvedEngine, engineInfo, {
|
|
94
115
|
model,
|
|
116
|
+
message: fullMessage, // Pass fullMessage for codex exec mode
|
|
95
117
|
resume: resumeSession || (conversation.tmux_window ? conversation.id : null)
|
|
96
118
|
});
|
|
97
119
|
} else {
|
|
@@ -105,7 +127,7 @@ router.post('/', async (req, res) => {
|
|
|
105
127
|
|
|
106
128
|
if (conversation.tmux_window && tmux.hasWindow(conversation.tmux_window, hostObj)) {
|
|
107
129
|
// Resume existing window — send message directly
|
|
108
|
-
tmux.sendKeys(conversation.tmux_window,
|
|
130
|
+
tmux.sendKeys(conversation.tmux_window, fullMessage, { enter: true, host: hostName });
|
|
109
131
|
logPath = conversation.log_path;
|
|
110
132
|
} else {
|
|
111
133
|
// Create new tmux window
|
|
@@ -127,7 +149,7 @@ router.post('/', async (req, res) => {
|
|
|
127
149
|
if (resolvedEngine !== 'codex') {
|
|
128
150
|
// Interactive CLIs need a moment to start
|
|
129
151
|
await new Promise(r => setTimeout(r, 1500));
|
|
130
|
-
tmux.sendKeys(windowName,
|
|
152
|
+
tmux.sendKeys(windowName, fullMessage, { enter: true, host: hostName });
|
|
131
153
|
}
|
|
132
154
|
// Codex exec mode gets the message via the command itself
|
|
133
155
|
}
|
|
@@ -255,7 +277,9 @@ function buildEngineCommand(engine, engineInfo, opts = {}) {
|
|
|
255
277
|
return `${bin} --output-format json${opts.model ? ` --model ${opts.model}` : ''}`;
|
|
256
278
|
|
|
257
279
|
case 'codex':
|
|
258
|
-
|
|
280
|
+
// For codex, message is passed via --prompt flag in exec mode
|
|
281
|
+
const prompt = opts.message ? ` --prompt "${opts.message.replace(/"/g, '\\"')}"` : '';
|
|
282
|
+
return `${bin} exec --json${opts.model ? ` -m ${opts.model}` : ''}${opts.reasoningEffort ? ` --reasoning ${opts.reasoningEffort}` : ''}${prompt}`;
|
|
259
283
|
|
|
260
284
|
case 'gemini':
|
|
261
285
|
if (opts.resume) {
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* GET /api/v1/sessions — List/load sessions
|
|
2
|
+
* GET /api/v1/sessions — List/load sessions (with tmux_alive status)
|
|
3
3
|
* GET /api/v1/sessions/:id/messages — Get session messages
|
|
4
4
|
* GET /api/v1/sessions/:id/summary — Get session summary
|
|
5
5
|
* POST /api/v1/sessions/:id/rename — Rename session
|
|
6
|
+
* POST /api/v1/sessions/:id/pin — Pin/unpin session
|
|
6
7
|
* DELETE /api/v1/sessions/:id — Delete session
|
|
7
8
|
*/
|
|
8
9
|
|
|
@@ -11,15 +12,39 @@ const router = Router();
|
|
|
11
12
|
|
|
12
13
|
// List conversations (optionally grouped by date)
|
|
13
14
|
router.get('/', (req, res) => {
|
|
14
|
-
const { store } = req;
|
|
15
|
+
const { store, tmux } = req;
|
|
15
16
|
const { workspace, groupBy } = req.query;
|
|
16
17
|
|
|
17
18
|
if (groupBy === 'date') {
|
|
18
19
|
const grouped = store.listGroupedByDate({ workspace });
|
|
20
|
+
|
|
21
|
+
// Enrich with tmux_alive status
|
|
22
|
+
Object.keys(grouped).forEach(dateGroup => {
|
|
23
|
+
grouped[dateGroup].forEach(conv => {
|
|
24
|
+
if (conv.tmux_window) {
|
|
25
|
+
const host = conv.host !== 'local' ? conv.host : null;
|
|
26
|
+
conv.tmux_alive = tmux.hasWindow(conv.tmux_window, host);
|
|
27
|
+
} else {
|
|
28
|
+
conv.tmux_alive = false;
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
19
33
|
return res.json(grouped);
|
|
20
34
|
}
|
|
21
35
|
|
|
22
36
|
const convs = store.listConversations({ workspace });
|
|
37
|
+
|
|
38
|
+
// Enrich with tmux_alive status
|
|
39
|
+
convs.forEach(conv => {
|
|
40
|
+
if (conv.tmux_window) {
|
|
41
|
+
const host = conv.host !== 'local' ? conv.host : null;
|
|
42
|
+
conv.tmux_alive = tmux.hasWindow(conv.tmux_window, host);
|
|
43
|
+
} else {
|
|
44
|
+
conv.tmux_alive = false;
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
23
48
|
res.json({ conversations: convs });
|
|
24
49
|
});
|
|
25
50
|
|
|
@@ -79,6 +104,13 @@ router.post('/:id/rename', (req, res) => {
|
|
|
79
104
|
res.json({ success: true, title });
|
|
80
105
|
});
|
|
81
106
|
|
|
107
|
+
// Pin/unpin conversation
|
|
108
|
+
router.post('/:id/pin', (req, res) => {
|
|
109
|
+
const { store } = req;
|
|
110
|
+
const result = store.pinConversation(req.params.id);
|
|
111
|
+
res.json({ pinned: result });
|
|
112
|
+
});
|
|
113
|
+
|
|
82
114
|
// Delete conversation (kills tmux window too)
|
|
83
115
|
router.delete('/:id', (req, res) => {
|
|
84
116
|
const { store, tmux, logWatcher } = req;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const router = express.Router();
|
|
3
|
+
const multer = require('multer');
|
|
4
|
+
const { getApiKey } = require('../db/adapter');
|
|
5
|
+
|
|
6
|
+
const upload = multer({
|
|
7
|
+
storage: multer.memoryStorage(),
|
|
8
|
+
limits: { fileSize: 25 * 1024 * 1024 } // 25MB max (Whisper limit)
|
|
9
|
+
});
|
|
10
|
+
|
|
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
|
+
router.post('/transcribe', upload.single('audio'), async (req, res) => {
|
|
20
|
+
try {
|
|
21
|
+
const apiKey = getApiKey('openai');
|
|
22
|
+
if (!apiKey) {
|
|
23
|
+
return res.status(400).json({ error: 'OpenAI API key not configured' });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (!req.file) {
|
|
27
|
+
return res.status(400).json({ error: 'No audio file provided' });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Build FormData using native Node.js 22+ FormData and File
|
|
31
|
+
const formData = new FormData();
|
|
32
|
+
const audioFile = new File([req.file.buffer], 'audio.webm', {
|
|
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
|
+
});
|
|
49
|
+
|
|
50
|
+
// Use native fetch (Node.js 22+)
|
|
51
|
+
const response = await fetch('https://api.openai.com/v1/audio/transcriptions', {
|
|
52
|
+
method: 'POST',
|
|
53
|
+
headers: {
|
|
54
|
+
'Authorization': `Bearer ${apiKey}`
|
|
55
|
+
},
|
|
56
|
+
body: formData
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
if (!response.ok) {
|
|
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
|
+
|
|
69
|
+
} catch (error) {
|
|
70
|
+
console.error('[Speech] Transcription error:', error);
|
|
71
|
+
res.status(500).json({ error: error.message });
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
module.exports = router;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const multer = require('multer');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
|
|
6
|
+
const router = express.Router();
|
|
7
|
+
|
|
8
|
+
// NexusCrew: attachments in ~/.nexuscrew/attachments
|
|
9
|
+
const ATTACHMENTS_DIR = path.join(process.env.HOME, '.nexuscrew', 'attachments');
|
|
10
|
+
|
|
11
|
+
// Ensure directory exists
|
|
12
|
+
if (!fs.existsSync(ATTACHMENTS_DIR)) {
|
|
13
|
+
fs.mkdirSync(ATTACHMENTS_DIR, { recursive: true });
|
|
14
|
+
console.log(`[Upload] Created attachments directory: ${ATTACHMENTS_DIR}`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Multer storage config
|
|
18
|
+
const storage = multer.diskStorage({
|
|
19
|
+
destination: (req, file, cb) => {
|
|
20
|
+
cb(null, ATTACHMENTS_DIR);
|
|
21
|
+
},
|
|
22
|
+
filename: (req, file, cb) => {
|
|
23
|
+
// Unique filename: timestamp_originalname
|
|
24
|
+
const timestamp = Date.now();
|
|
25
|
+
const safeName = file.originalname.replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
26
|
+
cb(null, `${timestamp}_${safeName}`);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// File filter - allow images and documents
|
|
31
|
+
const fileFilter = (req, file, cb) => {
|
|
32
|
+
const allowedMimes = [
|
|
33
|
+
// Images
|
|
34
|
+
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml',
|
|
35
|
+
// Documents
|
|
36
|
+
'text/plain', 'text/markdown', 'text/csv', 'text/html', 'text/css',
|
|
37
|
+
'application/json', 'application/xml', 'application/pdf',
|
|
38
|
+
'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
39
|
+
'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
40
|
+
// Code files (often sent as octet-stream)
|
|
41
|
+
'application/octet-stream', 'text/x-python', 'text/javascript', 'application/javascript'
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
// Also allow by extension for code files
|
|
45
|
+
const allowedExts = ['.js', '.ts', '.py', '.java', '.c', '.cpp', '.h', '.rb', '.php', '.go', '.rs', '.sh', '.bash', '.zsh', '.md', '.txt', '.json', '.yaml', '.yml', '.toml', '.xml', '.html', '.css', '.sql', '.log'];
|
|
46
|
+
const ext = path.extname(file.originalname).toLowerCase();
|
|
47
|
+
|
|
48
|
+
if (allowedMimes.includes(file.mimetype) || allowedExts.includes(ext)) {
|
|
49
|
+
cb(null, true);
|
|
50
|
+
} else {
|
|
51
|
+
cb(new Error(`File type not allowed: ${file.mimetype} (${ext})`), false);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// Multer config - 50MB limit
|
|
56
|
+
const upload = multer({
|
|
57
|
+
storage,
|
|
58
|
+
fileFilter,
|
|
59
|
+
limits: { fileSize: 50 * 1024 * 1024 }
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// POST /api/v1/upload - Single file upload
|
|
63
|
+
router.post('/', upload.single('file'), (req, res) => {
|
|
64
|
+
try {
|
|
65
|
+
if (!req.file) {
|
|
66
|
+
return res.status(400).json({ error: 'No file uploaded' });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const filePath = req.file.path;
|
|
70
|
+
const fileName = req.file.filename;
|
|
71
|
+
const originalName = req.file.originalname;
|
|
72
|
+
const mimeType = req.file.mimetype;
|
|
73
|
+
const size = req.file.size;
|
|
74
|
+
|
|
75
|
+
console.log(`[Upload] File saved: ${filePath} (${size} bytes, ${mimeType})`);
|
|
76
|
+
|
|
77
|
+
res.json({
|
|
78
|
+
success: true,
|
|
79
|
+
file: {
|
|
80
|
+
path: filePath,
|
|
81
|
+
name: fileName,
|
|
82
|
+
originalName,
|
|
83
|
+
mimeType,
|
|
84
|
+
size
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
} catch (error) {
|
|
88
|
+
console.error('[Upload] Error:', error);
|
|
89
|
+
res.status(500).json({ error: error.message });
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// POST /api/v1/upload/multiple - Multiple files upload
|
|
94
|
+
router.post('/multiple', upload.array('files', 10), (req, res) => {
|
|
95
|
+
try {
|
|
96
|
+
if (!req.files || req.files.length === 0) {
|
|
97
|
+
return res.status(400).json({ error: 'No files uploaded' });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const files = req.files.map(f => ({
|
|
101
|
+
path: f.path,
|
|
102
|
+
name: f.filename,
|
|
103
|
+
originalName: f.originalname,
|
|
104
|
+
mimeType: f.mimetype,
|
|
105
|
+
size: f.size
|
|
106
|
+
}));
|
|
107
|
+
|
|
108
|
+
console.log(`[Upload] ${files.length} files saved`);
|
|
109
|
+
|
|
110
|
+
res.json({
|
|
111
|
+
success: true,
|
|
112
|
+
files
|
|
113
|
+
});
|
|
114
|
+
} catch (error) {
|
|
115
|
+
console.error('[Upload] Error:', error);
|
|
116
|
+
res.status(500).json({ error: error.message });
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Error handler for multer
|
|
121
|
+
router.use((error, req, res, next) => {
|
|
122
|
+
if (error instanceof multer.MulterError) {
|
|
123
|
+
if (error.code === 'LIMIT_FILE_SIZE') {
|
|
124
|
+
return res.status(400).json({ error: 'File too large (max 50MB)' });
|
|
125
|
+
}
|
|
126
|
+
return res.status(400).json({ error: error.message });
|
|
127
|
+
}
|
|
128
|
+
if (error) {
|
|
129
|
+
return res.status(400).json({ error: error.message });
|
|
130
|
+
}
|
|
131
|
+
next();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
module.exports = router;
|