@mmmbuto/nexuscli 0.9.6 → 0.9.7-termux
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 +21 -4
- package/lib/cli/engines.js +51 -2
- package/lib/config/manager.js +5 -0
- package/lib/config/models.js +28 -0
- package/lib/server/middleware/rate-limit.js +1 -1
- package/lib/server/models/Message.js +1 -1
- package/lib/server/routes/models.js +2 -0
- package/lib/server/routes/qwen.js +240 -0
- package/lib/server/routes/sessions.js +13 -1
- package/lib/server/server.js +7 -4
- package/lib/server/services/cli-loader.js +83 -3
- package/lib/server/services/context-bridge.js +4 -2
- package/lib/server/services/qwen-output-parser.js +289 -0
- package/lib/server/services/qwen-wrapper.js +251 -0
- package/lib/server/services/session-importer.js +35 -2
- package/lib/server/services/session-manager.js +32 -5
- package/lib/server/tests/history-sync.test.js +11 -2
- package/lib/server/tests/integration-session-sync.test.js +40 -8
- package/lib/server/tests/integration.test.js +33 -16
- package/lib/server/tests/performance.test.js +16 -9
- package/lib/server/tests/services.test.js +17 -10
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
## Overview
|
|
10
10
|
|
|
11
|
-
NexusCLI is a lightweight, Termux-first AI cockpit to orchestrate Claude Code, Codex CLI, and
|
|
11
|
+
NexusCLI is a lightweight, Termux-first AI cockpit to orchestrate Claude Code, Codex CLI, Gemini CLI, and Qwen Code CLI from a single web/terminal UI. It supports live interrupts, native session resume, and voice input with HTTPS auto-setup for remote devices.
|
|
12
12
|
|
|
13
13
|
---
|
|
14
14
|
|
|
@@ -27,7 +27,13 @@ NexusCLI is a lightweight, Termux-first AI cockpit to orchestrate Claude Code, C
|
|
|
27
27
|
|
|
28
28
|
---
|
|
29
29
|
|
|
30
|
-
## Highlights (v0.9.
|
|
30
|
+
## Highlights (v0.9.7-termux, testing)
|
|
31
|
+
|
|
32
|
+
- **QWEN Engine**: Integrated Qwen Code CLI with streaming statusbar events
|
|
33
|
+
- **QWEN Models**: `coder-model` and `vision-model` available in UI
|
|
34
|
+
- **Session Import**: Qwen sessions indexed alongside Claude/Codex/Gemini
|
|
35
|
+
|
|
36
|
+
### v0.9.6
|
|
31
37
|
|
|
32
38
|
- **Jobs Runner Restored**: `jobs` route works again after cleanup regression
|
|
33
39
|
- **Termux-Safe Execution**: no hardcoded `/bin` or `/usr/bin` paths for job tools
|
|
@@ -47,7 +53,7 @@ NexusCLI is a lightweight, Termux-first AI cockpit to orchestrate Claude Code, C
|
|
|
47
53
|
|
|
48
54
|
## Features
|
|
49
55
|
|
|
50
|
-
- Multi-engine support (Claude, Codex, Gemini)
|
|
56
|
+
- Multi-engine support (Claude, Codex, Gemini, Qwen)
|
|
51
57
|
- Session continuity with explicit workspace selection
|
|
52
58
|
- SSE streaming responses
|
|
53
59
|
- Model selector with think-mode toggle and default model preference
|
|
@@ -65,6 +71,7 @@ NexusCLI is a lightweight, Termux-first AI cockpit to orchestrate Claude Code, C
|
|
|
65
71
|
| **Claude-compatible** | DeepSeek (deepseek-*), GLM-4.6 | DeepSeek, Z.ai |
|
|
66
72
|
| **Codex** | GPT-5.2 Codex, GPT-5.2, GPT-5.1 Codex (Mini/Max), GPT-5.1 | OpenAI |
|
|
67
73
|
| **Gemini** | Gemini 3 Pro Preview, Gemini 3 Flash Preview | Google |
|
|
74
|
+
| **Qwen** | coder-model, vision-model | Alibaba |
|
|
68
75
|
|
|
69
76
|
---
|
|
70
77
|
|
|
@@ -78,6 +85,13 @@ npm install -g @mmmbuto/nexuscli
|
|
|
78
85
|
npm install -g github:DioNanos/nexuscli
|
|
79
86
|
```
|
|
80
87
|
|
|
88
|
+
### Release Channels
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
# Testing channel
|
|
92
|
+
npm install -g @mmmbuto/nexuscli@testing
|
|
93
|
+
```
|
|
94
|
+
|
|
81
95
|
## Setup
|
|
82
96
|
|
|
83
97
|
```bash
|
|
@@ -132,7 +146,7 @@ nexuscli api set openrouter <key> # Future: Multi-provider gateway
|
|
|
132
146
|
nexuscli api delete <provider> # Remove key
|
|
133
147
|
```
|
|
134
148
|
|
|
135
|
-
> **Note**: Claude/Codex/Gemini keys are managed by their respective CLIs.
|
|
149
|
+
> **Note**: Claude/Codex/Gemini/Qwen keys are managed by their respective CLIs.
|
|
136
150
|
> OpenAI key enables voice input via Whisper. HTTPS auto-generated for remote mic access.
|
|
137
151
|
|
|
138
152
|
---
|
|
@@ -144,6 +158,7 @@ nexuscli api delete <provider> # Remove key
|
|
|
144
158
|
- Claude Code CLI (`claude`)
|
|
145
159
|
- Codex CLI (`codex`)
|
|
146
160
|
- Gemini CLI (`gemini`)
|
|
161
|
+
- Qwen Code CLI (`qwen`)
|
|
147
162
|
|
|
148
163
|
---
|
|
149
164
|
|
|
@@ -177,9 +192,11 @@ It is a **research and learning tool**.
|
|
|
177
192
|
| `POST /api/v1/chat` | Claude | SSE streaming chat |
|
|
178
193
|
| `POST /api/v1/codex` | Codex | SSE streaming chat |
|
|
179
194
|
| `POST /api/v1/gemini` | Gemini | SSE streaming chat |
|
|
195
|
+
| `POST /api/v1/qwen` | Qwen | SSE streaming chat |
|
|
180
196
|
| `POST /api/v1/chat/interrupt` | Claude | Stop running generation |
|
|
181
197
|
| `POST /api/v1/codex/interrupt` | Codex | Stop running generation |
|
|
182
198
|
| `POST /api/v1/gemini/interrupt` | Gemini | Stop running generation |
|
|
199
|
+
| `POST /api/v1/qwen/interrupt` | Qwen | Stop running generation |
|
|
183
200
|
| `GET /api/v1/models` | All | List available models |
|
|
184
201
|
| `GET /api/v1/config` | - | Get user preferences (default model) |
|
|
185
202
|
| `GET /health` | - | Health check |
|
package/lib/cli/engines.js
CHANGED
|
@@ -18,7 +18,8 @@ function detectEngines() {
|
|
|
18
18
|
const engines = {
|
|
19
19
|
claude: { available: false, path: null, version: null },
|
|
20
20
|
codex: { available: false, path: null, version: null },
|
|
21
|
-
gemini: { available: false, path: null, version: null }
|
|
21
|
+
gemini: { available: false, path: null, version: null },
|
|
22
|
+
qwen: { available: false, path: null, version: null }
|
|
22
23
|
};
|
|
23
24
|
|
|
24
25
|
// Claude
|
|
@@ -42,6 +43,13 @@ function detectEngines() {
|
|
|
42
43
|
engines.gemini = { available: true, path: geminiPath, version: geminiVersion };
|
|
43
44
|
} catch {}
|
|
44
45
|
|
|
46
|
+
// Qwen
|
|
47
|
+
try {
|
|
48
|
+
const qwenPath = execSync('which qwen 2>/dev/null', { encoding: 'utf8' }).trim();
|
|
49
|
+
const qwenVersion = execSync('qwen --version 2>/dev/null', { encoding: 'utf8' }).trim().split('\n')[0];
|
|
50
|
+
engines.qwen = { available: true, path: qwenPath, version: qwenVersion };
|
|
51
|
+
} catch {}
|
|
52
|
+
|
|
45
53
|
return engines;
|
|
46
54
|
}
|
|
47
55
|
|
|
@@ -87,6 +95,16 @@ function listEngines() {
|
|
|
87
95
|
console.log(chalk.bold(`║ Gemini: ${chalk.gray('○ not installed')}`));
|
|
88
96
|
}
|
|
89
97
|
|
|
98
|
+
// Qwen
|
|
99
|
+
const qwenEnabled = config.engines?.qwen?.enabled === true;
|
|
100
|
+
if (detected.qwen.available) {
|
|
101
|
+
const status = qwenEnabled ? chalk.green('✓ enabled') : chalk.gray('○ disabled');
|
|
102
|
+
console.log(chalk.bold(`║ QWEN: ${status}`));
|
|
103
|
+
console.log(chalk.gray(`║ ${detected.qwen.version}`));
|
|
104
|
+
} else {
|
|
105
|
+
console.log(chalk.bold(`║ QWEN: ${chalk.gray('○ not installed')}`));
|
|
106
|
+
}
|
|
107
|
+
|
|
90
108
|
console.log(chalk.bold('╚═══════════════════════════════════════════╝'));
|
|
91
109
|
}
|
|
92
110
|
|
|
@@ -129,6 +147,17 @@ async function testEngine(engine) {
|
|
|
129
147
|
}
|
|
130
148
|
}
|
|
131
149
|
|
|
150
|
+
if (engine === 'qwen') {
|
|
151
|
+
try {
|
|
152
|
+
execSync('qwen --version', { stdio: 'inherit' });
|
|
153
|
+
console.log(chalk.green(` ✓ Qwen CLI is working`));
|
|
154
|
+
return true;
|
|
155
|
+
} catch {
|
|
156
|
+
console.log(chalk.red(` ✗ Qwen CLI not found`));
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
132
161
|
console.log(chalk.yellow(` Engine ${engine} not testable`));
|
|
133
162
|
return false;
|
|
134
163
|
}
|
|
@@ -149,10 +178,13 @@ async function addEngine() {
|
|
|
149
178
|
if (detected.gemini.available) {
|
|
150
179
|
choices.push({ name: `Gemini (${detected.gemini.version})`, value: 'gemini' });
|
|
151
180
|
}
|
|
181
|
+
if (detected.qwen.available) {
|
|
182
|
+
choices.push({ name: `QWEN (${detected.qwen.version})`, value: 'qwen' });
|
|
183
|
+
}
|
|
152
184
|
|
|
153
185
|
if (choices.length === 0) {
|
|
154
186
|
console.log(chalk.yellow(' No AI engines detected.'));
|
|
155
|
-
console.log(chalk.gray(' Install: claude, codex, or
|
|
187
|
+
console.log(chalk.gray(' Install: claude, codex, gemini, or qwen CLI'));
|
|
156
188
|
return;
|
|
157
189
|
}
|
|
158
190
|
|
|
@@ -206,6 +238,23 @@ async function addEngine() {
|
|
|
206
238
|
setConfigValue('engines.gemini.model', 'gemini-3-pro-preview');
|
|
207
239
|
console.log(chalk.green(` ✓ Gemini configured (gemini-3-pro-preview)`));
|
|
208
240
|
}
|
|
241
|
+
|
|
242
|
+
if (engine === 'qwen') {
|
|
243
|
+
const { model } = await inquirer.prompt([{
|
|
244
|
+
type: 'list',
|
|
245
|
+
name: 'model',
|
|
246
|
+
message: 'Default Qwen model:',
|
|
247
|
+
choices: [
|
|
248
|
+
{ name: 'Coder (default)', value: 'coder-model' },
|
|
249
|
+
{ name: 'Vision', value: 'vision-model' }
|
|
250
|
+
],
|
|
251
|
+
default: 'coder-model'
|
|
252
|
+
}]);
|
|
253
|
+
|
|
254
|
+
setConfigValue('engines.qwen.enabled', true);
|
|
255
|
+
setConfigValue('engines.qwen.model', model);
|
|
256
|
+
console.log(chalk.green(` ✓ QWEN configured (${model})`));
|
|
257
|
+
}
|
|
209
258
|
}
|
|
210
259
|
|
|
211
260
|
/**
|
package/lib/config/manager.js
CHANGED
package/lib/config/models.js
CHANGED
|
@@ -164,6 +164,34 @@ function getCliTools() {
|
|
|
164
164
|
}
|
|
165
165
|
]
|
|
166
166
|
}
|
|
167
|
+
,
|
|
168
|
+
|
|
169
|
+
// ============================================================
|
|
170
|
+
// QWEN - Qwen Code CLI (Termux fork)
|
|
171
|
+
// ============================================================
|
|
172
|
+
'qwen': {
|
|
173
|
+
name: 'QWEN',
|
|
174
|
+
icon: 'Cpu',
|
|
175
|
+
enabled: true,
|
|
176
|
+
endpoint: '/api/v1/qwen',
|
|
177
|
+
models: [
|
|
178
|
+
{
|
|
179
|
+
id: 'coder-model',
|
|
180
|
+
name: 'coder-model',
|
|
181
|
+
label: 'Qwen Coder',
|
|
182
|
+
description: '🔧 Default Qwen Coder model',
|
|
183
|
+
category: 'qwen',
|
|
184
|
+
default: true
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
id: 'vision-model',
|
|
188
|
+
name: 'vision-model',
|
|
189
|
+
label: 'Qwen Vision',
|
|
190
|
+
description: '👁️ Vision-capable model',
|
|
191
|
+
category: 'qwen'
|
|
192
|
+
}
|
|
193
|
+
]
|
|
194
|
+
}
|
|
167
195
|
};
|
|
168
196
|
}
|
|
169
197
|
|
|
@@ -10,7 +10,7 @@ const rateLimit = require('express-rate-limit');
|
|
|
10
10
|
/**
|
|
11
11
|
* Chat endpoints rate limiter
|
|
12
12
|
* - 10 requests per minute per user
|
|
13
|
-
* - Applies to: /api/v1/chat, /api/v1/codex, /api/v1/gemini
|
|
13
|
+
* - Applies to: /api/v1/chat, /api/v1/codex, /api/v1/gemini, /api/v1/qwen
|
|
14
14
|
*/
|
|
15
15
|
const chatRateLimiter = rateLimit({
|
|
16
16
|
windowMs: 60 * 1000, // 1 minute window
|
|
@@ -13,7 +13,7 @@ class Message {
|
|
|
13
13
|
* @param {string} content - Message content (markdown)
|
|
14
14
|
* @param {Object} metadata - Optional metadata
|
|
15
15
|
* @param {number} createdAt - Optional timestamp override
|
|
16
|
-
* @param {string} engine - Engine used ('claude' | 'codex')
|
|
16
|
+
* @param {string} engine - Engine used ('claude' | 'codex' | 'gemini' | 'qwen')
|
|
17
17
|
* @returns {Object} Created message
|
|
18
18
|
*/
|
|
19
19
|
static create(conversationId, role, content, metadata = null, createdAt = Date.now(), engine = 'claude') {
|
|
@@ -10,6 +10,7 @@ const { getCliTools } = require('../../config/models');
|
|
|
10
10
|
* - Claude: Opus 4.5, Sonnet 4.5, Haiku 4.5
|
|
11
11
|
* - Codex: GPT-5.1 variants
|
|
12
12
|
* - Gemini: Gemini 3 Pro Preview, Gemini 3 Flash Preview
|
|
13
|
+
* - Qwen: coder-model, vision-model
|
|
13
14
|
*/
|
|
14
15
|
router.get('/', (req, res) => {
|
|
15
16
|
try {
|
|
@@ -35,6 +36,7 @@ router.get('/:engine', (req, res) => {
|
|
|
35
36
|
if (normalizedEngine.includes('claude')) normalizedEngine = 'claude';
|
|
36
37
|
if (normalizedEngine.includes('codex') || normalizedEngine.includes('openai')) normalizedEngine = 'codex';
|
|
37
38
|
if (normalizedEngine.includes('gemini') || normalizedEngine.includes('google')) normalizedEngine = 'gemini';
|
|
39
|
+
if (normalizedEngine.includes('qwen')) normalizedEngine = 'qwen';
|
|
38
40
|
|
|
39
41
|
if (!cliTools[normalizedEngine]) {
|
|
40
42
|
return res.status(404).json({ error: `Engine not found: ${engine}` });
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Qwen Route - /api/v1/qwen
|
|
3
|
+
*
|
|
4
|
+
* Send messages to Qwen Code CLI with SSE streaming.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const express = require('express');
|
|
8
|
+
const QwenWrapper = require('../services/qwen-wrapper');
|
|
9
|
+
const Message = require('../models/Message');
|
|
10
|
+
const { v4: uuidv4 } = require('uuid');
|
|
11
|
+
const sessionManager = require('../services/session-manager');
|
|
12
|
+
const contextBridge = require('../services/context-bridge');
|
|
13
|
+
const { resolveWorkspacePath } = require('../../utils/workspace');
|
|
14
|
+
|
|
15
|
+
const router = express.Router();
|
|
16
|
+
const qwenWrapper = new QwenWrapper();
|
|
17
|
+
|
|
18
|
+
function ensureConversation(conversationId, workspacePath) {
|
|
19
|
+
try {
|
|
20
|
+
const stmt = require('../db').prepare(`
|
|
21
|
+
INSERT OR IGNORE INTO conversations (id, title, created_at, updated_at, metadata)
|
|
22
|
+
VALUES (?, ?, ?, ?, ?)
|
|
23
|
+
`);
|
|
24
|
+
const now = Date.now();
|
|
25
|
+
const metadata = workspacePath ? JSON.stringify({ workspace: workspacePath }) : null;
|
|
26
|
+
stmt.run(conversationId, 'New Chat', now, now, metadata);
|
|
27
|
+
} catch (err) {
|
|
28
|
+
console.warn('[Qwen] Failed to ensure conversation exists:', err.message);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* POST /api/v1/qwen
|
|
34
|
+
* Body:
|
|
35
|
+
* {
|
|
36
|
+
* conversationId?: string,
|
|
37
|
+
* message: string,
|
|
38
|
+
* model?: string,
|
|
39
|
+
* workspace?: string
|
|
40
|
+
* }
|
|
41
|
+
*/
|
|
42
|
+
router.post('/', async (req, res) => {
|
|
43
|
+
try {
|
|
44
|
+
console.log('[Qwen] === NEW QWEN REQUEST ===');
|
|
45
|
+
console.log('[Qwen] Body:', JSON.stringify(req.body, null, 2));
|
|
46
|
+
|
|
47
|
+
const { conversationId, message, model = 'coder-model', workspace } = req.body;
|
|
48
|
+
|
|
49
|
+
if (!message) {
|
|
50
|
+
return res.status(400).json({ error: 'message required' });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const isAvailable = await qwenWrapper.isAvailable();
|
|
54
|
+
if (!isAvailable) {
|
|
55
|
+
return res.status(503).json({
|
|
56
|
+
error: 'Qwen CLI not available',
|
|
57
|
+
details: 'Please install Qwen CLI: npm install -g @mmmbuto/qwen-code-termux'
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const workspacePath = resolveWorkspacePath(workspace, process.cwd());
|
|
62
|
+
if (workspace && workspacePath !== workspace) {
|
|
63
|
+
console.warn(`[Qwen] Workspace corrected: ${workspace} → ${workspacePath}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const frontendConversationId = conversationId || uuidv4();
|
|
67
|
+
ensureConversation(frontendConversationId, workspacePath);
|
|
68
|
+
|
|
69
|
+
const { sessionId, isNew: isNewSession } = await sessionManager.getOrCreateSession(
|
|
70
|
+
frontendConversationId,
|
|
71
|
+
'qwen',
|
|
72
|
+
workspacePath
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const nativeSessionId = isNewSession ? null : sessionManager.getNativeThreadId(sessionId);
|
|
76
|
+
|
|
77
|
+
// SSE headers
|
|
78
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
79
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
80
|
+
res.setHeader('Connection', 'keep-alive');
|
|
81
|
+
res.setHeader('X-Accel-Buffering', 'no');
|
|
82
|
+
|
|
83
|
+
res.write(`data: ${JSON.stringify({
|
|
84
|
+
type: 'message_start',
|
|
85
|
+
messageId: `user-${Date.now()}`,
|
|
86
|
+
sessionId,
|
|
87
|
+
conversationId: frontendConversationId,
|
|
88
|
+
engine: 'qwen'
|
|
89
|
+
})}\n\n`);
|
|
90
|
+
|
|
91
|
+
const lastEngine = Message.getLastEngine(frontendConversationId);
|
|
92
|
+
const isEngineBridge = lastEngine && lastEngine !== 'qwen';
|
|
93
|
+
|
|
94
|
+
let promptToSend = message;
|
|
95
|
+
|
|
96
|
+
if (isEngineBridge) {
|
|
97
|
+
const contextResult = await contextBridge.buildContext({
|
|
98
|
+
conversationId: frontendConversationId,
|
|
99
|
+
sessionId,
|
|
100
|
+
fromEngine: lastEngine,
|
|
101
|
+
toEngine: 'qwen',
|
|
102
|
+
userMessage: message
|
|
103
|
+
});
|
|
104
|
+
promptToSend = contextResult.prompt;
|
|
105
|
+
|
|
106
|
+
res.write(`data: ${JSON.stringify({
|
|
107
|
+
type: 'status',
|
|
108
|
+
category: 'system',
|
|
109
|
+
message: `Context bridged from ${lastEngine}`,
|
|
110
|
+
icon: '🔄'
|
|
111
|
+
})}\n\n`);
|
|
112
|
+
} else if (nativeSessionId) {
|
|
113
|
+
console.log(`[Qwen] Native resume: qwen --resume ${nativeSessionId}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Save user message
|
|
117
|
+
try {
|
|
118
|
+
Message.create(
|
|
119
|
+
frontendConversationId,
|
|
120
|
+
'user',
|
|
121
|
+
message,
|
|
122
|
+
{ workspace: workspacePath, model },
|
|
123
|
+
Date.now(),
|
|
124
|
+
'qwen'
|
|
125
|
+
);
|
|
126
|
+
sessionManager.bumpSessionActivity(sessionId, 1);
|
|
127
|
+
} catch (dbErr) {
|
|
128
|
+
console.warn('[Qwen] Failed to save user message:', dbErr.message);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (isNewSession) {
|
|
132
|
+
const title = sessionManager.extractTitle(message);
|
|
133
|
+
sessionManager.updateSessionTitle(sessionId, title);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const result = await qwenWrapper.sendMessage({
|
|
137
|
+
prompt: promptToSend,
|
|
138
|
+
threadId: nativeSessionId,
|
|
139
|
+
model,
|
|
140
|
+
workspacePath,
|
|
141
|
+
processId: sessionId,
|
|
142
|
+
onStatus: (event) => {
|
|
143
|
+
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
if (result.sessionId) {
|
|
148
|
+
sessionManager.setNativeThreadId(sessionId, result.sessionId);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
Message.create(
|
|
153
|
+
frontendConversationId,
|
|
154
|
+
'assistant',
|
|
155
|
+
result.text,
|
|
156
|
+
{ model, usage: result.usage },
|
|
157
|
+
Date.now(),
|
|
158
|
+
'qwen'
|
|
159
|
+
);
|
|
160
|
+
sessionManager.bumpSessionActivity(sessionId, 1);
|
|
161
|
+
} catch (dbErr) {
|
|
162
|
+
console.warn('[Qwen] Failed to save assistant message:', dbErr.message);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (contextBridge.shouldTriggerSummary(frontendConversationId, isEngineBridge)) {
|
|
166
|
+
contextBridge.triggerSummaryGeneration(frontendConversationId, '[Qwen]');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
res.write(`data: ${JSON.stringify({
|
|
170
|
+
type: 'message_done',
|
|
171
|
+
content: result.text,
|
|
172
|
+
usage: result.usage,
|
|
173
|
+
sessionId,
|
|
174
|
+
conversationId: frontendConversationId,
|
|
175
|
+
engine: 'qwen',
|
|
176
|
+
model
|
|
177
|
+
})}\n\n`);
|
|
178
|
+
|
|
179
|
+
res.end();
|
|
180
|
+
console.log('[Qwen] === REQUEST COMPLETE ===');
|
|
181
|
+
} catch (error) {
|
|
182
|
+
console.error('[Qwen] Error:', error);
|
|
183
|
+
if (!res.headersSent) {
|
|
184
|
+
res.status(500).json({ error: error.message });
|
|
185
|
+
} else {
|
|
186
|
+
res.write(`data: ${JSON.stringify({
|
|
187
|
+
type: 'error',
|
|
188
|
+
error: error.message
|
|
189
|
+
})}\n\n`);
|
|
190
|
+
res.end();
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* GET /api/v1/qwen/status
|
|
197
|
+
*/
|
|
198
|
+
router.get('/status', async (_req, res) => {
|
|
199
|
+
try {
|
|
200
|
+
const isAvailable = await qwenWrapper.isAvailable();
|
|
201
|
+
res.json({
|
|
202
|
+
available: isAvailable,
|
|
203
|
+
defaultModel: qwenWrapper.getDefaultModel(),
|
|
204
|
+
models: qwenWrapper.getAvailableModels()
|
|
205
|
+
});
|
|
206
|
+
} catch (error) {
|
|
207
|
+
console.error('[Qwen] Status check error:', error);
|
|
208
|
+
res.status(500).json({ error: error.message });
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* GET /api/v1/qwen/models
|
|
214
|
+
*/
|
|
215
|
+
router.get('/models', (_req, res) => {
|
|
216
|
+
res.json({
|
|
217
|
+
models: qwenWrapper.getAvailableModels(),
|
|
218
|
+
default: qwenWrapper.getDefaultModel()
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* POST /api/v1/qwen/interrupt
|
|
224
|
+
*/
|
|
225
|
+
router.post('/interrupt', async (req, res) => {
|
|
226
|
+
try {
|
|
227
|
+
const { sessionId } = req.body;
|
|
228
|
+
if (!sessionId) {
|
|
229
|
+
return res.status(400).json({ error: 'sessionId required' });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const result = qwenWrapper.interrupt(sessionId);
|
|
233
|
+
res.json(result);
|
|
234
|
+
} catch (error) {
|
|
235
|
+
console.error('[Qwen] Interrupt error:', error);
|
|
236
|
+
res.status(500).json({ error: error.message });
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
module.exports = router;
|
|
@@ -15,11 +15,12 @@ const SESSION_DIRS = {
|
|
|
15
15
|
claude: path.join(process.env.HOME || '', '.claude', 'projects'),
|
|
16
16
|
codex: path.join(process.env.HOME || '', '.codex', 'sessions'),
|
|
17
17
|
gemini: path.join(process.env.HOME || '', '.gemini', 'sessions'),
|
|
18
|
+
qwen: path.join(process.env.HOME || '', '.qwen', 'projects'),
|
|
18
19
|
};
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* POST /api/v1/sessions/import
|
|
22
|
-
* Importa tutte le sessioni native (Claude/Codex/Gemini) nel DB
|
|
23
|
+
* Importa tutte le sessioni native (Claude/Codex/Gemini/Qwen) nel DB
|
|
23
24
|
*/
|
|
24
25
|
router.post('/import', async (_req, res) => {
|
|
25
26
|
try {
|
|
@@ -280,6 +281,11 @@ function pathToSlug(workspacePath) {
|
|
|
280
281
|
return workspacePath.replace(/[\/\.]/g, '-');
|
|
281
282
|
}
|
|
282
283
|
|
|
284
|
+
function qwenProjectDir(workspacePath) {
|
|
285
|
+
if (!workspacePath) return 'default';
|
|
286
|
+
return workspacePath.replace(/[^a-zA-Z0-9]/g, '-');
|
|
287
|
+
}
|
|
288
|
+
|
|
283
289
|
/**
|
|
284
290
|
* Helper: Get the filesystem path for a session file
|
|
285
291
|
*/
|
|
@@ -287,6 +293,7 @@ function getSessionFilePath(sessionId, engine, workspacePath, sessionPath) {
|
|
|
287
293
|
const normalizedEngine = engine?.toLowerCase().includes('claude') ? 'claude'
|
|
288
294
|
: engine?.toLowerCase().includes('codex') ? 'codex'
|
|
289
295
|
: engine?.toLowerCase().includes('gemini') ? 'gemini'
|
|
296
|
+
: engine?.toLowerCase().includes('qwen') ? 'qwen'
|
|
290
297
|
: 'claude';
|
|
291
298
|
|
|
292
299
|
switch (normalizedEngine) {
|
|
@@ -303,6 +310,11 @@ function getSessionFilePath(sessionId, engine, workspacePath, sessionPath) {
|
|
|
303
310
|
return findCodexSessionFile(baseDir, nativeId);
|
|
304
311
|
case 'gemini':
|
|
305
312
|
return path.join(SESSION_DIRS.gemini, `${sessionId}.jsonl`);
|
|
313
|
+
case 'qwen': {
|
|
314
|
+
const project = qwenProjectDir(workspacePath);
|
|
315
|
+
const fileId = sessionPath || sessionId;
|
|
316
|
+
return path.join(SESSION_DIRS.qwen, project, 'chats', `${fileId}.jsonl`);
|
|
317
|
+
}
|
|
306
318
|
default:
|
|
307
319
|
return null;
|
|
308
320
|
}
|
package/lib/server/server.js
CHANGED
|
@@ -22,6 +22,7 @@ const jobsRouter = require('./routes/jobs');
|
|
|
22
22
|
const chatRouter = require('./routes/chat');
|
|
23
23
|
const codexRouter = require('./routes/codex');
|
|
24
24
|
const geminiRouter = require('./routes/gemini');
|
|
25
|
+
const qwenRouter = require('./routes/qwen');
|
|
25
26
|
const modelsRouter = require('./routes/models');
|
|
26
27
|
const workspaceRouter = require('./routes/workspace');
|
|
27
28
|
const workspacesRouter = require('./routes/workspaces');
|
|
@@ -51,7 +52,7 @@ app.get('/health', (req, res) => {
|
|
|
51
52
|
status: 'ok',
|
|
52
53
|
service: 'nexuscli-backend',
|
|
53
54
|
version: pkg.version,
|
|
54
|
-
engines: ['claude', 'codex', 'gemini'],
|
|
55
|
+
engines: ['claude', 'codex', 'gemini', 'qwen'],
|
|
55
56
|
port: PORT,
|
|
56
57
|
timestamp: new Date().toISOString()
|
|
57
58
|
});
|
|
@@ -78,6 +79,7 @@ app.use('/api/v1/jobs', authMiddleware, jobsRouter);
|
|
|
78
79
|
app.use('/api/v1/chat', authMiddleware, chatRateLimiter, chatRouter);
|
|
79
80
|
app.use('/api/v1/codex', authMiddleware, chatRateLimiter, codexRouter);
|
|
80
81
|
app.use('/api/v1/gemini', authMiddleware, chatRateLimiter, geminiRouter);
|
|
82
|
+
app.use('/api/v1/qwen', authMiddleware, chatRateLimiter, qwenRouter);
|
|
81
83
|
app.use('/api/v1/upload', authMiddleware, uploadRouter); // File upload
|
|
82
84
|
|
|
83
85
|
// STT routes
|
|
@@ -89,13 +91,14 @@ app.get('/', (req, res) => {
|
|
|
89
91
|
res.json({
|
|
90
92
|
service: 'NexusCLI Backend',
|
|
91
93
|
version: pkg.version,
|
|
92
|
-
engines: ['claude', 'codex', 'gemini'],
|
|
94
|
+
engines: ['claude', 'codex', 'gemini', 'qwen'],
|
|
93
95
|
endpoints: {
|
|
94
96
|
health: '/health',
|
|
95
97
|
models: '/api/v1/models',
|
|
96
98
|
chat: '/api/v1/chat (Claude)',
|
|
97
99
|
codex: '/api/v1/codex (OpenAI)',
|
|
98
100
|
gemini: '/api/v1/gemini (Google)',
|
|
101
|
+
qwen: '/api/v1/qwen (Qwen)',
|
|
99
102
|
conversations: '/api/v1/conversations',
|
|
100
103
|
jobs: '/api/v1/jobs'
|
|
101
104
|
}
|
|
@@ -154,10 +157,10 @@ async function start() {
|
|
|
154
157
|
// Continue anyway - database will sync on first workspace mount
|
|
155
158
|
}
|
|
156
159
|
|
|
157
|
-
// Import native sessions from all engines (Claude/Codex/Gemini)
|
|
160
|
+
// Import native sessions from all engines (Claude/Codex/Gemini/Qwen)
|
|
158
161
|
try {
|
|
159
162
|
const imported = sessionImporter.importAll();
|
|
160
|
-
console.log(`[Startup] Imported sessions → Claude:${imported.claude} Codex:${imported.codex} Gemini:${imported.gemini}`);
|
|
163
|
+
console.log(`[Startup] Imported sessions → Claude:${imported.claude} Codex:${imported.codex} Gemini:${imported.gemini} Qwen:${imported.qwen}`);
|
|
161
164
|
} catch (error) {
|
|
162
165
|
console.error('[Startup] ⚠️ Session import failed:', error.message);
|
|
163
166
|
}
|