claude-remote 0.5.2 → 0.6.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/bin/claude-remote.js +1 -1
- package/hooks/bridge-session-start.js +32 -32
- package/lib/cli.js +1 -0
- package/lib/http-server.js +60 -22
- package/lib/interactive-questions.js +183 -0
- package/lib/logger.js +172 -138
- package/lib/state.js +8 -6
- package/lib/ws-server.js +132 -96
- package/package.json +3 -2
- package/server.js +23 -16
- package/web/index.html +383 -0
- package/web/main.js +68 -0
- package/web/modules/chat-cache.js +118 -0
- package/web/modules/confirm.js +25 -0
- package/web/modules/constants.js +59 -0
- package/web/modules/debug.js +81 -0
- package/web/modules/dir-picker.js +128 -0
- package/web/modules/hub.js +619 -0
- package/web/modules/image-upload.js +290 -0
- package/web/modules/input.js +279 -0
- package/web/modules/interactions.js +423 -0
- package/web/modules/keyboard.js +78 -0
- package/web/modules/model-picker.js +47 -0
- package/web/modules/permissions.js +94 -0
- package/web/modules/renderer.js +863 -0
- package/web/modules/sessions.js +108 -0
- package/web/modules/settings.js +101 -0
- package/web/modules/state.js +61 -0
- package/web/modules/toast.js +68 -0
- package/web/modules/todo.js +292 -0
- package/web/modules/utils.js +102 -0
- package/web/modules/waiting.js +93 -0
- package/web/modules/websocket.js +486 -0
- package/web/styles.css +1959 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Image Upload
|
|
3
|
+
// ============================================================
|
|
4
|
+
import { MAX_IMAGE_BYTES, IMAGE_CHUNK_BYTES } from './constants.js';
|
|
5
|
+
import { $, makeUploadId } from './utils.js';
|
|
6
|
+
import { S, pendingImage, setPendingImage } from './state.js';
|
|
7
|
+
import { showToast } from './toast.js';
|
|
8
|
+
import { setWaiting } from './waiting.js';
|
|
9
|
+
import { updateSendBtn } from './input.js';
|
|
10
|
+
|
|
11
|
+
function imageProgressLabel(image) {
|
|
12
|
+
if (!image) return '0%';
|
|
13
|
+
if (image.status === 'uploaded') return 'Done';
|
|
14
|
+
if (image.status === 'submitting') return 'Send';
|
|
15
|
+
if (image.status === 'failed') return 'Retry';
|
|
16
|
+
return `${Math.max(0, Math.min(100, Math.round((image.progress || 0) * 100)))}%`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function updateImagePreviewUi() {
|
|
20
|
+
const preview = $('image-preview');
|
|
21
|
+
const img = $('image-preview-img');
|
|
22
|
+
const overlay = $('image-upload-overlay');
|
|
23
|
+
const ring = $('image-upload-ring');
|
|
24
|
+
const text = $('image-upload-text');
|
|
25
|
+
const removeBtn = $('image-preview-remove');
|
|
26
|
+
const currentImage = pendingImage;
|
|
27
|
+
|
|
28
|
+
if (!currentImage) {
|
|
29
|
+
preview.classList.add('hidden');
|
|
30
|
+
img.src = '';
|
|
31
|
+
overlay.classList.add('hidden');
|
|
32
|
+
text.textContent = '0%';
|
|
33
|
+
ring.style.strokeDashoffset = '97.4';
|
|
34
|
+
removeBtn.disabled = false;
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
preview.classList.remove('hidden');
|
|
39
|
+
img.src = currentImage.previewUrl || '';
|
|
40
|
+
removeBtn.disabled = currentImage.status === 'submitting';
|
|
41
|
+
|
|
42
|
+
const showOverlay = currentImage.status === 'uploading' || currentImage.status === 'uploaded' ||
|
|
43
|
+
currentImage.status === 'submitting' || currentImage.status === 'failed';
|
|
44
|
+
overlay.classList.toggle('hidden', !showOverlay);
|
|
45
|
+
text.textContent = imageProgressLabel(currentImage);
|
|
46
|
+
ring.style.strokeDashoffset = String(97.4 * (1 - Math.max(0, Math.min(1, currentImage.progress || 0))));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function clearUploadWaiter(uploadId, err = null) {
|
|
50
|
+
const waiter = S.uploadWaiters.get(uploadId);
|
|
51
|
+
if (!waiter) return;
|
|
52
|
+
S.uploadWaiters.delete(uploadId);
|
|
53
|
+
if (err) waiter.reject(err);
|
|
54
|
+
else waiter.resolve();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function waitForUploadStatus(uploadId, expectedStatuses, matchFn) {
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
S.uploadWaiters.set(uploadId, {
|
|
60
|
+
expectedStatuses: new Set(expectedStatuses),
|
|
61
|
+
matchFn,
|
|
62
|
+
resolve,
|
|
63
|
+
reject,
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function handleUploadStatus(m) {
|
|
69
|
+
const currentImage = pendingImage;
|
|
70
|
+
if (currentImage && m.uploadId === currentImage.uploadId) {
|
|
71
|
+
if (Number.isFinite(m.totalBytes) && m.totalBytes > 0) currentImage.totalBytes = m.totalBytes;
|
|
72
|
+
if (Number.isFinite(m.receivedBytes)) currentImage.uploadedBytes = m.receivedBytes;
|
|
73
|
+
const totalBytes = currentImage.totalBytes || 0;
|
|
74
|
+
if (totalBytes > 0 && Number.isFinite(currentImage.uploadedBytes)) {
|
|
75
|
+
currentImage.progress = Math.max(0, Math.min(1, currentImage.uploadedBytes / totalBytes));
|
|
76
|
+
}
|
|
77
|
+
if (m.status === 'ready_for_chunks' || m.status === 'uploading') currentImage.status = 'uploading';
|
|
78
|
+
else if (m.status === 'uploaded') {
|
|
79
|
+
currentImage.status = 'uploaded';
|
|
80
|
+
currentImage.progress = 1;
|
|
81
|
+
} else if (m.status === 'submitted') {
|
|
82
|
+
currentImage.status = 'submitted';
|
|
83
|
+
currentImage.progress = 1;
|
|
84
|
+
} else if (m.status === 'error' || m.status === 'aborted') {
|
|
85
|
+
currentImage.status = 'failed';
|
|
86
|
+
}
|
|
87
|
+
updateImagePreviewUi();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const waiter = S.uploadWaiters.get(m.uploadId);
|
|
91
|
+
if (!waiter) return;
|
|
92
|
+
if (m.status === 'error' || m.status === 'aborted') {
|
|
93
|
+
S.uploadWaiters.delete(m.uploadId);
|
|
94
|
+
waiter.reject(new Error(m.message || 'Image upload failed'));
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (!waiter.expectedStatuses.has(m.status)) return;
|
|
98
|
+
if (waiter.matchFn && !waiter.matchFn(m)) return;
|
|
99
|
+
S.uploadWaiters.delete(m.uploadId);
|
|
100
|
+
waiter.resolve(m);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function clearPendingImage({ abortUpload = true } = {}) {
|
|
104
|
+
const currentImage = pendingImage;
|
|
105
|
+
if (currentImage && abortUpload && currentImage.uploadId && S.ws && S.ws.readyState === WebSocket.OPEN &&
|
|
106
|
+
currentImage.status !== 'submitted') {
|
|
107
|
+
S.ws.send(JSON.stringify({ type: 'image_upload_abort', uploadId: currentImage.uploadId }));
|
|
108
|
+
}
|
|
109
|
+
if (currentImage?.previewUrl) {
|
|
110
|
+
try { URL.revokeObjectURL(currentImage.previewUrl); } catch {}
|
|
111
|
+
}
|
|
112
|
+
setPendingImage(null);
|
|
113
|
+
updateImagePreviewUi();
|
|
114
|
+
updateSendBtn();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function fileChunkToBase64(blob) {
|
|
118
|
+
return new Promise((resolve, reject) => {
|
|
119
|
+
const reader = new FileReader();
|
|
120
|
+
reader.onerror = () => reject(reader.error || new Error('Failed to read image chunk'));
|
|
121
|
+
reader.onload = () => {
|
|
122
|
+
const result = String(reader.result || '');
|
|
123
|
+
const commaIdx = result.indexOf(',');
|
|
124
|
+
resolve(commaIdx >= 0 ? result.slice(commaIdx + 1) : result);
|
|
125
|
+
};
|
|
126
|
+
reader.readAsDataURL(blob);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function fileToDataUrl(file) {
|
|
131
|
+
return new Promise((resolve, reject) => {
|
|
132
|
+
const objectUrl = URL.createObjectURL(file);
|
|
133
|
+
const img = new Image();
|
|
134
|
+
img.onerror = () => {
|
|
135
|
+
try { URL.revokeObjectURL(objectUrl); } catch {}
|
|
136
|
+
reject(new Error('Failed to decode image preview'));
|
|
137
|
+
};
|
|
138
|
+
img.onload = () => {
|
|
139
|
+
try {
|
|
140
|
+
const maxW = 480;
|
|
141
|
+
const maxH = 320;
|
|
142
|
+
const scale = Math.min(1, maxW / img.width, maxH / img.height);
|
|
143
|
+
const width = Math.max(1, Math.round(img.width * scale));
|
|
144
|
+
const height = Math.max(1, Math.round(img.height * scale));
|
|
145
|
+
const canvas = document.createElement('canvas');
|
|
146
|
+
canvas.width = width;
|
|
147
|
+
canvas.height = height;
|
|
148
|
+
const ctx = canvas.getContext('2d');
|
|
149
|
+
if (!ctx) throw new Error('Canvas unavailable');
|
|
150
|
+
ctx.drawImage(img, 0, 0, width, height);
|
|
151
|
+
resolve(canvas.toDataURL('image/jpeg', 0.82));
|
|
152
|
+
} catch (err) {
|
|
153
|
+
reject(err);
|
|
154
|
+
} finally {
|
|
155
|
+
try { URL.revokeObjectURL(objectUrl); } catch {}
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
img.src = objectUrl;
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function submitPendingImageUpload() {
|
|
163
|
+
const currentImage = pendingImage;
|
|
164
|
+
if (!currentImage || !currentImage.submitQueued || currentImage.status !== 'uploaded') return;
|
|
165
|
+
if (!S.ws || S.ws.readyState !== WebSocket.OPEN) throw new Error('Connection lost');
|
|
166
|
+
|
|
167
|
+
const uploadId = currentImage.uploadId;
|
|
168
|
+
currentImage.status = 'submitting';
|
|
169
|
+
updateImagePreviewUi();
|
|
170
|
+
const waitForSubmitted = waitForUploadStatus(uploadId, ['submitted']);
|
|
171
|
+
S.ws.send(JSON.stringify({
|
|
172
|
+
type: 'image_submit',
|
|
173
|
+
uploadId,
|
|
174
|
+
text: currentImage.queuedText || '',
|
|
175
|
+
}));
|
|
176
|
+
await waitForSubmitted;
|
|
177
|
+
clearPendingImage({ abortUpload: false });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function startImageUpload(image) {
|
|
181
|
+
if (!image || !S.ws || S.ws.readyState !== WebSocket.OPEN) {
|
|
182
|
+
throw new Error('Connection unavailable');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
image.status = 'uploading';
|
|
186
|
+
image.progress = 0;
|
|
187
|
+
image.uploadedBytes = 0;
|
|
188
|
+
updateImagePreviewUi();
|
|
189
|
+
|
|
190
|
+
const totalChunks = Math.max(1, Math.ceil(image.file.size / IMAGE_CHUNK_BYTES));
|
|
191
|
+
let waitForStatus = waitForUploadStatus(image.uploadId, ['ready_for_chunks']);
|
|
192
|
+
S.ws.send(JSON.stringify({
|
|
193
|
+
type: 'image_upload_init',
|
|
194
|
+
uploadId: image.uploadId,
|
|
195
|
+
totalBytes: image.file.size,
|
|
196
|
+
totalChunks,
|
|
197
|
+
mediaType: image.mediaType,
|
|
198
|
+
name: image.name,
|
|
199
|
+
}));
|
|
200
|
+
await waitForStatus;
|
|
201
|
+
|
|
202
|
+
for (let index = 0; index < totalChunks; index++) {
|
|
203
|
+
const start = index * IMAGE_CHUNK_BYTES;
|
|
204
|
+
const end = Math.min(image.file.size, start + IMAGE_CHUNK_BYTES);
|
|
205
|
+
const base64 = await fileChunkToBase64(image.file.slice(start, end));
|
|
206
|
+
waitForStatus = waitForUploadStatus(image.uploadId, ['uploading'], msg => msg.chunkIndex === index);
|
|
207
|
+
S.ws.send(JSON.stringify({
|
|
208
|
+
type: 'image_upload_chunk',
|
|
209
|
+
uploadId: image.uploadId,
|
|
210
|
+
index,
|
|
211
|
+
base64,
|
|
212
|
+
}));
|
|
213
|
+
await waitForStatus;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
waitForStatus = waitForUploadStatus(image.uploadId, ['uploaded']);
|
|
217
|
+
S.ws.send(JSON.stringify({ type: 'image_upload_complete', uploadId: image.uploadId }));
|
|
218
|
+
await waitForStatus;
|
|
219
|
+
|
|
220
|
+
const currentImage = pendingImage;
|
|
221
|
+
if (currentImage && currentImage.uploadId === image.uploadId && currentImage.submitQueued) {
|
|
222
|
+
await submitPendingImageUpload();
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function initImageUpload() {
|
|
227
|
+
$('btn-image').addEventListener('click', () => {
|
|
228
|
+
if (S.waiting) return;
|
|
229
|
+
$('image-file-input').click();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
$('image-file-input').addEventListener('change', async (e) => {
|
|
233
|
+
const file = e.target.files[0];
|
|
234
|
+
if (!file) return;
|
|
235
|
+
e.target.value = '';
|
|
236
|
+
|
|
237
|
+
if (!file.type.startsWith('image/')) {
|
|
238
|
+
showToast('Please select an image file');
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (file.size > MAX_IMAGE_BYTES) {
|
|
243
|
+
showToast('Image too large (max 4MB)');
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
if (!S.ws || S.ws.readyState !== WebSocket.OPEN || !S.authenticated) {
|
|
247
|
+
showToast('Connection unavailable');
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
clearPendingImage();
|
|
252
|
+
const previewUrl = await fileToDataUrl(file);
|
|
253
|
+
const newImage = {
|
|
254
|
+
file,
|
|
255
|
+
mediaType: file.type || 'image/png',
|
|
256
|
+
name: file.name,
|
|
257
|
+
previewUrl,
|
|
258
|
+
uploadId: makeUploadId(),
|
|
259
|
+
status: 'uploading',
|
|
260
|
+
progress: 0,
|
|
261
|
+
uploadedBytes: 0,
|
|
262
|
+
totalBytes: file.size,
|
|
263
|
+
submitQueued: false,
|
|
264
|
+
queuedText: '',
|
|
265
|
+
};
|
|
266
|
+
setPendingImage(newImage);
|
|
267
|
+
updateImagePreviewUi();
|
|
268
|
+
updateSendBtn();
|
|
269
|
+
|
|
270
|
+
try {
|
|
271
|
+
await startImageUpload(newImage);
|
|
272
|
+
} catch (err) {
|
|
273
|
+
const currentImage = pendingImage;
|
|
274
|
+
if (currentImage) {
|
|
275
|
+
const wasQueued = currentImage.submitQueued;
|
|
276
|
+
currentImage.status = 'failed';
|
|
277
|
+
updateImagePreviewUi();
|
|
278
|
+
if (wasQueued && S.waiting) setWaiting(false, 'image_upload_failed');
|
|
279
|
+
}
|
|
280
|
+
showToast(err.message || 'Image upload failed');
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
$('image-preview-remove').addEventListener('click', () => {
|
|
285
|
+
clearPendingImage();
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
updateImagePreviewUi();
|
|
289
|
+
updateSendBtn();
|
|
290
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Input handling & slash commands
|
|
3
|
+
// ============================================================
|
|
4
|
+
import { COMMANDS } from './constants.js';
|
|
5
|
+
import { $, esc } from './utils.js';
|
|
6
|
+
import { S, pendingImage } from './state.js';
|
|
7
|
+
import { debugLog } from './debug.js';
|
|
8
|
+
import { showToast } from './toast.js';
|
|
9
|
+
import { scrollEnd } from './waiting.js';
|
|
10
|
+
import { removeWelcome, closeGroup, scheduleSessionCacheSave, clearConversationUi } from './renderer.js';
|
|
11
|
+
import { showModelPicker } from './model-picker.js';
|
|
12
|
+
import { submitPendingImageUpload, updateImagePreviewUi } from './image-upload.js';
|
|
13
|
+
import { setWaiting } from './waiting.js';
|
|
14
|
+
|
|
15
|
+
const $input = $('input');
|
|
16
|
+
const $msgs = $('messages');
|
|
17
|
+
|
|
18
|
+
let cmdMenuOpen = false;
|
|
19
|
+
let cmdActiveIdx = -1;
|
|
20
|
+
|
|
21
|
+
export function updateSendBtn() {
|
|
22
|
+
const btn = $('btn-send');
|
|
23
|
+
if (S.waiting) {
|
|
24
|
+
btn.classList.remove('empty');
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const empty = !$input.value.trim() && !pendingImage;
|
|
28
|
+
btn.classList.toggle('empty', empty);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function setSendButtonMode(mode) {
|
|
32
|
+
const btn = $('btn-send');
|
|
33
|
+
btn.disabled = false;
|
|
34
|
+
if (mode === 'stop') {
|
|
35
|
+
btn.classList.add('stop-mode');
|
|
36
|
+
btn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>';
|
|
37
|
+
btn.title = 'Stop';
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
btn.classList.remove('stop-mode');
|
|
41
|
+
btn.innerHTML = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M12 19V5M5 12l7-7 7 7"/></svg>';
|
|
42
|
+
btn.title = 'Send';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function showCmdMenu(filter) {
|
|
46
|
+
const menu = $('cmd-menu');
|
|
47
|
+
const items = COMMANDS.filter(c => c.name.includes(filter.toLowerCase()));
|
|
48
|
+
if (items.length === 0) { hideCmdMenu(); return; }
|
|
49
|
+
|
|
50
|
+
menu.innerHTML = items.map((c, i) =>
|
|
51
|
+
`<div class="cmd-item${i === 0 ? ' active' : ''}" data-cmd="${c.name}">
|
|
52
|
+
<div class="cmd-icon">${c.icon}</div>
|
|
53
|
+
<div><div class="cmd-name">${c.name}</div><div class="cmd-desc">${c.desc}</div></div>
|
|
54
|
+
</div>`
|
|
55
|
+
).join('');
|
|
56
|
+
|
|
57
|
+
menu.querySelectorAll('.cmd-item').forEach(el => {
|
|
58
|
+
el.addEventListener('click', () => execCmd(el.dataset.cmd));
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
cmdActiveIdx = 0;
|
|
62
|
+
cmdMenuOpen = true;
|
|
63
|
+
menu.classList.add('visible');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function hideCmdMenu() {
|
|
67
|
+
$('cmd-menu').classList.remove('visible');
|
|
68
|
+
cmdMenuOpen = false;
|
|
69
|
+
cmdActiveIdx = -1;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const CMD_FEEDBACK = {
|
|
73
|
+
'/compact': { toast: null, overlay: true, label: '对话压缩中...' },
|
|
74
|
+
'/clear': { toast: 'Clearing conversation...', overlay: false },
|
|
75
|
+
'/cost': { toast: 'Fetching token costs...', overlay: false },
|
|
76
|
+
'/help': { toast: 'Loading help...', overlay: false },
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
function normalizeSlashCommandInput(text) {
|
|
80
|
+
const value = String(text || '').trim();
|
|
81
|
+
if (!value) return '';
|
|
82
|
+
const match = value.match(/^(\/[^\s]+)/);
|
|
83
|
+
if (!match || match[0] !== value) return '';
|
|
84
|
+
return match[1].toLowerCase();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function execCmd(cmd) {
|
|
88
|
+
cmd = normalizeSlashCommandInput(cmd) || String(cmd || '').trim().toLowerCase();
|
|
89
|
+
hideCmdMenu();
|
|
90
|
+
$input.value = '';
|
|
91
|
+
$input.style.height = 'auto';
|
|
92
|
+
updateSendBtn();
|
|
93
|
+
|
|
94
|
+
if (cmd === '/model') {
|
|
95
|
+
showModelPicker();
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (cmd === '/cost') {
|
|
100
|
+
closeGroup();
|
|
101
|
+
const el = document.createElement('div');
|
|
102
|
+
el.className = 'user-msg';
|
|
103
|
+
el.textContent = '/cost';
|
|
104
|
+
$msgs.appendChild(el);
|
|
105
|
+
scrollEnd();
|
|
106
|
+
sendSlashCmd(cmd);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (cmd === '/clear') {
|
|
111
|
+
clearConversationUi();
|
|
112
|
+
S.sessionId = '';
|
|
113
|
+
S.resumeRequestedFor = '';
|
|
114
|
+
sendSlashCmd(cmd);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const fb = CMD_FEEDBACK[cmd];
|
|
119
|
+
if (fb) {
|
|
120
|
+
if (fb.overlay) showCmdOverlay(fb.label);
|
|
121
|
+
else if (fb.toast) showToast(fb.toast);
|
|
122
|
+
}
|
|
123
|
+
sendSlashCmd(cmd);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function sendSlashCmd(text) {
|
|
127
|
+
if (!S.ws || S.ws.readyState !== WebSocket.OPEN) return;
|
|
128
|
+
S.ws.send(JSON.stringify({ type: 'chat', text }));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function sendControlInput(data) {
|
|
132
|
+
if (!S.ws || S.ws.readyState !== WebSocket.OPEN) return;
|
|
133
|
+
S.ws.send(JSON.stringify({ type: 'input', data: String(data) }));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function sendControlEnter() {
|
|
137
|
+
sendControlInput('\r');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function sendControlLine(text, { startDelayMs = 0, submitDelayMs = 120 } = {}) {
|
|
141
|
+
setTimeout(() => {
|
|
142
|
+
if (S.ws?.readyState === WebSocket.OPEN) sendControlInput(text);
|
|
143
|
+
}, startDelayMs);
|
|
144
|
+
setTimeout(() => {
|
|
145
|
+
if (S.ws?.readyState === WebSocket.OPEN) sendControlEnter();
|
|
146
|
+
}, startDelayMs + submitDelayMs);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function showCmdOverlay(label) {
|
|
150
|
+
let ov = $('cmd-overlay');
|
|
151
|
+
if (!ov) {
|
|
152
|
+
ov = document.createElement('div');
|
|
153
|
+
ov.id = 'cmd-overlay';
|
|
154
|
+
ov.innerHTML = `
|
|
155
|
+
<div class="cmd-overlay-card">
|
|
156
|
+
<div class="cmd-overlay-spinner"></div>
|
|
157
|
+
<span class="cmd-overlay-label"></span>
|
|
158
|
+
</div>
|
|
159
|
+
`;
|
|
160
|
+
document.getElementById('app').appendChild(ov);
|
|
161
|
+
}
|
|
162
|
+
ov.querySelector('.cmd-overlay-label').textContent = label;
|
|
163
|
+
ov.classList.add('visible');
|
|
164
|
+
$('input-area').classList.add('waiting');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function hideCmdOverlay() {
|
|
168
|
+
const ov = $('cmd-overlay');
|
|
169
|
+
if (ov) ov.classList.remove('visible');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function send() {
|
|
173
|
+
if (S.waiting) {
|
|
174
|
+
if (S.ws && S.ws.readyState === WebSocket.OPEN && S.authenticated) {
|
|
175
|
+
S.ws.send(JSON.stringify({ type: 'interrupt' }));
|
|
176
|
+
}
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const t = $input.value.trim();
|
|
180
|
+
const hasImage = !!pendingImage;
|
|
181
|
+
if ((!t && !hasImage) || !S.ws || S.ws.readyState !== WebSocket.OPEN || !S.authenticated) return;
|
|
182
|
+
debugLog('send_invoked', {
|
|
183
|
+
hasImage,
|
|
184
|
+
textPreview: t.slice(0, 80),
|
|
185
|
+
sessionId: S.sessionId || null,
|
|
186
|
+
waiting: S.waiting,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const slashCommand = normalizeSlashCommandInput(t);
|
|
190
|
+
if (!hasImage && COMMANDS.some(command => command.name === slashCommand)) {
|
|
191
|
+
execCmd(slashCommand);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
removeWelcome(); closeGroup();
|
|
196
|
+
|
|
197
|
+
const el = document.createElement('div');
|
|
198
|
+
el.className = 'user-msg'; el.dataset.optimistic = '1';
|
|
199
|
+
let html = '';
|
|
200
|
+
if (hasImage) {
|
|
201
|
+
if (pendingImage.status === 'failed') {
|
|
202
|
+
showToast('Image upload failed. Re-select the image and try again.');
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
html += `<img src="${pendingImage.previewUrl}" style="max-width:200px;max-height:120px;border-radius:8px;display:block;margin-bottom:${t ? '6px' : '0'}">`;
|
|
206
|
+
}
|
|
207
|
+
if (t) html += esc(t).replace(/\n/g, '<br>');
|
|
208
|
+
el.innerHTML = html;
|
|
209
|
+
$msgs.appendChild(el);
|
|
210
|
+
S.isAtBottom = true; scrollEnd();
|
|
211
|
+
scheduleSessionCacheSave();
|
|
212
|
+
|
|
213
|
+
if (hasImage) {
|
|
214
|
+
pendingImage.submitQueued = true;
|
|
215
|
+
pendingImage.queuedText = t || '';
|
|
216
|
+
} else {
|
|
217
|
+
S.ws.send(JSON.stringify({ type: 'chat', text: t }));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
$input.value = ''; $input.style.height = 'auto';
|
|
221
|
+
|
|
222
|
+
if (hasImage && pendingImage.status === 'uploaded') {
|
|
223
|
+
submitPendingImageUpload().catch(err => {
|
|
224
|
+
if (pendingImage) {
|
|
225
|
+
pendingImage.status = 'failed';
|
|
226
|
+
updateImagePreviewUi();
|
|
227
|
+
}
|
|
228
|
+
setWaiting(false, 'image_submit_failed');
|
|
229
|
+
showToast(err.message || 'Image submit failed');
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function initInput() {
|
|
235
|
+
$input.addEventListener('input', () => {
|
|
236
|
+
$input.style.height = 'auto';
|
|
237
|
+
$input.style.height = Math.min($input.scrollHeight, 120) + 'px';
|
|
238
|
+
updateSendBtn();
|
|
239
|
+
|
|
240
|
+
const val = $input.value;
|
|
241
|
+
if (val.startsWith('/') && !val.includes(' ') && val.length > 0) {
|
|
242
|
+
showCmdMenu(val);
|
|
243
|
+
} else {
|
|
244
|
+
hideCmdMenu();
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
$input.addEventListener('keydown', e => {
|
|
249
|
+
if (cmdMenuOpen) {
|
|
250
|
+
const items = $('cmd-menu').querySelectorAll('.cmd-item');
|
|
251
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
252
|
+
e.preventDefault();
|
|
253
|
+
items[cmdActiveIdx]?.classList.remove('active');
|
|
254
|
+
if (e.key === 'ArrowDown') cmdActiveIdx = Math.min(cmdActiveIdx + 1, items.length - 1);
|
|
255
|
+
else cmdActiveIdx = Math.max(cmdActiveIdx - 1, 0);
|
|
256
|
+
items[cmdActiveIdx]?.classList.add('active');
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (e.key === 'Enter') {
|
|
260
|
+
e.preventDefault();
|
|
261
|
+
const active = items[cmdActiveIdx];
|
|
262
|
+
if (active) execCmd(active.dataset.cmd);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (e.key === 'Escape') {
|
|
266
|
+
e.preventDefault();
|
|
267
|
+
hideCmdMenu();
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); }
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
$('btn-send').addEventListener('click', send);
|
|
275
|
+
$('btn-scroll').addEventListener('click', () => {
|
|
276
|
+
const chat = $('chat-area');
|
|
277
|
+
chat.scrollTop = chat.scrollHeight;
|
|
278
|
+
});
|
|
279
|
+
}
|