@mmmbuto/nexuscli 0.9.7-termux → 0.9.8
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 +33 -35
- package/frontend/dist/assets/index-CoLEGBO4.css +1 -0
- package/frontend/dist/assets/{index-CvOYN8ds.js → index-D8XkscmI.js} +1703 -1703
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/sw.js +1 -1
- package/lib/cli/api.js +1 -1
- package/lib/config/models.js +4 -4
- package/lib/server/db/adapter.js +1 -0
- package/lib/server/db/migrations/005_message_engine_not_null.sql +45 -0
- package/lib/server/routes/qwen.js +25 -4
- package/lib/server/services/claude-wrapper.js +6 -6
- package/lib/server/services/cli-loader.js +1 -1
- package/lib/server/services/qwen-output-parser.js +30 -0
- package/lib/server/services/qwen-wrapper.js +17 -2
- package/lib/server/services/session-importer.js +6 -1
- package/lib/server/services/workspace-manager.js +121 -0
- package/lib/utils/attachments.js +100 -0
- package/package.json +1 -1
- package/frontend/dist/assets/index-BAfxoAUN.css +0 -1
package/frontend/dist/index.html
CHANGED
|
@@ -59,8 +59,8 @@
|
|
|
59
59
|
|
|
60
60
|
<!-- Prevent Scaling on iOS -->
|
|
61
61
|
<meta name="format-detection" content="telephone=no" />
|
|
62
|
-
<script type="module" crossorigin src="/assets/index-
|
|
63
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
62
|
+
<script type="module" crossorigin src="/assets/index-D8XkscmI.js"></script>
|
|
63
|
+
<link rel="stylesheet" crossorigin href="/assets/index-CoLEGBO4.css">
|
|
64
64
|
</head>
|
|
65
65
|
<body>
|
|
66
66
|
<div id="root"></div>
|
package/frontend/dist/sw.js
CHANGED
package/lib/cli/api.js
CHANGED
package/lib/config/models.js
CHANGED
|
@@ -60,11 +60,11 @@ function getCliTools() {
|
|
|
60
60
|
description: '💬 Fast Chat',
|
|
61
61
|
category: 'claude'
|
|
62
62
|
},
|
|
63
|
-
// === GLM-4.
|
|
63
|
+
// === GLM-4.7 (Z.ai) ===
|
|
64
64
|
{
|
|
65
|
-
id: 'glm-4-
|
|
66
|
-
name: 'glm-4-
|
|
67
|
-
label: 'GLM 4.
|
|
65
|
+
id: 'glm-4-7',
|
|
66
|
+
name: 'glm-4-7',
|
|
67
|
+
label: 'GLM 4.7',
|
|
68
68
|
description: '🌍 Advanced Chinese/English Multilingual',
|
|
69
69
|
category: 'claude'
|
|
70
70
|
}
|
package/lib/server/db/adapter.js
CHANGED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
-- ============================================================
|
|
2
|
+
-- MIGRATION 005: Fix engine column to NOT NULL
|
|
3
|
+
-- Ensures all messages have a valid engine field for proper avatar display
|
|
4
|
+
-- ============================================================
|
|
5
|
+
|
|
6
|
+
-- Step 1: Backfill any NULL values to 'claude' (safe default)
|
|
7
|
+
UPDATE messages SET engine = 'claude' WHERE engine IS NULL OR engine = '';
|
|
8
|
+
|
|
9
|
+
-- Step 2: Recreate messages table with NOT NULL constraint
|
|
10
|
+
-- SQLite doesn't support ALTER COLUMN directly, so we recreate
|
|
11
|
+
|
|
12
|
+
-- Create new table with proper schema
|
|
13
|
+
CREATE TABLE IF NOT EXISTS messages_new (
|
|
14
|
+
id TEXT PRIMARY KEY,
|
|
15
|
+
conversation_id TEXT NOT NULL,
|
|
16
|
+
role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system')),
|
|
17
|
+
content TEXT NOT NULL,
|
|
18
|
+
created_at INTEGER NOT NULL,
|
|
19
|
+
metadata TEXT,
|
|
20
|
+
engine TEXT NOT NULL DEFAULT 'claude',
|
|
21
|
+
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
-- Copy all data from old table to new table
|
|
25
|
+
INSERT INTO messages_new
|
|
26
|
+
SELECT id, conversation_id, role, content, created_at, metadata,
|
|
27
|
+
COALESCE(engine, 'claude') AS engine
|
|
28
|
+
FROM messages;
|
|
29
|
+
|
|
30
|
+
-- Drop old table
|
|
31
|
+
DROP TABLE messages;
|
|
32
|
+
|
|
33
|
+
-- Rename new table to original name
|
|
34
|
+
ALTER TABLE messages_new RENAME TO messages;
|
|
35
|
+
|
|
36
|
+
-- Step 3: Recreate all indexes
|
|
37
|
+
CREATE INDEX IF NOT EXISTS idx_messages_conversation_id ON messages(conversation_id);
|
|
38
|
+
CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at ASC);
|
|
39
|
+
CREATE INDEX IF NOT EXISTS idx_messages_engine ON messages(engine);
|
|
40
|
+
CREATE INDEX IF NOT EXISTS idx_messages_conversation_engine ON messages(conversation_id, engine);
|
|
41
|
+
|
|
42
|
+
-- Verification query ( informational only )
|
|
43
|
+
SELECT 'Messages with NULL engine (should be 0):' as check_name,
|
|
44
|
+
COUNT(*) as count
|
|
45
|
+
FROM messages WHERE engine IS NULL;
|
|
@@ -11,6 +11,7 @@ const { v4: uuidv4 } = require('uuid');
|
|
|
11
11
|
const sessionManager = require('../services/session-manager');
|
|
12
12
|
const contextBridge = require('../services/context-bridge');
|
|
13
13
|
const { resolveWorkspacePath } = require('../../utils/workspace');
|
|
14
|
+
const { normalizeAttachments } = require('../../utils/attachments');
|
|
14
15
|
|
|
15
16
|
const router = express.Router();
|
|
16
17
|
const qwenWrapper = new QwenWrapper();
|
|
@@ -40,11 +41,12 @@ function ensureConversation(conversationId, workspacePath) {
|
|
|
40
41
|
* }
|
|
41
42
|
*/
|
|
42
43
|
router.post('/', async (req, res) => {
|
|
44
|
+
const fallbackModel = req.body?.model || 'coder-model';
|
|
43
45
|
try {
|
|
44
46
|
console.log('[Qwen] === NEW QWEN REQUEST ===');
|
|
45
47
|
console.log('[Qwen] Body:', JSON.stringify(req.body, null, 2));
|
|
46
48
|
|
|
47
|
-
const { conversationId, message, model = 'coder-model', workspace } = req.body;
|
|
49
|
+
const { conversationId, message, model = 'coder-model', workspace, attachments } = req.body;
|
|
48
50
|
|
|
49
51
|
if (!message) {
|
|
50
52
|
return res.status(400).json({ error: 'message required' });
|
|
@@ -63,6 +65,13 @@ router.post('/', async (req, res) => {
|
|
|
63
65
|
console.warn(`[Qwen] Workspace corrected: ${workspace} → ${workspacePath}`);
|
|
64
66
|
}
|
|
65
67
|
|
|
68
|
+
const attachmentInfo = normalizeAttachments({
|
|
69
|
+
message,
|
|
70
|
+
attachments,
|
|
71
|
+
workspacePath
|
|
72
|
+
});
|
|
73
|
+
const userMessage = attachmentInfo.promptMessage || message;
|
|
74
|
+
|
|
66
75
|
const frontendConversationId = conversationId || uuidv4();
|
|
67
76
|
ensureConversation(frontendConversationId, workspacePath);
|
|
68
77
|
|
|
@@ -91,7 +100,7 @@ router.post('/', async (req, res) => {
|
|
|
91
100
|
const lastEngine = Message.getLastEngine(frontendConversationId);
|
|
92
101
|
const isEngineBridge = lastEngine && lastEngine !== 'qwen';
|
|
93
102
|
|
|
94
|
-
let promptToSend =
|
|
103
|
+
let promptToSend = userMessage;
|
|
95
104
|
|
|
96
105
|
if (isEngineBridge) {
|
|
97
106
|
const contextResult = await contextBridge.buildContext({
|
|
@@ -99,7 +108,7 @@ router.post('/', async (req, res) => {
|
|
|
99
108
|
sessionId,
|
|
100
109
|
fromEngine: lastEngine,
|
|
101
110
|
toEngine: 'qwen',
|
|
102
|
-
userMessage:
|
|
111
|
+
userMessage: userMessage
|
|
103
112
|
});
|
|
104
113
|
promptToSend = contextResult.prompt;
|
|
105
114
|
|
|
@@ -113,6 +122,15 @@ router.post('/', async (req, res) => {
|
|
|
113
122
|
console.log(`[Qwen] Native resume: qwen --resume ${nativeSessionId}`);
|
|
114
123
|
}
|
|
115
124
|
|
|
125
|
+
const attachmentRefs = attachmentInfo.attachmentPaths.map((filePath) => {
|
|
126
|
+
const escapedPath = filePath.replace(/([\\\s,;!?()[\]{}])/g, '\\$1');
|
|
127
|
+
return `@${escapedPath}`;
|
|
128
|
+
});
|
|
129
|
+
if (attachmentRefs.length > 0) {
|
|
130
|
+
promptToSend = `${attachmentRefs.join('\n')}\n\n${promptToSend}`;
|
|
131
|
+
console.log(`[Qwen] Attached ${attachmentRefs.length} file(s) to prompt`);
|
|
132
|
+
}
|
|
133
|
+
|
|
116
134
|
// Save user message
|
|
117
135
|
try {
|
|
118
136
|
Message.create(
|
|
@@ -138,6 +156,7 @@ router.post('/', async (req, res) => {
|
|
|
138
156
|
threadId: nativeSessionId,
|
|
139
157
|
model,
|
|
140
158
|
workspacePath,
|
|
159
|
+
includeDirectories: attachmentInfo.includeDirectories,
|
|
141
160
|
processId: sessionId,
|
|
142
161
|
onStatus: (event) => {
|
|
143
162
|
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
@@ -185,7 +204,9 @@ router.post('/', async (req, res) => {
|
|
|
185
204
|
} else {
|
|
186
205
|
res.write(`data: ${JSON.stringify({
|
|
187
206
|
type: 'error',
|
|
188
|
-
error: error.message
|
|
207
|
+
error: error.message,
|
|
208
|
+
engine: 'qwen',
|
|
209
|
+
model: fallbackModel
|
|
189
210
|
})}\n\n`);
|
|
190
211
|
res.end();
|
|
191
212
|
}
|
|
@@ -165,7 +165,7 @@ class ClaudeWrapper extends BaseCliWrapper {
|
|
|
165
165
|
|
|
166
166
|
// Detect alternative models early (needed for args construction)
|
|
167
167
|
const isDeepSeek = model.startsWith('deepseek-');
|
|
168
|
-
const isGLM = model === 'glm-4-
|
|
168
|
+
const isGLM = model === 'glm-4-7';
|
|
169
169
|
const isAlternativeModel = isDeepSeek || isGLM;
|
|
170
170
|
|
|
171
171
|
// Build Claude Code CLI args
|
|
@@ -233,7 +233,7 @@ class ClaudeWrapper extends BaseCliWrapper {
|
|
|
233
233
|
const glmKey = getApiKey('zai') || process.env.ZAI_API_KEY;
|
|
234
234
|
|
|
235
235
|
if (!glmKey) {
|
|
236
|
-
const errorMsg = `Z.ai API key not configured for GLM-4.
|
|
236
|
+
const errorMsg = `Z.ai API key not configured for GLM-4.7!\n\n` +
|
|
237
237
|
`Run this command to add your API key:\n` +
|
|
238
238
|
` nexuscli api set zai YOUR_API_KEY\n\n` +
|
|
239
239
|
`Get your key at: https://z.ai`;
|
|
@@ -251,12 +251,12 @@ class ClaudeWrapper extends BaseCliWrapper {
|
|
|
251
251
|
return reject(new Error(errorMsg));
|
|
252
252
|
}
|
|
253
253
|
|
|
254
|
-
// GLM-4.
|
|
254
|
+
// GLM-4.7 uses Z.ai Anthropic-compatible API
|
|
255
255
|
spawnEnv.ANTHROPIC_BASE_URL = 'https://api.z.ai/api/anthropic';
|
|
256
256
|
spawnEnv.ANTHROPIC_AUTH_TOKEN = glmKey;
|
|
257
|
-
spawnEnv.ANTHROPIC_MODEL = 'GLM-4.
|
|
257
|
+
spawnEnv.ANTHROPIC_MODEL = 'GLM-4.7'; // Z.ai model name
|
|
258
258
|
spawnEnv.API_TIMEOUT_MS = '3000000'; // 50 minutes timeout
|
|
259
|
-
console.log(`[ClaudeWrapper] GLM-4.
|
|
259
|
+
console.log(`[ClaudeWrapper] GLM-4.7 detected - using Z.ai API with extended timeout`);
|
|
260
260
|
}
|
|
261
261
|
|
|
262
262
|
console.log(`[ClaudeWrapper] Model: ${model}${isDeepSeek ? ' (DeepSeek API)' : isGLM ? ' (Z.ai API)' : ''}`);
|
|
@@ -360,7 +360,7 @@ class ClaudeWrapper extends BaseCliWrapper {
|
|
|
360
360
|
let timeoutMs = 600000; // 10 minutes default
|
|
361
361
|
let timeoutLabel = '10 minutes';
|
|
362
362
|
if (isGLM) {
|
|
363
|
-
timeoutMs = 3600000; // 60 minutes for GLM-4.
|
|
363
|
+
timeoutMs = 3600000; // 60 minutes for GLM-4.7 (slow responses)
|
|
364
364
|
timeoutLabel = '60 minutes';
|
|
365
365
|
} else if (isDeepSeek) {
|
|
366
366
|
timeoutMs = 900000; // 15 minutes for DeepSeek
|
|
@@ -79,6 +79,27 @@ class QwenOutputParser {
|
|
|
79
79
|
break;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
case 'tool_use': {
|
|
83
|
+
events.push(this._formatToolUseEvent(event));
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
case 'tool_result': {
|
|
88
|
+
const toolName = event.tool_name || event.tool || event.name || event.function?.name || 'Tool';
|
|
89
|
+
const success =
|
|
90
|
+
event.status ? event.status !== 'error' && event.status !== 'failure' : !event.is_error;
|
|
91
|
+
|
|
92
|
+
events.push({
|
|
93
|
+
type: 'status',
|
|
94
|
+
category: 'tool',
|
|
95
|
+
message: `${toolName}: ${success ? 'completed' : 'failed'}`,
|
|
96
|
+
icon: success ? '✅' : '❌',
|
|
97
|
+
toolOutput: this._truncateOutput(event.output || event.result || event.content),
|
|
98
|
+
timestamp: event.timestamp || new Date().toISOString(),
|
|
99
|
+
});
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
|
|
82
103
|
case 'assistant': {
|
|
83
104
|
const contentBlocks = event.message?.content;
|
|
84
105
|
const text = this._extractText(contentBlocks);
|
|
@@ -122,6 +143,9 @@ class QwenOutputParser {
|
|
|
122
143
|
if (stream.type === 'content_block_start' && stream.content_block?.type === 'tool_use') {
|
|
123
144
|
events.push(this._formatToolUseEvent(stream.content_block));
|
|
124
145
|
}
|
|
146
|
+
if (stream.type === 'message_start' && stream.message?.model) {
|
|
147
|
+
this.model = stream.message.model;
|
|
148
|
+
}
|
|
125
149
|
break;
|
|
126
150
|
}
|
|
127
151
|
|
|
@@ -158,6 +182,12 @@ class QwenOutputParser {
|
|
|
158
182
|
break;
|
|
159
183
|
}
|
|
160
184
|
|
|
185
|
+
case 'error': {
|
|
186
|
+
const message = event.message || event.error || 'Unknown error';
|
|
187
|
+
events.push({ type: 'error', message });
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
|
|
161
191
|
default:
|
|
162
192
|
// Ignore other event types
|
|
163
193
|
break;
|
|
@@ -83,6 +83,7 @@ class QwenWrapper extends BaseCliWrapper {
|
|
|
83
83
|
threadId,
|
|
84
84
|
model = DEFAULT_MODEL,
|
|
85
85
|
workspacePath,
|
|
86
|
+
includeDirectories = [],
|
|
86
87
|
onStatus,
|
|
87
88
|
processId: processIdOverride
|
|
88
89
|
}) {
|
|
@@ -91,24 +92,36 @@ class QwenWrapper extends BaseCliWrapper {
|
|
|
91
92
|
let promiseSettled = false;
|
|
92
93
|
|
|
93
94
|
const cwd = workspacePath || this.workspaceDir;
|
|
95
|
+
const resolvedModel = model || DEFAULT_MODEL;
|
|
94
96
|
|
|
95
97
|
const args = [
|
|
96
98
|
'-y',
|
|
97
|
-
'-m',
|
|
99
|
+
'-m', resolvedModel,
|
|
98
100
|
'-o', 'stream-json',
|
|
99
101
|
'--include-partial-messages',
|
|
100
102
|
];
|
|
101
103
|
|
|
104
|
+
if (includeDirectories && includeDirectories.length > 0) {
|
|
105
|
+
includeDirectories.forEach((dir) => {
|
|
106
|
+
if (dir) {
|
|
107
|
+
args.push('--include-directories', dir);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
102
112
|
if (threadId) {
|
|
103
113
|
args.push('--resume', threadId);
|
|
104
114
|
}
|
|
105
115
|
|
|
106
116
|
args.push(prompt);
|
|
107
117
|
|
|
108
|
-
console.log(`[QwenWrapper] Model: ${
|
|
118
|
+
console.log(`[QwenWrapper] Model: ${resolvedModel}`);
|
|
109
119
|
console.log(`[QwenWrapper] ThreadId: ${threadId || '(new session)'}`);
|
|
110
120
|
console.log(`[QwenWrapper] CWD: ${cwd}`);
|
|
111
121
|
console.log(`[QwenWrapper] Prompt length: ${prompt.length}`);
|
|
122
|
+
if (includeDirectories && includeDirectories.length > 0) {
|
|
123
|
+
console.log(`[QwenWrapper] Include dirs: ${includeDirectories.join(', ')}`);
|
|
124
|
+
}
|
|
112
125
|
|
|
113
126
|
let ptyProcess;
|
|
114
127
|
try {
|
|
@@ -119,6 +132,8 @@ class QwenWrapper extends BaseCliWrapper {
|
|
|
119
132
|
cwd,
|
|
120
133
|
env: {
|
|
121
134
|
...process.env,
|
|
135
|
+
QWEN_CODE_MODEL: resolvedModel,
|
|
136
|
+
QWEN_MODEL: resolvedModel,
|
|
122
137
|
TERM: 'xterm-256color',
|
|
123
138
|
}
|
|
124
139
|
});
|
|
@@ -161,14 +161,19 @@ class SessionImporter {
|
|
|
161
161
|
|
|
162
162
|
/**
|
|
163
163
|
* Controlla se la sessione esiste già
|
|
164
|
+
* Returns false if sessions table doesn't exist (allows import)
|
|
164
165
|
*/
|
|
165
166
|
sessionExists(sessionId) {
|
|
166
167
|
try {
|
|
167
168
|
const stmt = prepare('SELECT 1 FROM sessions WHERE id = ?');
|
|
168
169
|
return !!stmt.get(sessionId);
|
|
169
170
|
} catch (err) {
|
|
171
|
+
// If sessions table doesn't exist, return false to allow import
|
|
172
|
+
if (err.message && err.message.includes('no such table')) {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
170
175
|
console.warn(`[SessionImporter] exists check failed: ${err.message}`);
|
|
171
|
-
return true; // default to skip to avoid duplicates
|
|
176
|
+
return true; // default to skip to avoid duplicates on other errors
|
|
172
177
|
}
|
|
173
178
|
}
|
|
174
179
|
|
|
@@ -11,6 +11,7 @@ class WorkspaceManager {
|
|
|
11
11
|
this.claudePath = path.join(process.env.HOME, '.claude');
|
|
12
12
|
this.historyPath = path.join(this.claudePath, 'history.jsonl');
|
|
13
13
|
this.projectsPath = path.join(this.claudePath, 'projects');
|
|
14
|
+
this.qwenPath = path.join(process.env.HOME, '.qwen');
|
|
14
15
|
this.cacheTtlMs = 5 * 60 * 1000; // 5 minutes
|
|
15
16
|
this.historyCache = {
|
|
16
17
|
entries: null,
|
|
@@ -38,6 +39,17 @@ class WorkspaceManager {
|
|
|
38
39
|
return '/' + slug.replace(/^-/, '').replace(/-/g, '/');
|
|
39
40
|
}
|
|
40
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Convert workspace path to Qwen project directory name
|
|
44
|
+
* Matches Qwen Storage.sanitizeCwd behavior
|
|
45
|
+
* @param {string} workspacePath - Absolute path
|
|
46
|
+
* @returns {string} Qwen project directory name
|
|
47
|
+
*/
|
|
48
|
+
qwenPathToProject(workspacePath) {
|
|
49
|
+
if (!workspacePath) return 'default';
|
|
50
|
+
return workspacePath.replace(/[^a-zA-Z0-9]/g, '-');
|
|
51
|
+
}
|
|
52
|
+
|
|
41
53
|
/**
|
|
42
54
|
* Discover all workspaces from .claude/projects/
|
|
43
55
|
* Reads real workspace path from session file 'cwd' field
|
|
@@ -169,6 +181,10 @@ class WorkspaceManager {
|
|
|
169
181
|
const claudeSessions = await this.indexClaudeCodeSessions(workspacePath);
|
|
170
182
|
sessions.push(...claudeSessions);
|
|
171
183
|
|
|
184
|
+
// Index Qwen sessions
|
|
185
|
+
const qwenSessions = await this.indexQwenSessions(workspacePath);
|
|
186
|
+
sessions.push(...qwenSessions);
|
|
187
|
+
|
|
172
188
|
// TODO: Index other CLI tools (Codex, Aider, etc.)
|
|
173
189
|
|
|
174
190
|
// Sync sessions to database (batch mode to avoid "Statement closed" errors)
|
|
@@ -332,6 +348,111 @@ class WorkspaceManager {
|
|
|
332
348
|
return sessions;
|
|
333
349
|
}
|
|
334
350
|
|
|
351
|
+
/**
|
|
352
|
+
* Index Qwen sessions from .qwen/projects/<workspace-sanitized>/chats/
|
|
353
|
+
* @param {string} workspacePath
|
|
354
|
+
* @returns {Promise<Array>}
|
|
355
|
+
*/
|
|
356
|
+
async indexQwenSessions(workspacePath) {
|
|
357
|
+
const project = this.qwenPathToProject(workspacePath);
|
|
358
|
+
const projectDir = path.join(this.qwenPath, 'projects', project);
|
|
359
|
+
|
|
360
|
+
if (!fs.existsSync(projectDir)) {
|
|
361
|
+
// Silently skip - Qwen may not be used for this workspace
|
|
362
|
+
return [];
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const chatsDir = path.join(projectDir, 'chats');
|
|
366
|
+
if (!fs.existsSync(chatsDir)) {
|
|
367
|
+
return [];
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// List session files
|
|
371
|
+
const sessionFiles = fs.readdirSync(chatsDir)
|
|
372
|
+
.filter(f => f.endsWith('.jsonl'));
|
|
373
|
+
|
|
374
|
+
console.log(`[WorkspaceManager] Found ${sessionFiles.length} Qwen session files in ${project}`);
|
|
375
|
+
|
|
376
|
+
const sessions = [];
|
|
377
|
+
|
|
378
|
+
for (const file of sessionFiles) {
|
|
379
|
+
const sessionId = file.replace('.jsonl', '');
|
|
380
|
+
const sessionPath = path.join(chatsDir, file);
|
|
381
|
+
|
|
382
|
+
// Read session file line by line
|
|
383
|
+
const messages = [];
|
|
384
|
+
let firstTimestamp = null;
|
|
385
|
+
let lastTimestamp = null;
|
|
386
|
+
|
|
387
|
+
try {
|
|
388
|
+
const fileStream = fs.createReadStream(sessionPath);
|
|
389
|
+
const rl = readline.createInterface({
|
|
390
|
+
input: fileStream,
|
|
391
|
+
crlfDelay: Infinity
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
for await (const line of rl) {
|
|
395
|
+
if (!line.trim()) continue;
|
|
396
|
+
try {
|
|
397
|
+
const entry = JSON.parse(line);
|
|
398
|
+
|
|
399
|
+
// Only include user/assistant messages (skip system/ui_telemetry)
|
|
400
|
+
if (entry.type === 'user' || entry.type === 'assistant') {
|
|
401
|
+
const timestamp = new Date(entry.timestamp).getTime();
|
|
402
|
+
if (!firstTimestamp || timestamp < firstTimestamp) firstTimestamp = timestamp;
|
|
403
|
+
if (!lastTimestamp || timestamp > lastTimestamp) lastTimestamp = timestamp;
|
|
404
|
+
|
|
405
|
+
// Extract content from Qwen format
|
|
406
|
+
let content = '';
|
|
407
|
+
const parts = entry.message?.parts;
|
|
408
|
+
if (Array.isArray(parts)) {
|
|
409
|
+
content = parts
|
|
410
|
+
.filter(p => p && p.text)
|
|
411
|
+
.map(p => p.text)
|
|
412
|
+
.join('\n');
|
|
413
|
+
} else if (typeof entry.message?.content === 'string') {
|
|
414
|
+
content = entry.message.content;
|
|
415
|
+
} else if (entry.text) {
|
|
416
|
+
content = entry.text;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
messages.push({
|
|
420
|
+
role: entry.type,
|
|
421
|
+
content: content,
|
|
422
|
+
timestamp: entry.timestamp
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
} catch (e) {
|
|
426
|
+
// Skip malformed lines
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
} catch (error) {
|
|
430
|
+
console.error(`[WorkspaceManager] Error reading Qwen session ${sessionId}:`, error.message);
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (messages.length > 0) {
|
|
435
|
+
sessions.push({
|
|
436
|
+
id: sessionId,
|
|
437
|
+
engine: 'qwen',
|
|
438
|
+
workspace_path: workspacePath,
|
|
439
|
+
session_path: sessionPath,
|
|
440
|
+
title: this.extractTitle(messages),
|
|
441
|
+
message_count: messages.length,
|
|
442
|
+
last_used_at: lastTimestamp,
|
|
443
|
+
created_at: firstTimestamp
|
|
444
|
+
// NOTE: messages NOT included - loaded on-demand by CliLoader (filesystem = source of truth)
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Sort by most recent first
|
|
450
|
+
sessions.sort((a, b) => b.last_used_at - a.last_used_at);
|
|
451
|
+
|
|
452
|
+
console.log(`[WorkspaceManager] Loaded ${sessions.length} Qwen sessions (sorted by recency)`);
|
|
453
|
+
return sessions;
|
|
454
|
+
}
|
|
455
|
+
|
|
335
456
|
/**
|
|
336
457
|
* Return cached history entries with TTL and fs.watch invalidation.
|
|
337
458
|
*/
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { fixTermuxPath } = require('./workspace');
|
|
4
|
+
|
|
5
|
+
const ATTACHMENT_LINE_REGEX = /^\s*\[Attached:\s*(.+?)\s*\]\s*$/;
|
|
6
|
+
const IMAGE_EXTS = new Set([
|
|
7
|
+
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.tiff', '.tif', '.svg', '.heic'
|
|
8
|
+
]);
|
|
9
|
+
|
|
10
|
+
function parseAttachmentMarkers(message = '') {
|
|
11
|
+
if (!message) {
|
|
12
|
+
return { cleanMessage: message, paths: [] };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const lines = message.split('\n');
|
|
16
|
+
const paths = [];
|
|
17
|
+
const kept = [];
|
|
18
|
+
|
|
19
|
+
for (const line of lines) {
|
|
20
|
+
const match = line.match(ATTACHMENT_LINE_REGEX);
|
|
21
|
+
if (match) {
|
|
22
|
+
paths.push(match[1]);
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
kept.push(line);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const cleanMessage = kept.join('\n').trim();
|
|
29
|
+
return { cleanMessage, paths };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isImageAttachment(attachment) {
|
|
33
|
+
const mimeType = attachment?.mimeType || '';
|
|
34
|
+
if (mimeType.startsWith('image/')) return true;
|
|
35
|
+
const ext = path.extname(attachment?.path || '').toLowerCase();
|
|
36
|
+
return IMAGE_EXTS.has(ext);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function normalizeAttachmentPath(rawPath, workspacePath) {
|
|
40
|
+
if (!rawPath || typeof rawPath !== 'string') return null;
|
|
41
|
+
let fixedPath = fixTermuxPath(rawPath);
|
|
42
|
+
if (!path.isAbsolute(fixedPath) && workspacePath) {
|
|
43
|
+
fixedPath = path.resolve(workspacePath, fixedPath);
|
|
44
|
+
}
|
|
45
|
+
return fixedPath;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function normalizeAttachments({ message, rawMessage, attachments, workspacePath }) {
|
|
49
|
+
const parsed = parseAttachmentMarkers(message || '');
|
|
50
|
+
const promptMessage = rawMessage != null ? rawMessage : (parsed.cleanMessage || message || '');
|
|
51
|
+
|
|
52
|
+
const incoming = [];
|
|
53
|
+
if (Array.isArray(attachments)) {
|
|
54
|
+
incoming.push(...attachments);
|
|
55
|
+
}
|
|
56
|
+
if (parsed.paths.length > 0) {
|
|
57
|
+
parsed.paths.forEach((p) => incoming.push({ path: p }));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const seen = new Set();
|
|
61
|
+
const normalized = [];
|
|
62
|
+
|
|
63
|
+
for (const entry of incoming) {
|
|
64
|
+
if (!entry) continue;
|
|
65
|
+
const rawPath = typeof entry === 'string' ? entry : entry.path;
|
|
66
|
+
const resolvedPath = normalizeAttachmentPath(rawPath, workspacePath);
|
|
67
|
+
if (!resolvedPath) continue;
|
|
68
|
+
if (!fs.existsSync(resolvedPath)) continue;
|
|
69
|
+
if (seen.has(resolvedPath)) continue;
|
|
70
|
+
|
|
71
|
+
seen.add(resolvedPath);
|
|
72
|
+
const record = {
|
|
73
|
+
...entry,
|
|
74
|
+
path: resolvedPath,
|
|
75
|
+
name: entry.name || path.basename(resolvedPath)
|
|
76
|
+
};
|
|
77
|
+
normalized.push(record);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const attachmentPaths = normalized.map((att) => att.path);
|
|
81
|
+
const includeDirectories = Array.from(
|
|
82
|
+
new Set(attachmentPaths.map((p) => path.dirname(p)))
|
|
83
|
+
);
|
|
84
|
+
const imageFiles = normalized.filter(isImageAttachment).map((att) => att.path);
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
promptMessage,
|
|
88
|
+
attachments: normalized,
|
|
89
|
+
attachmentPaths,
|
|
90
|
+
includeDirectories,
|
|
91
|
+
imageFiles,
|
|
92
|
+
cleanMessage: parsed.cleanMessage
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
module.exports = {
|
|
97
|
+
normalizeAttachments,
|
|
98
|
+
parseAttachmentMarkers,
|
|
99
|
+
isImageAttachment
|
|
100
|
+
};
|