claude-simple 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client.js +31 -2
- package/dist/app.0.2.1.min.js +868 -0
- package/dist/highlight.min.js +448 -430
- package/dist/index.html +2 -2
- package/dist/style.0.2.1.min.css +1182 -0
- package/package.json +1 -1
- package/public/agent-panel.js +44 -0
- package/public/api.js +61 -10
- package/public/app.js +4 -0
- package/public/chat.js +338 -31
- package/public/files.js +22 -30
- package/public/git.js +21 -17
- package/public/icons/icon-180.png +0 -0
- package/public/icons/icon-192.png +0 -0
- package/public/icons/icon-512.png +0 -0
- package/public/icons/icon-maskable-512.png +0 -0
- package/public/index.html +16 -3
- package/public/lib.js +7 -2
- package/public/manifest.webmanifest +36 -0
- package/public/offline.html +33 -0
- package/public/pwa.js +78 -0
- package/public/shell/boot.js +3 -0
- package/public/shell/hub.js +4 -2
- package/public/shell/notes.js +93 -31
- package/public/shell/session-list.js +29 -10
- package/public/shell/session-nav.js +7 -2
- package/public/shell/templates.js +11 -3
- package/public/shell.js +101 -8
- package/public/state.js +13 -2
- package/public/style.css +135 -3
- package/public/sw.js +104 -0
- package/public/terminal.js +22 -0
- package/public/wake-lock.js +45 -0
- package/server.js +5 -1
- package/src/agents/claude.js +108 -10
- package/src/agents/codex.js +1 -1
- package/src/agents/grok.js +40 -32
- package/src/agents/meta-agent.js +40 -1
- package/src/agents/stream-batch.js +62 -0
- package/src/app.js +312 -41
- package/src/auto-update.js +215 -0
- package/src/runtime/approvals.js +67 -0
- package/src/runtime/outbox.js +143 -0
- package/src/runtime/protocol.js +224 -0
- package/dist/app.0.1.0.min.js +0 -825
- package/dist/style.0.1.0.min.css +0 -1050
package/package.json
CHANGED
package/public/agent-panel.js
CHANGED
|
@@ -346,6 +346,37 @@ function updateToolChip(id, phase, result) {
|
|
|
346
346
|
}
|
|
347
347
|
}
|
|
348
348
|
|
|
349
|
+
/** run_command wants to execute — show an inline approve/deny card and POST the answer. */
|
|
350
|
+
function appendApprovalPrompt(d) {
|
|
351
|
+
const box = $('ma-messages');
|
|
352
|
+
if (!box) return;
|
|
353
|
+
const el = document.createElement('div');
|
|
354
|
+
el.className = 'ma-approval';
|
|
355
|
+
el.dataset.approvalId = d.id;
|
|
356
|
+
const cmd = d.args?.command ?? '';
|
|
357
|
+
el.innerHTML = `
|
|
358
|
+
<div class="ma-approval-label">⚠ Agent 想要执行命令,是否允许?</div>
|
|
359
|
+
<pre class="ma-approval-cmd">${esc(cmd)}</pre>
|
|
360
|
+
<div class="ma-approval-actions">
|
|
361
|
+
<button type="button" class="btn btn-xs btn-error" data-approve="0">拒绝</button>
|
|
362
|
+
<button type="button" class="btn btn-xs btn-success" data-approve="1">允许执行</button>
|
|
363
|
+
</div>`;
|
|
364
|
+
el.querySelectorAll('[data-approve]').forEach((btn) => {
|
|
365
|
+
btn.addEventListener('click', async () => {
|
|
366
|
+
const approved = btn.dataset.approve === '1';
|
|
367
|
+
el.querySelectorAll('button').forEach((b) => { b.disabled = true; });
|
|
368
|
+
el.querySelector('.ma-approval-label').textContent = approved ? '✓ 已允许,正在执行…' : '✗ 已拒绝';
|
|
369
|
+
try {
|
|
370
|
+
await api('POST', '/api/ai/approve', { id: d.id, approved });
|
|
371
|
+
} catch (e) {
|
|
372
|
+
el.querySelector('.ma-approval-label').textContent = `确认失败:${e.message}`;
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
box.appendChild(el);
|
|
377
|
+
box.scrollTop = box.scrollHeight;
|
|
378
|
+
}
|
|
379
|
+
|
|
349
380
|
function nextImgLabel() {
|
|
350
381
|
imgSeq += 1;
|
|
351
382
|
return `img${imgSeq}`;
|
|
@@ -713,6 +744,7 @@ async function sendChat() {
|
|
|
713
744
|
updateToolChip(d.id, d.phase, d.result);
|
|
714
745
|
}
|
|
715
746
|
},
|
|
747
|
+
approval: (d) => appendApprovalPrompt(d),
|
|
716
748
|
done: (d) => {
|
|
717
749
|
if (d.sessionId) {
|
|
718
750
|
chatSessionId = d.sessionId;
|
|
@@ -1069,7 +1101,19 @@ export function initMetaAgent() {
|
|
|
1069
1101
|
});
|
|
1070
1102
|
|
|
1071
1103
|
// bootstrap: empty chat (last session restored on openMetaAgent)
|
|
1104
|
+
// History/config fetch is deferred to loadMetaAgentBoot() — called once hub
|
|
1105
|
+
// status + selected machine are known (boot.js / hub.js), not here: at this
|
|
1106
|
+
// point probeHub() hasn't resolved yet, so hubMode always reads as false.
|
|
1072
1107
|
loadSession(null, { silent: true });
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/**
|
|
1111
|
+
* Meta-agent history + AI config are machine-scoped in hub mode — skip until
|
|
1112
|
+
* a machine is selected (called again from enterMachine() once one is).
|
|
1113
|
+
*/
|
|
1114
|
+
export function loadMetaAgentBoot() {
|
|
1115
|
+
if (!ctx.token) return;
|
|
1116
|
+
if (hubMode.peek() && !currentMachineId()) return;
|
|
1073
1117
|
refreshHistoryCache();
|
|
1074
1118
|
api('GET', '/api/ai/config').then(scheduleAutoReport).catch(() => {});
|
|
1075
1119
|
}
|
package/public/api.js
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
// api.js — HTTP + WebSocket helpers
|
|
2
2
|
import { ctx, hubMode, selectedMachineId } from './state.js';
|
|
3
|
+
import { createFetch } from './lib.js';
|
|
4
|
+
|
|
5
|
+
// Only coalesce identical in-flight reads. Data is deliberately not retained
|
|
6
|
+
// here: session and workspace freshness remain controlled by their existing
|
|
7
|
+
// IndexedDB cache, while other API reads keep their previous always-fresh
|
|
8
|
+
// behavior.
|
|
9
|
+
const pendingGets = createFetch({ cache: false, dedupe: true, retry: 0 });
|
|
10
|
+
/** Path → ETag for conditional GETs (304 = free on mobile radio). */
|
|
11
|
+
const etagStore = new Map();
|
|
12
|
+
export const NOT_MODIFIED = Symbol('not-modified');
|
|
3
13
|
|
|
4
14
|
/** Active edge machine id (hub). */
|
|
5
15
|
export function currentMachineId(opts = {}) {
|
|
@@ -27,19 +37,60 @@ export async function api(method, path, body, signal, opts = {}) {
|
|
|
27
37
|
headers['X-Machine-Id'] = machineId;
|
|
28
38
|
}
|
|
29
39
|
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
40
|
+
const etagKey = opts.etag === true
|
|
41
|
+
? `${ctx.token || ''}:${machineId || ''}:${path}`
|
|
42
|
+
: (opts.etag || null);
|
|
43
|
+
if (method.toUpperCase() === 'GET' && etagKey) {
|
|
44
|
+
const prev = etagStore.get(etagKey);
|
|
45
|
+
if (prev) headers['If-None-Match'] = prev;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const request = async () => {
|
|
49
|
+
const res = await fetch(path, {
|
|
50
|
+
method,
|
|
51
|
+
signal,
|
|
52
|
+
headers,
|
|
53
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
54
|
+
});
|
|
55
|
+
if (res.status === 304) return NOT_MODIFIED;
|
|
56
|
+
const data = await res.json().catch(() => ({}));
|
|
57
|
+
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
|
58
|
+
if (etagKey) {
|
|
59
|
+
const etag = res.headers.get('ETag');
|
|
60
|
+
if (etag) etagStore.set(etagKey, etag);
|
|
61
|
+
}
|
|
62
|
+
return data;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// A caller-provided AbortSignal must retain exclusive ownership of its
|
|
66
|
+
// request; sharing it could let one panel cancel another panel's read.
|
|
67
|
+
if (method.toUpperCase() !== 'GET' || signal || opts.dedupe === false) {
|
|
68
|
+
return request();
|
|
69
|
+
}
|
|
70
|
+
const key = `${ctx.token || ''}:${machineId || ''}:${path}`;
|
|
71
|
+
return pendingGets.get(key, request);
|
|
39
72
|
}
|
|
40
73
|
|
|
74
|
+
const outboundWs = [];
|
|
75
|
+
|
|
41
76
|
export function sendWs(data) {
|
|
42
|
-
if (ctx.ws?.readyState === WebSocket.OPEN)
|
|
77
|
+
if (ctx.ws?.readyState === WebSocket.OPEN) {
|
|
78
|
+
ctx.ws.send(JSON.stringify(data));
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
if (data?.type === 'ping' || data?.type === 'pong' || data?.type === 'subscribe') return false;
|
|
82
|
+
outboundWs.push(data);
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function flushWsQueue() {
|
|
87
|
+
while (outboundWs.length && ctx.ws?.readyState === WebSocket.OPEN) {
|
|
88
|
+
ctx.ws.send(JSON.stringify(outboundWs.shift()));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function clearWsQueue() {
|
|
93
|
+
outboundWs.length = 0;
|
|
43
94
|
}
|
|
44
95
|
|
|
45
96
|
/** Query string for download links (token + machine in hub mode). */
|
package/public/app.js
CHANGED
|
@@ -10,6 +10,7 @@ import { initTerminal } from './terminal.js';
|
|
|
10
10
|
import { initImagePaste } from './chat.js';
|
|
11
11
|
import { initMetaAgent } from './agent-panel.js';
|
|
12
12
|
import { initRouter, applyInitialRoute, parseHashPath } from './router.js';
|
|
13
|
+
import { initPwa } from './pwa.js';
|
|
13
14
|
|
|
14
15
|
function markBootReady() {
|
|
15
16
|
const root = document.documentElement;
|
|
@@ -93,3 +94,6 @@ initRouter(resumeSession, switchTab);
|
|
|
93
94
|
document.addEventListener('router:home', () => {
|
|
94
95
|
clearBootRestore();
|
|
95
96
|
});
|
|
97
|
+
|
|
98
|
+
// PWA: register SW (static cache only; never touches chat WS)
|
|
99
|
+
initPwa();
|
package/public/chat.js
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
import { watch, delegate, esc, $ } from './lib.js';
|
|
3
3
|
import { ctx, isProcessing, currentProject, currentTab, currentModel, currentEffort, currentPermission,
|
|
4
4
|
currentAgent, setAgent, AGENT_LABELS, getDefaultModel, getModelsForAgent, getEffortsForModel,
|
|
5
|
-
addCustomModel, chatDensity } from './state.js';
|
|
6
|
-
import { sendWs, api } from './api.js';
|
|
5
|
+
addCustomModel, chatDensity, wsStatus } from './state.js';
|
|
6
|
+
import { sendWs, api, flushWsQueue, clearWsQueue } from './api.js';
|
|
7
|
+
import { initWakeLock, setWakeLockDesired } from './wake-lock.js';
|
|
7
8
|
|
|
8
9
|
function assistantLabel() {
|
|
9
10
|
return AGENT_LABELS[currentAgent.peek()] || 'Assistant';
|
|
@@ -21,23 +22,246 @@ function stickBottom(el, force) {
|
|
|
21
22
|
if (force || atBottom(el)) el.scrollTop = el.scrollHeight;
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
// ── WebSocket
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
// ── WebSocket (auto-reconnect for mobile radio / background) ─────────────────
|
|
26
|
+
let wsGen = 0;
|
|
27
|
+
let reconnectTimer = null;
|
|
28
|
+
let reconnectDelay = 1000;
|
|
29
|
+
let lastPongAt = 0;
|
|
30
|
+
let heartbeatTimer = null;
|
|
31
|
+
let lifecycleBound = false;
|
|
32
|
+
let sawDisconnect = false;
|
|
33
|
+
let lastWsMachine = null;
|
|
34
|
+
let reconnectQuiet = false;
|
|
35
|
+
/** Queued user text to send after current turn finishes (Grok used to block forever). */
|
|
36
|
+
let pendingUserText = null;
|
|
37
|
+
|
|
38
|
+
function bindSocketLifecycle() {
|
|
39
|
+
if (lifecycleBound) return;
|
|
40
|
+
lifecycleBound = true;
|
|
41
|
+
document.addEventListener('visibilitychange', () => {
|
|
42
|
+
if (document.visibilityState === 'visible') {
|
|
43
|
+
reconnectDelay = 400;
|
|
44
|
+
ensureChatSocket({ probe: true });
|
|
45
|
+
// Re-subscribe so server can re-attach stream / clear stuck busy state
|
|
46
|
+
if (ctx.ws?.readyState === WebSocket.OPEN && ctx.sessionId) {
|
|
47
|
+
try { ctx.ws.send(JSON.stringify({ type: 'subscribe', sessionId: ctx.sessionId })); } catch { /* ignore */ }
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
window.addEventListener('online', () => {
|
|
52
|
+
reconnectDelay = 400;
|
|
53
|
+
ensureChatSocket({ force: true });
|
|
54
|
+
});
|
|
55
|
+
window.addEventListener('pageshow', (e) => {
|
|
56
|
+
if (e.persisted) ensureChatSocket({ force: true });
|
|
57
|
+
else ensureChatSocket({ probe: true });
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function updateConnDot() {
|
|
62
|
+
const dot = $('conn-dot');
|
|
63
|
+
if (!dot) return;
|
|
64
|
+
const st = wsStatus.peek();
|
|
65
|
+
dot.classList.remove('bg-success', 'bg-warning', 'bg-error', 'bg-base-content/30', 'animate-pulse');
|
|
66
|
+
if (st === 'open') {
|
|
67
|
+
dot.classList.add('bg-success');
|
|
68
|
+
dot.title = '已连接';
|
|
69
|
+
} else if (st === 'connecting' || st === 'reconnecting') {
|
|
70
|
+
dot.classList.add('bg-warning', 'animate-pulse');
|
|
71
|
+
dot.title = st === 'connecting' ? '连接中…' : '重连中…';
|
|
72
|
+
} else if (st === 'offline') {
|
|
73
|
+
dot.classList.add('bg-error');
|
|
74
|
+
dot.title = '离线';
|
|
75
|
+
} else {
|
|
76
|
+
dot.classList.add('bg-base-content/30');
|
|
77
|
+
dot.title = '未连接';
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function ensureChatSocket(opts = {}) {
|
|
82
|
+
const st = ctx.ws?.readyState;
|
|
83
|
+
if (opts.force) {
|
|
84
|
+
reconnectDelay = 400;
|
|
85
|
+
connectWS({ quiet: true });
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (st === WebSocket.OPEN) {
|
|
89
|
+
if (opts.probe && lastPongAt && Date.now() - lastPongAt > 45_000) {
|
|
90
|
+
try { ctx.ws.close(); } catch { /* reconnect via close handler */ }
|
|
91
|
+
} else if (opts.probe && ctx.ws.readyState === WebSocket.OPEN) {
|
|
92
|
+
try { ctx.ws.send(JSON.stringify({ type: 'ping' })); } catch { /* ignore */ }
|
|
93
|
+
}
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (st === WebSocket.CONNECTING) return;
|
|
97
|
+
reconnectDelay = Math.min(reconnectDelay, 800);
|
|
98
|
+
connectWS({ quiet: true });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function scheduleReconnect() {
|
|
102
|
+
if (reconnectTimer) return;
|
|
103
|
+
wsStatus.value = (typeof navigator !== 'undefined' && navigator.onLine === false) ? 'offline' : 'reconnecting';
|
|
104
|
+
updateConnDot();
|
|
105
|
+
reconnectTimer = setTimeout(() => {
|
|
106
|
+
reconnectTimer = null;
|
|
107
|
+
connectWS({ quiet: true });
|
|
108
|
+
}, reconnectDelay);
|
|
109
|
+
reconnectDelay = Math.min(Math.round(reconnectDelay * 1.8), 15_000);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function stopHeartbeat() {
|
|
113
|
+
if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function startHeartbeat(sock, gen) {
|
|
117
|
+
stopHeartbeat();
|
|
118
|
+
// Server already pings ~35s. Client only watches for silence and pings
|
|
119
|
+
// when the tab is visible — avoids double traffic + radio wake on mobile.
|
|
120
|
+
heartbeatTimer = setInterval(() => {
|
|
121
|
+
if (gen !== wsGen || sock.readyState !== WebSocket.OPEN) return;
|
|
122
|
+
if (lastPongAt && Date.now() - lastPongAt > 70_000) {
|
|
123
|
+
try { sock.close(); } catch { /* close handler reconnects */ }
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;
|
|
127
|
+
// Light client probe only if server ping seems late (tunnel stall).
|
|
128
|
+
if (lastPongAt && Date.now() - lastPongAt > 40_000) {
|
|
129
|
+
try { sock.send(JSON.stringify({ type: 'ping' })); } catch { /* ignore */ }
|
|
130
|
+
}
|
|
131
|
+
}, 30_000);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function syncWakeLock() {
|
|
135
|
+
// Keep screen on while a turn is running OR while a project chat is open and connected.
|
|
136
|
+
const need = isProcessing.peek()
|
|
137
|
+
|| (!!currentProject.peek() && wsStatus.peek() === 'open');
|
|
138
|
+
setWakeLockDesired(need);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Manual reconnect (toolbar / banner). Resets backoff and forces a new socket. */
|
|
142
|
+
export function refreshConnection() {
|
|
143
|
+
reconnectDelay = 400;
|
|
144
|
+
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
|
145
|
+
reconnectQuiet = false;
|
|
146
|
+
const st = ctx.ws?.readyState;
|
|
147
|
+
if (st === WebSocket.OPEN || st === WebSocket.CONNECTING) {
|
|
148
|
+
try { ctx.ws?.close(); } catch { /* ignore */ }
|
|
149
|
+
}
|
|
150
|
+
connectWS({ quiet: false });
|
|
151
|
+
appendSystemMsg('正在重新连接并同步…');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function connectWS(opts = {}) {
|
|
155
|
+
bindSocketLifecycle();
|
|
156
|
+
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
|
157
|
+
if (opts.quiet) reconnectQuiet = true;
|
|
158
|
+
|
|
159
|
+
const mid = ctx.machineId || (typeof localStorage !== 'undefined' ? localStorage.getItem('machineId') : null);
|
|
160
|
+
if (lastWsMachine !== mid) {
|
|
161
|
+
clearWsQueue();
|
|
162
|
+
lastWsMachine = mid;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const gen = ++wsGen;
|
|
166
|
+
try { ctx.ws?.close(); } catch { /* ignore */ }
|
|
167
|
+
|
|
168
|
+
wsStatus.value = 'connecting';
|
|
169
|
+
updateConnDot();
|
|
27
170
|
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
28
171
|
const q = new URLSearchParams();
|
|
29
172
|
if (ctx.token) q.set('token', ctx.token);
|
|
30
|
-
// Hub mode: route chat WS to the selected edge
|
|
31
|
-
const mid = ctx.machineId || (typeof localStorage !== 'undefined' ? localStorage.getItem('machineId') : null);
|
|
32
173
|
if (mid) q.set('machine', mid);
|
|
33
|
-
ctx.
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
ctx.ws
|
|
37
|
-
|
|
174
|
+
if (ctx.sessionId) q.set('sessionId', ctx.sessionId);
|
|
175
|
+
|
|
176
|
+
const sock = new WebSocket(`${proto}://${location.host}/ws/chat?${q}`);
|
|
177
|
+
ctx.ws = sock;
|
|
178
|
+
|
|
179
|
+
sock.addEventListener('open', () => {
|
|
180
|
+
if (gen !== wsGen) return;
|
|
181
|
+
reconnectDelay = 1000;
|
|
182
|
+
lastPongAt = Date.now();
|
|
183
|
+
wsStatus.value = 'open';
|
|
184
|
+
updateConnDot();
|
|
185
|
+
if (ctx.sessionId) {
|
|
186
|
+
try { sock.send(JSON.stringify({ type: 'subscribe', sessionId: ctx.sessionId })); } catch { /* ignore */ }
|
|
187
|
+
}
|
|
188
|
+
flushWsQueue();
|
|
189
|
+
startHeartbeat(sock, gen);
|
|
190
|
+
syncWakeLock();
|
|
191
|
+
if (sawDisconnect && !reconnectQuiet) {
|
|
192
|
+
appendSystemMsg(mid ? `已重连 · ${mid}` : '已重连');
|
|
193
|
+
}
|
|
194
|
+
sawDisconnect = false;
|
|
195
|
+
reconnectQuiet = false;
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
sock.addEventListener('message', e => {
|
|
199
|
+
if (gen !== wsGen) return;
|
|
200
|
+
let msg;
|
|
201
|
+
try { msg = JSON.parse(e.data); } catch { return; }
|
|
202
|
+
if (msg.type === 'pong') { lastPongAt = Date.now(); return; }
|
|
203
|
+
if (msg.type === 'ping') {
|
|
204
|
+
lastPongAt = Date.now();
|
|
205
|
+
if (sock.readyState === WebSocket.OPEN) {
|
|
206
|
+
try { sock.send(JSON.stringify({ type: 'pong' })); } catch { /* ignore */ }
|
|
207
|
+
}
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (msg.type === 'run-status') {
|
|
211
|
+
// Only adopt "running" from server; never re-lock after we already finished a turn
|
|
212
|
+
// unless the server still has a busy run (reconnect mid-stream).
|
|
213
|
+
if (msg.running) {
|
|
214
|
+
isProcessing.value = true;
|
|
215
|
+
} else if (isProcessing.peek()) {
|
|
216
|
+
// Server says idle — unlock (fixes Grok lingering process / missed complete)
|
|
217
|
+
isProcessing.value = false;
|
|
218
|
+
flushPendingUserText();
|
|
219
|
+
}
|
|
220
|
+
syncWakeLock();
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
handleWsMessage(msg);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
sock.addEventListener('close', () => {
|
|
227
|
+
if (gen !== wsGen) return;
|
|
228
|
+
stopHeartbeat();
|
|
229
|
+
sawDisconnect = true;
|
|
230
|
+
wsStatus.value = (typeof navigator !== 'undefined' && navigator.onLine === false) ? 'offline' : 'reconnecting';
|
|
231
|
+
updateConnDot();
|
|
232
|
+
scheduleReconnect();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
sock.addEventListener('error', () => {
|
|
236
|
+
// close follows; avoid flipping isProcessing so an in-flight run can resume
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function flushPendingUserText() {
|
|
241
|
+
if (!pendingUserText || isProcessing.peek()) return;
|
|
242
|
+
const text = pendingUserText;
|
|
243
|
+
pendingUserText = null;
|
|
244
|
+
const input = $('chat-input');
|
|
245
|
+
if (input) {
|
|
246
|
+
input.value = text;
|
|
247
|
+
autoResize(input);
|
|
248
|
+
}
|
|
249
|
+
// Defer so isProcessing watch can re-enable send button first
|
|
250
|
+
setTimeout(() => sendMessage(), 0);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function markTurnDone() {
|
|
254
|
+
isProcessing.value = false;
|
|
255
|
+
syncWakeLock();
|
|
256
|
+
flushPendingUserText();
|
|
38
257
|
}
|
|
39
258
|
|
|
40
259
|
function handleWsMessage(msg) {
|
|
260
|
+
// Server outbox may pack multiple events into one frame
|
|
261
|
+
if (msg.type === 'batch' && Array.isArray(msg.items)) {
|
|
262
|
+
for (const item of msg.items) handleWsMessage(item);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
41
265
|
if (msg.type === 'session-created') {
|
|
42
266
|
ctx.sessionId = msg.sessionId;
|
|
43
267
|
if (msg.agent) setAgent(msg.agent);
|
|
@@ -49,7 +273,6 @@ function handleWsMessage(msg) {
|
|
|
49
273
|
finalizeThoughtStream();
|
|
50
274
|
finalizeAssistantStream();
|
|
51
275
|
flushToolBatch();
|
|
52
|
-
isProcessing.value = false;
|
|
53
276
|
const u = msg.usage;
|
|
54
277
|
if (u) {
|
|
55
278
|
const norm = {
|
|
@@ -66,24 +289,27 @@ function handleWsMessage(msg) {
|
|
|
66
289
|
appendHistoryTokenBar(norm);
|
|
67
290
|
}
|
|
68
291
|
if (!msg.is_error) appendSystemMsg('✓ Done');
|
|
292
|
+
// Unlock immediately so the next Grok turn can be typed/sent without waiting process exit.
|
|
293
|
+
markTurnDone();
|
|
69
294
|
return;
|
|
70
295
|
}
|
|
71
296
|
if (msg.type === 'complete') {
|
|
72
297
|
finalizeThoughtStream();
|
|
73
298
|
finalizeAssistantStream();
|
|
74
299
|
flushToolBatch();
|
|
75
|
-
isProcessing.value = false;
|
|
76
300
|
// result already printed "Done" for codex/grok; avoid double for those
|
|
77
301
|
if (!msg.aborted && msg.agent === 'claude') appendSystemMsg('✓ Done');
|
|
78
302
|
else if (!msg.aborted && msg.agent && msg.agent !== 'claude' && !msg.error) { /* result handled it */ }
|
|
79
303
|
else if (!msg.aborted && !msg.agent) appendSystemMsg('✓ Done');
|
|
304
|
+
else if (msg.aborted) appendSystemMsg('已停止');
|
|
305
|
+
markTurnDone();
|
|
80
306
|
return;
|
|
81
307
|
}
|
|
82
308
|
if (msg.type === 'error') {
|
|
83
309
|
finalizeThoughtStream();
|
|
84
310
|
finalizeAssistantStream();
|
|
85
311
|
appendMsg('error', 'Error', msg.message);
|
|
86
|
-
|
|
312
|
+
markTurnDone();
|
|
87
313
|
return;
|
|
88
314
|
}
|
|
89
315
|
if (msg.type === 'shell-start') { ctx.shellBubble = null; return; }
|
|
@@ -95,7 +321,7 @@ function handleWsMessage(msg) {
|
|
|
95
321
|
if (msg.type === 'shell-exit') {
|
|
96
322
|
ctx.shellBubble = null;
|
|
97
323
|
appendSystemMsg(msg.code === 0 ? '✓ Exit 0' : `✗ Exit ${msg.code}${msg.error ? ': ' + msg.error : ''}`);
|
|
98
|
-
|
|
324
|
+
markTurnDone();
|
|
99
325
|
return;
|
|
100
326
|
}
|
|
101
327
|
if (msg.type === 'assistant') {
|
|
@@ -152,6 +378,11 @@ function handleWsMessage(msg) {
|
|
|
152
378
|
return;
|
|
153
379
|
}
|
|
154
380
|
|
|
381
|
+
if (msg.type === 'permission_resolved') {
|
|
382
|
+
const id = msg.requestId || msg.request_id;
|
|
383
|
+
if (id) $(`perm-${id}`)?.remove();
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
155
386
|
if (msg.type === 'permission_request' || (msg.request_id && msg.tool_name))
|
|
156
387
|
renderPermissionRequest(msg);
|
|
157
388
|
}
|
|
@@ -467,7 +698,18 @@ async function showUsageCommand() {
|
|
|
467
698
|
export function sendMessage() {
|
|
468
699
|
const input = $('chat-input');
|
|
469
700
|
const text = input?.value.trim();
|
|
470
|
-
if (!text ||
|
|
701
|
+
if (!text || !currentProject.peek()) return;
|
|
702
|
+
|
|
703
|
+
// While a turn is running, queue the next message (common Grok complaint).
|
|
704
|
+
if (isProcessing.peek()) {
|
|
705
|
+
pendingUserText = text;
|
|
706
|
+
input.value = '';
|
|
707
|
+
autoResize(input);
|
|
708
|
+
updateInputMode('');
|
|
709
|
+
appendSystemMsg(`已排队下一条,当前回复结束后发送:${text.length > 80 ? text.slice(0, 80) + '…' : text}`);
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
|
|
471
713
|
input.value = '';
|
|
472
714
|
autoResize(input);
|
|
473
715
|
updateInputMode('');
|
|
@@ -479,32 +721,51 @@ export function sendMessage() {
|
|
|
479
721
|
}
|
|
480
722
|
|
|
481
723
|
isProcessing.value = true;
|
|
724
|
+
syncWakeLock();
|
|
482
725
|
const now = Date.now();
|
|
483
726
|
if (text.startsWith('!')) {
|
|
484
727
|
appendMsg('shell', '$ Shell', text.slice(1).trim(), { ts: now });
|
|
485
|
-
|
|
728
|
+
// Typed control plane (legacy shell-command still accepted server-side)
|
|
729
|
+
const ok = sendWs({
|
|
730
|
+
type: 'command',
|
|
731
|
+
cmd: 'shell.exec',
|
|
732
|
+
command: text.slice(1).trim(),
|
|
733
|
+
cwd: currentProject.peek().path,
|
|
734
|
+
});
|
|
735
|
+
if (!ok) appendSystemMsg('连接未就绪,命令已入队,重连后发送');
|
|
486
736
|
} else {
|
|
487
737
|
flushToolBatch();
|
|
488
738
|
appendMsg('user', 'You', text, { ts: now });
|
|
489
739
|
const effort = currentEffort.peek();
|
|
490
740
|
const permission = currentPermission.peek();
|
|
491
741
|
const agent = currentAgent.peek() || 'claude';
|
|
492
|
-
|
|
742
|
+
const ok = sendWs({
|
|
743
|
+
type: 'command',
|
|
744
|
+
cmd: 'turn.start',
|
|
493
745
|
agent,
|
|
494
|
-
|
|
746
|
+
command: text,
|
|
495
747
|
sessionId: ctx.sessionId,
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
748
|
+
options: {
|
|
749
|
+
agent,
|
|
750
|
+
cwd: currentProject.peek().path,
|
|
751
|
+
sessionId: ctx.sessionId,
|
|
752
|
+
configDir: ctx.configDir,
|
|
753
|
+
model: currentModel.peek(),
|
|
754
|
+
...(effort && { effort }),
|
|
755
|
+
...(permission && permission !== 'default' && { permissionMode: permission }),
|
|
756
|
+
...(permission === 'bypassPermissions' && { allowDangerouslySkipPermissions: true }),
|
|
757
|
+
},
|
|
758
|
+
});
|
|
759
|
+
if (!ok) appendSystemMsg('连接未就绪,消息已入队,重连后发送');
|
|
502
760
|
}
|
|
503
761
|
}
|
|
504
762
|
|
|
505
763
|
export function stopProcessing() {
|
|
506
|
-
if (ctx.sessionId)
|
|
507
|
-
|
|
764
|
+
if (ctx.sessionId) {
|
|
765
|
+
sendWs({ type: 'command', cmd: 'turn.interrupt', sessionId: ctx.sessionId });
|
|
766
|
+
}
|
|
767
|
+
pendingUserText = null;
|
|
768
|
+
markTurnDone();
|
|
508
769
|
}
|
|
509
770
|
|
|
510
771
|
export function newSession() {
|
|
@@ -977,13 +1238,52 @@ function highlightCode(root) {
|
|
|
977
1238
|
// ── Init (called after HTML is in DOM) ────────────────────────────────────────
|
|
978
1239
|
export function initChat() {
|
|
979
1240
|
applyChatDensity();
|
|
1241
|
+
initWakeLock();
|
|
1242
|
+
|
|
1243
|
+
watch(wsStatus, st => {
|
|
1244
|
+
updateConnDot();
|
|
1245
|
+
syncWakeLock();
|
|
1246
|
+
const el = $('ws-banner');
|
|
1247
|
+
const text = $('ws-banner-text');
|
|
1248
|
+
if (!el) return;
|
|
1249
|
+
if (st === 'open' || st === 'idle') {
|
|
1250
|
+
el.classList.add('hidden');
|
|
1251
|
+
if (text) text.textContent = '';
|
|
1252
|
+
return;
|
|
1253
|
+
}
|
|
1254
|
+
// Show banner for reconnecting/offline; brief connecting after a drop
|
|
1255
|
+
if (st === 'connecting' && !sawDisconnect) {
|
|
1256
|
+
el.classList.add('hidden');
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
el.classList.remove('hidden');
|
|
1260
|
+
const msg = st === 'offline'
|
|
1261
|
+
? '网络已断开,恢复后会自动重连'
|
|
1262
|
+
: st === 'connecting'
|
|
1263
|
+
? '正在连接…'
|
|
1264
|
+
: '连接中断,正在重连…(熄屏或切网时常见)';
|
|
1265
|
+
if (text) text.textContent = msg;
|
|
1266
|
+
else el.textContent = msg;
|
|
1267
|
+
});
|
|
1268
|
+
|
|
1269
|
+
watch(currentProject, () => syncWakeLock());
|
|
1270
|
+
|
|
1271
|
+
delegate.on('click', '#btn-refresh-conn, #ws-banner-retry', e => {
|
|
1272
|
+
e.preventDefault();
|
|
1273
|
+
refreshConnection();
|
|
1274
|
+
});
|
|
980
1275
|
|
|
981
1276
|
// Processing state → send/stop buttons + typing indicator
|
|
1277
|
+
// Keep the input enabled so the next message can be composed (and queued).
|
|
982
1278
|
watch(isProcessing, val => {
|
|
983
1279
|
$('send-btn')?.classList.toggle('hidden', val);
|
|
984
1280
|
$('stop-btn')?.classList.toggle('hidden', !val);
|
|
985
1281
|
const input = $('chat-input');
|
|
986
|
-
if (input)
|
|
1282
|
+
if (input) {
|
|
1283
|
+
input.disabled = false;
|
|
1284
|
+
if (val) input.placeholder = '回复中… 可先写好下一条,结束后自动发送 · 或点 ■ 停止';
|
|
1285
|
+
else updateInputMode(input.value || '');
|
|
1286
|
+
}
|
|
987
1287
|
const msgs = $('messages');
|
|
988
1288
|
if (!msgs) return;
|
|
989
1289
|
const existing = $('typing-indicator');
|
|
@@ -995,6 +1295,7 @@ export function initChat() {
|
|
|
995
1295
|
existing?.remove();
|
|
996
1296
|
flushToolBatch();
|
|
997
1297
|
}
|
|
1298
|
+
syncWakeLock();
|
|
998
1299
|
});
|
|
999
1300
|
|
|
1000
1301
|
watch(chatDensity, () => applyChatDensity());
|
|
@@ -1006,9 +1307,15 @@ export function initChat() {
|
|
|
1006
1307
|
});
|
|
1007
1308
|
delegate.on('input', '#chat-input', (e, el) => { autoResize(el); updateInputMode(el.value); });
|
|
1008
1309
|
|
|
1009
|
-
// Permission buttons
|
|
1310
|
+
// Permission buttons → approval.respond (typed control plane)
|
|
1010
1311
|
delegate.on('click', '[data-perm-id]', (e, el) => {
|
|
1011
|
-
sendWs({
|
|
1312
|
+
sendWs({
|
|
1313
|
+
type: 'command',
|
|
1314
|
+
cmd: 'approval.respond',
|
|
1315
|
+
requestId: el.dataset.permId,
|
|
1316
|
+
allow: el.dataset.permAllow === '1',
|
|
1317
|
+
sessionId: ctx.sessionId,
|
|
1318
|
+
});
|
|
1012
1319
|
el.closest('.perm-banner')?.remove();
|
|
1013
1320
|
});
|
|
1014
1321
|
|