jinzd-ai-cli 0.4.304 → 0.4.305

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.
Files changed (64) hide show
  1. package/dist/{batch-RY2F7BX5.js → batch-OX27K4GG.js} +2 -2
  2. package/dist/{chunk-7L7T46PI.js → chunk-3ZABGT3S.js} +1 -1
  3. package/dist/{chunk-RWM2GFRC.js → chunk-45UVJXFQ.js} +6 -2
  4. package/dist/{chunk-6AGTDWO3.js → chunk-4DH5LZR5.js} +1 -1
  5. package/dist/{chunk-23SJVOQD.js → chunk-72AR23R2.js} +26 -5
  6. package/dist/{chunk-OECPGCHP.js → chunk-AXKERUXH.js} +3 -3
  7. package/dist/{chunk-VOIOUJXI.js → chunk-C2Z77RTU.js} +13 -5
  8. package/dist/{chunk-F3EJ4MIP.js → chunk-GOIDKTBE.js} +12 -11
  9. package/dist/{chunk-BSBGPDCX.js → chunk-I6RATYRF.js} +1 -1
  10. package/dist/{chunk-2PLYXL4P.js → chunk-IPUTC5EX.js} +12 -10
  11. package/dist/{chunk-7XU4BATV.js → chunk-KBDKKHXB.js} +15 -6
  12. package/dist/{chunk-4DEMGCK4.js → chunk-LF67HWWR.js} +37 -13
  13. package/dist/{chunk-RHCRE6XE.js → chunk-NBYSFFCO.js} +1 -1
  14. package/dist/{chunk-XIMM47EG.js → chunk-NFLE34QG.js} +1 -1
  15. package/dist/{chunk-BXP6YZ2P.js → chunk-NRSYBKWA.js} +5 -4
  16. package/dist/{chunk-IEQAE3QG.js → chunk-OAXLMF3U.js} +5 -4
  17. package/dist/{chunk-2GRGLTAK.js → chunk-PA4C452B.js} +1 -1
  18. package/dist/{chunk-I2WJ44D3.js → chunk-Q4DAKI7I.js} +1 -1
  19. package/dist/{chunk-TBTVV3KK.js → chunk-SZ4226XY.js} +1 -1
  20. package/dist/{chunk-3FI4TE5S.js → chunk-UFK5CTBU.js} +3 -3
  21. package/dist/{chunk-PPIISCFN.js → chunk-UFW4S3RU.js} +37 -13
  22. package/dist/{chunk-CKH4KQ4E.js → chunk-WQ3OMEC4.js} +6 -2
  23. package/dist/{chunk-UUSRWSSX.js → chunk-XJOK5353.js} +15 -6
  24. package/dist/{chunk-DNGUSTL6.js → chunk-YPOWDFOV.js} +1 -1
  25. package/dist/{ci-QYTLBM6Z.js → ci-IIMLOXFH.js} +4 -4
  26. package/dist/{ci-format-E5X27NC2.js → ci-format-FMBJC3EY.js} +2 -2
  27. package/dist/{constants-JDPKWZJP.js → constants-55QQ3RYE.js} +1 -1
  28. package/dist/{constants-F5WA5VPO.js → constants-7NVAPQVR.js} +1 -1
  29. package/dist/{doctor-cli-7NSEVW63.js → doctor-cli-ZHZ2XOF3.js} +4 -4
  30. package/dist/electron-server.js +232 -106
  31. package/dist/{hub-43PWTGGO.js → hub-MF53NIM2.js} +4 -4
  32. package/dist/{hub-server-RF6LURXY.js → hub-server-CBKCP5VD.js} +1 -1
  33. package/dist/index.js +24 -23
  34. package/dist/{indexer-RBEKMFT4.js → indexer-FPKY7LCT.js} +2 -2
  35. package/dist/{indexer-TNDWGFRI.js → indexer-P3FPRTPJ.js} +2 -2
  36. package/dist/{persistent-memory-A7RPDLR7.js → persistent-memory-LCOKUDTM.js} +2 -2
  37. package/dist/{persistent-memory-SHG3CJCP.js → persistent-memory-NACQW2VB.js} +2 -2
  38. package/dist/{pr-LW755EL7.js → pr-NHWE5FW7.js} +4 -4
  39. package/dist/{run-tests-MJ3SCW27.js → run-tests-EUPJLDXS.js} +2 -2
  40. package/dist/{run-tests-HV37CM5U.js → run-tests-ZG5X25BJ.js} +2 -2
  41. package/dist/{semantic-HLAE2O4F.js → semantic-7GM5M4MU.js} +3 -3
  42. package/dist/{semantic-VPNKXKKK.js → semantic-QOBAXEQ4.js} +3 -3
  43. package/dist/{server-EZ5ULLDP.js → server-PG7QCH53.js} +193 -82
  44. package/dist/{server-WI3Q4EDH.js → server-PNYRE3AL.js} +9 -9
  45. package/dist/{store-MWNHVGJT.js → store-CMSSWZF7.js} +3 -1
  46. package/dist/{store-VO37H6LS.js → store-SIKHCUYJ.js} +3 -1
  47. package/dist/{task-orchestrator-PWW5I2KE.js → task-orchestrator-WAJTCE6D.js} +9 -9
  48. package/dist/{usage-MAIV6XUZ.js → usage-DOES3DOS.js} +2 -2
  49. package/dist/{vector-store-JBAE6PS4.js → vector-store-JAXWDZYW.js} +1 -1
  50. package/dist/{vector-store-BBDXB5IQ.js → vector-store-PWIOQFTP.js} +1 -1
  51. package/dist/web/client/app.js +135 -60
  52. package/dist/web/client/app.js.br +0 -0
  53. package/dist/web/client/app.js.gz +0 -0
  54. package/dist/web/client/chat-input.js +38 -0
  55. package/dist/web/client/chat-input.js.br +0 -0
  56. package/dist/web/client/chat-input.js.gz +0 -0
  57. package/dist/web/client/index.html +1 -1
  58. package/dist/web/client/index.html.br +0 -0
  59. package/dist/web/client/index.html.gz +0 -0
  60. package/dist/web/client/stream-render.js +33 -0
  61. package/dist/web/client/style.css +2 -1
  62. package/dist/web/client/style.css.br +0 -0
  63. package/dist/web/client/style.css.gz +0 -0
  64. package/package.json +1 -1
@@ -1,20 +1,20 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  ToolRegistry
4
- } from "./chunk-2PLYXL4P.js";
4
+ } from "./chunk-IPUTC5EX.js";
5
5
  import "./chunk-GHWZVPUV.js";
6
- import "./chunk-7L7T46PI.js";
7
- import "./chunk-4DEMGCK4.js";
8
- import "./chunk-UUSRWSSX.js";
9
- import "./chunk-CKH4KQ4E.js";
10
- import "./chunk-BXP6YZ2P.js";
6
+ import "./chunk-3ZABGT3S.js";
7
+ import "./chunk-LF67HWWR.js";
8
+ import "./chunk-XJOK5353.js";
9
+ import "./chunk-WQ3OMEC4.js";
10
+ import "./chunk-NRSYBKWA.js";
11
11
  import "./chunk-KHYD3WXE.js";
12
12
  import "./chunk-5C76XDDF.js";
13
13
  import "./chunk-NYNSYLFY.js";
14
14
  import "./chunk-3FEZSLGI.js";
15
15
  import {
16
16
  runTool
17
- } from "./chunk-I2WJ44D3.js";
17
+ } from "./chunk-Q4DAKI7I.js";
18
18
  import "./chunk-RS7SKYTS.js";
19
19
  import {
20
20
  getDangerLevel,
@@ -24,10 +24,10 @@ import "./chunk-TU3L3PW7.js";
24
24
  import "./chunk-HOSJZMQS.js";
25
25
  import "./chunk-IW3Q7AE5.js";
26
26
  import "./chunk-4BKXL7SM.js";
27
- import "./chunk-BSBGPDCX.js";
27
+ import "./chunk-I6RATYRF.js";
28
28
  import {
29
29
  VERSION
30
- } from "./chunk-RHCRE6XE.js";
30
+ } from "./chunk-NBYSFFCO.js";
31
31
 
32
32
  // src/mcp/server.ts
33
33
  import { createInterface } from "readline";
@@ -5,8 +5,9 @@ import {
5
5
  loadIndex,
6
6
  removeFile,
7
7
  saveIndex,
8
+ symbolFingerprint,
8
9
  upsertFileSymbols
9
- } from "./chunk-CKH4KQ4E.js";
10
+ } from "./chunk-WQ3OMEC4.js";
10
11
  import "./chunk-IW3Q7AE5.js";
11
12
  export {
12
13
  clearIndex,
@@ -14,5 +15,6 @@ export {
14
15
  loadIndex,
15
16
  removeFile,
16
17
  saveIndex,
18
+ symbolFingerprint,
17
19
  upsertFileSymbols
18
20
  };
@@ -4,8 +4,9 @@ import {
4
4
  loadIndex,
5
5
  removeFile,
6
6
  saveIndex,
7
+ symbolFingerprint,
7
8
  upsertFileSymbols
8
- } from "./chunk-RWM2GFRC.js";
9
+ } from "./chunk-45UVJXFQ.js";
9
10
  import "./chunk-6BUTA5VW.js";
10
11
  export {
11
12
  clearIndex,
@@ -13,5 +14,6 @@ export {
13
14
  loadIndex,
14
15
  removeFile,
15
16
  saveIndex,
17
+ symbolFingerprint,
16
18
  upsertFileSymbols
17
19
  };
@@ -4,20 +4,20 @@ import {
4
4
  googleSearchContext,
5
5
  isToolCapableProvider,
6
6
  truncateOutput
7
- } from "./chunk-2PLYXL4P.js";
7
+ } from "./chunk-IPUTC5EX.js";
8
8
  import "./chunk-GHWZVPUV.js";
9
- import "./chunk-7L7T46PI.js";
10
- import "./chunk-4DEMGCK4.js";
11
- import "./chunk-UUSRWSSX.js";
12
- import "./chunk-CKH4KQ4E.js";
13
- import "./chunk-BXP6YZ2P.js";
9
+ import "./chunk-3ZABGT3S.js";
10
+ import "./chunk-LF67HWWR.js";
11
+ import "./chunk-XJOK5353.js";
12
+ import "./chunk-WQ3OMEC4.js";
13
+ import "./chunk-NRSYBKWA.js";
14
14
  import "./chunk-KHYD3WXE.js";
15
15
  import "./chunk-5C76XDDF.js";
16
16
  import "./chunk-NYNSYLFY.js";
17
17
  import "./chunk-3FEZSLGI.js";
18
18
  import {
19
19
  runTool
20
- } from "./chunk-I2WJ44D3.js";
20
+ } from "./chunk-Q4DAKI7I.js";
21
21
  import "./chunk-RS7SKYTS.js";
22
22
  import {
23
23
  getDangerLevel,
@@ -29,10 +29,10 @@ import {
29
29
  atomicWriteFileSync
30
30
  } from "./chunk-IW3Q7AE5.js";
31
31
  import "./chunk-4BKXL7SM.js";
32
- import "./chunk-BSBGPDCX.js";
32
+ import "./chunk-I6RATYRF.js";
33
33
  import {
34
34
  SUBAGENT_ALLOWED_TOOLS
35
- } from "./chunk-RHCRE6XE.js";
35
+ } from "./chunk-NBYSFFCO.js";
36
36
 
37
37
  // src/hub/task-orchestrator.ts
38
38
  import { createInterface } from "readline";
@@ -9,10 +9,10 @@ import {
9
9
  import "./chunk-RS7SKYTS.js";
10
10
  import {
11
11
  ConfigManager
12
- } from "./chunk-XIMM47EG.js";
12
+ } from "./chunk-NFLE34QG.js";
13
13
  import "./chunk-TU3L3PW7.js";
14
14
  import "./chunk-IW3Q7AE5.js";
15
- import "./chunk-RHCRE6XE.js";
15
+ import "./chunk-NBYSFFCO.js";
16
16
 
17
17
  // src/cli/usage.ts
18
18
  function classifyRowCost(row) {
@@ -4,7 +4,7 @@ import {
4
4
  loadVectorStore,
5
5
  saveVectorStore,
6
6
  searchVectorStore
7
- } from "./chunk-IEQAE3QG.js";
7
+ } from "./chunk-OAXLMF3U.js";
8
8
  import "./chunk-JV5N65KN.js";
9
9
  import "./chunk-6BUTA5VW.js";
10
10
  export {
@@ -5,7 +5,7 @@ import {
5
5
  loadVectorStore,
6
6
  saveVectorStore,
7
7
  searchVectorStore
8
- } from "./chunk-BXP6YZ2P.js";
8
+ } from "./chunk-NRSYBKWA.js";
9
9
  import "./chunk-KHYD3WXE.js";
10
10
  import "./chunk-IW3Q7AE5.js";
11
11
  export {
@@ -9,6 +9,21 @@
9
9
 
10
10
  import { escapeHtml, formatToolArgs, formatDuration, sanitizeHtml, formatCostDisplay } from './util.js';
11
11
  import { fetchSessionReplay, fetchStatus, logoutAuth, registerFirstUser, submitAuth } from './api.js';
12
+ import { resolveFileReferences, messageBytes, quoteFileReference } from './chat-input.js';
13
+ import { appendStreamText, finishStreamText, flushStreamText } from './stream-render.js';
14
+
15
+ let sendingMessage = false;
16
+ let retrySubmission = null;
17
+ let chatLimits = { maxChatBytes: 1024 * 1024, maxImageBytes: 10 * 1024 * 1024 };
18
+ const chatAcks = new Map();
19
+ let lastPong = 0;
20
+ let pendingPing = 0;
21
+ let reconnectTimer = null;
22
+ function scheduleReconnect() {
23
+ if (reconnectTimer !== null) return;
24
+ reconnectTimer = setTimeout(() => { reconnectTimer = null; connect(); }, reconnectDelay);
25
+ reconnectDelay = Math.min(reconnectDelay * 2, 10000);
26
+ }
12
27
 
13
28
  // ── State ──────────────────────────────────────────────────────────
14
29
  // All cross-cutting mutable state now lives in state.js (P0-4 Phase 1),
@@ -47,21 +62,35 @@ function connect() {
47
62
  const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
48
63
  // Security: do NOT put token in URL or WebSocket JSON. Cookie is sent automatically.
49
64
  ws = new WebSocket(`${protocol}//${location.host}?tabId=${tabId}`);
65
+ const socket = ws;
50
66
 
51
67
  ws.onopen = () => {
68
+ if (ws !== socket) return;
69
+ lastPong = Date.now();
70
+ pendingPing = 0;
52
71
  connected = true;
53
72
  reconnectDelay = 1000; // Reset backoff on success
54
73
  connectionStatus.textContent = '🟢 Connected';
55
74
  connectionStatus.className = 'status-connected';
56
75
  requestSessionList();
57
76
  checkAuthStatus();
58
- // Start heartbeat ping every 30s
77
+ // Detect half-open sockets even when TCP has not delivered a close event.
59
78
  clearInterval(heartbeatTimer);
60
79
  heartbeatTimer = setInterval(() => {
61
- if (ws && ws.readyState === WebSocket.OPEN) {
80
+ const now = Date.now();
81
+ if (pendingPing && now - pendingPing >= 30000) {
82
+ ws.close(4000, 'Heartbeat timeout');
83
+ connected = false;
84
+ connectionStatus.textContent = '🔴 Connection timed out — reconnecting...';
85
+ clearInterval(heartbeatTimer);
86
+ scheduleReconnect();
87
+ return;
88
+ }
89
+ if (ws && ws.readyState === WebSocket.OPEN && !pendingPing && now - lastPong >= 15000) {
90
+ pendingPing = now;
62
91
  ws.send(JSON.stringify({ type: 'ping' }));
63
92
  }
64
- }, 30000);
93
+ }, 5000);
65
94
  // If we had a session & were processing, reset UI state
66
95
  if (processing) {
67
96
  setProcessing(false);
@@ -90,24 +119,28 @@ function connect() {
90
119
  };
91
120
 
92
121
  ws.onclose = () => {
122
+ if (ws !== socket) return;
93
123
  connected = false;
94
124
  clearInterval(heartbeatTimer);
125
+ for (const ack of chatAcks.values()) ack.reject(new Error('Disconnected before delivery was confirmed. Your draft was kept; reconnect and retry.'));
126
+ chatAcks.clear();
95
127
  connectionStatus.textContent = '🔴 Disconnected — reconnecting...';
96
128
  connectionStatus.className = 'status-disconnected';
97
129
  // Exponential backoff: 1s, 2s, 4s, max 10s
98
- setTimeout(connect, reconnectDelay);
99
- reconnectDelay = Math.min(reconnectDelay * 2, 10000);
130
+ scheduleReconnect();
100
131
  };
101
132
 
102
133
  ws.onerror = () => {
134
+ if (ws !== socket) return;
103
135
  connectionStatus.textContent = '🔴 Error';
104
136
  connectionStatus.className = 'status-disconnected';
105
137
  };
106
138
 
107
139
  ws.onmessage = (event) => {
140
+ if (ws !== socket) return;
108
141
  try {
109
142
  const msg = JSON.parse(event.data);
110
- if (msg.type === 'pong') return; // Heartbeat response, ignore
143
+ if (msg.type === 'pong') { lastPong = Date.now(); pendingPing = 0; return; }
111
144
  handleServerMessage(msg);
112
145
  } catch (e) {
113
146
  console.error('Failed to parse message:', e);
@@ -117,8 +150,9 @@ function connect() {
117
150
 
118
151
  export function send(msg) {
119
152
  if (ws && ws.readyState === WebSocket.OPEN) {
120
- ws.send(JSON.stringify(msg));
153
+ try { ws.send(JSON.stringify(msg)); return true; } catch { return false; }
121
154
  }
155
+ return false;
122
156
  }
123
157
 
124
158
  // ── Message handlers ───────────────────────────────────────────────
@@ -132,6 +166,11 @@ window.handleServerMessage = handleServerMessage;
132
166
 
133
167
  function handleServerMessage(msg) {
134
168
  switch (msg.type) {
169
+ case 'chat_ack': {
170
+ const waiting = chatAcks.get(msg.messageId);
171
+ if (waiting) { chatAcks.delete(msg.messageId); waiting.resolve(msg); }
172
+ break;
173
+ }
135
174
  case 'text_delta': handleTextDelta(msg.delta); break;
136
175
  case 'response_done': handleResponseDone(msg); break;
137
176
  case 'tool_call_start': handleToolCallStart(msg); break;
@@ -173,7 +212,7 @@ function handleTextDelta(delta) {
173
212
  currentAssistantContent = '';
174
213
  }
175
214
  currentAssistantContent += delta;
176
- renderMarkdown(currentAssistantEl, currentAssistantContent);
215
+ appendStreamText(currentAssistantEl, delta);
177
216
  currentAssistantEl.classList.add('streaming-cursor');
178
217
  scrollToBottom();
179
218
  }
@@ -181,9 +220,7 @@ function handleTextDelta(delta) {
181
220
  function handleResponseDone(msg) {
182
221
  if (currentAssistantEl) {
183
222
  currentAssistantEl.classList.remove('streaming-cursor');
184
- if (msg.content) {
185
- renderMarkdown(currentAssistantEl, msg.content);
186
- }
223
+ renderMarkdown(currentAssistantEl, msg.content ?? currentAssistantContent);
187
224
  } else if (msg.content) {
188
225
  const el = createAssistantMessage();
189
226
  renderMarkdown(el, msg.content);
@@ -792,6 +829,7 @@ export function addErrorMessage(text) {
792
829
  }
793
830
 
794
831
  function renderMarkdown(el, text) {
832
+ finishStreamText(el);
795
833
  try {
796
834
  // P1-SEC-01: sanitize AI-generated HTML before innerHTML insertion
797
835
  el.innerHTML = sanitizeHtml(marked.parse(text));
@@ -802,11 +840,6 @@ function renderMarkdown(el, text) {
802
840
  const btn = document.createElement('button');
803
841
  btn.className = 'copy-code-btn btn btn-ghost btn-xs';
804
842
  btn.textContent = '📋 Copy';
805
- btn.addEventListener('click', () => {
806
- navigator.clipboard.writeText(block.textContent || '');
807
- btn.textContent = 'Copied';
808
- setTimeout(() => btn.textContent = 'Copy', 1500);
809
- });
810
843
  pre.style.position = 'relative';
811
844
  pre.appendChild(btn);
812
845
  }
@@ -817,6 +850,20 @@ function renderMarkdown(el, text) {
817
850
  }
818
851
 
819
852
  // escapeHtml / formatToolArgs now live in util.js (imported at top).
853
+ // Delegation survives innerHTML snapshots and off-screen history restoration.
854
+ document.addEventListener('click', async event => {
855
+ const button = event.target.closest?.('.copy-code-btn');
856
+ if (!button) return;
857
+ const code = button.closest('pre')?.querySelector('code');
858
+ try {
859
+ await navigator.clipboard.writeText(code?.textContent || '');
860
+ button.textContent = 'Copied';
861
+ } catch {
862
+ button.textContent = 'Copy failed';
863
+ addInfoMessage('Clipboard unavailable. Select the code and copy it manually.');
864
+ }
865
+ setTimeout(() => { button.textContent = '📋 Copy'; }, 1500);
866
+ });
820
867
 
821
868
  /**
822
869
  * Create a static tool-call card for session history view.
@@ -861,13 +908,22 @@ function createHistoryToolCards(assistantMsg, resultMsgs) {
861
908
  }
862
909
  }
863
910
 
911
+ let scrollFrame = null;
864
912
  function scrollToBottom() {
865
- requestAnimationFrame(() => {
913
+ if (scrollFrame !== null) return;
914
+ scrollFrame = requestAnimationFrame(() => {
915
+ scrollFrame = null;
866
916
  chatArea.scrollTop = chatArea.scrollHeight;
867
917
  });
868
918
  }
869
919
 
870
920
  function setProcessing(value) {
921
+ if (!value && currentAssistantEl) {
922
+ renderMarkdown(currentAssistantEl, currentAssistantContent);
923
+ currentAssistantEl.classList.remove('streaming-cursor');
924
+ currentAssistantEl = null;
925
+ currentAssistantContent = '';
926
+ }
871
927
  processing = value;
872
928
  // Sync tab processing indicator
873
929
  if (activeTabIdx >= 0 && activeTabIdx < sessionTabs.length) {
@@ -909,7 +965,7 @@ function updateModelSelect(providerId, currentModelId) {
909
965
 
910
966
  async function sendMessage() {
911
967
  const text = userInput.value.trim();
912
- if (!text || !connected) return;
968
+ if (!text || !connected || sendingMessage) return;
913
969
 
914
970
  hideFileDropdown();
915
971
 
@@ -939,46 +995,53 @@ async function sendMessage() {
939
995
  return;
940
996
  }
941
997
 
942
- // Resolve @file references: replace @path with file content
943
- let resolvedText = text;
944
- const atRefs = text.match(/@([\w./_-]+[\w._-])/g);
945
- if (atRefs) {
946
- for (const ref of atRefs) {
947
- const filePath = ref.slice(1); // remove @
948
- try {
949
- const resp = await fetch(`/api/file-content?path=${encodeURIComponent(filePath)}`);
998
+ sendingMessage = true;
999
+ const images = pendingImages.slice();
1000
+ const originTab = sessionTabs[activeTabIdx];
1001
+ const originSessionId = originTab?.sessionId;
1002
+ try {
1003
+ const key = JSON.stringify({ text, images });
1004
+ let msg = retrySubmission?.key === key ? retrySubmission.msg : null;
1005
+ if (!msg) {
1006
+ const content = await resolveFileReferences(text, async (path, signal) => {
1007
+ const resp = await fetch(`/api/file-content?path=${encodeURIComponent(path)}`, { signal });
950
1008
  const data = await resp.json();
951
- if (data.content) {
952
- resolvedText = resolvedText.replace(ref,
953
- `[File: ${filePath}]\n\`\`\`\n${data.content}\n\`\`\``);
954
- } else if (data.error) {
955
- addInfoMessage(`⚠ ${ref}: ${data.error}`);
956
- }
957
- } catch {
958
- addInfoMessage(`⚠ Failed to read ${ref}`);
959
- }
1009
+ if (!resp.ok || typeof data.content !== 'string') throw new Error(`${path}: ${data.error || 'Could not read file'}`);
1010
+ return data.content;
1011
+ }, AbortSignal.timeout(30000));
1012
+ msg = { type: 'chat', messageId: `${tabId}-${Date.now()}-${Math.random().toString(36).slice(2)}`, content, images };
960
1013
  }
961
- }
962
-
963
- // Track input history
964
- if (inputHistory[inputHistory.length - 1] !== text) {
965
- inputHistory.push(text);
1014
+ if (sessionTabs[activeTabIdx] !== originTab || originTab?.sessionId !== originSessionId) throw new Error('Session changed while reading files. Review your draft and retry.');
1015
+ if (messageBytes(msg) > chatLimits.maxChatBytes) throw new Error(`Message and attachments exceed ${(chatLimits.maxChatBytes / 1024 / 1024).toFixed(0)} MiB. Remove an attachment or shorten the message. Your draft was kept.`);
1016
+ retrySubmission = { key, msg };
1017
+ const ack = await new Promise((resolve, reject) => {
1018
+ const timer = setTimeout(() => { chatAcks.delete(msg.messageId); reject(new Error('Delivery was not confirmed. Your draft was kept; retry sends the same message ID.')); }, 15000);
1019
+ chatAcks.set(msg.messageId, {
1020
+ resolve: value => { clearTimeout(timer); resolve(value); },
1021
+ reject: error => { clearTimeout(timer); reject(error); },
1022
+ });
1023
+ if (!send(msg)) {
1024
+ chatAcks.get(msg.messageId).reject(new Error('Not connected. Your draft was kept.'));
1025
+ chatAcks.delete(msg.messageId);
1026
+ }
1027
+ });
1028
+ if (!ack.accepted) { retrySubmission = null; throw new Error(ack.error || 'Message was not accepted.'); }
1029
+ retrySubmission = null;
1030
+ if (!ack.duplicate) { addUserMessage(text, images); setProcessing(true); }
1031
+ else { send({ type: 'command', name: 'session', args: ['load', ack.sessionId] }); }
1032
+ if (userInput.value.trim() === text) userInput.value = '';
1033
+ // Do not discard attachments pasted while the original request was resolving.
1034
+ const remaining = pendingImages.filter(img => !images.includes(img));
1035
+ if (remaining.length === 0) clearImagePreview();
1036
+ else pendingImages = remaining;
1037
+ userInput.style.height = 'auto';
1038
+ if (inputHistory[inputHistory.length - 1] !== text) inputHistory.push(text);
966
1039
  if (inputHistory.length > 50) inputHistory.shift();
967
- }
968
- historyIndex = -1;
969
- savedInputDraft = '';
970
-
971
- addUserMessage(text, pendingImages);
972
- const msg = { type: 'chat', content: resolvedText };
973
- if (pendingImages.length > 0) {
974
- msg.images = pendingImages.map(img => ({ name: img.name, data: img.data, mime: img.mime }));
975
- }
976
- send(msg);
977
- pendingImages = [];
978
- clearImagePreview();
979
- userInput.value = '';
980
- userInput.style.height = 'auto';
981
- setProcessing(true);
1040
+ historyIndex = -1;
1041
+ savedInputDraft = '';
1042
+ } catch (error) {
1043
+ addInfoMessage(`⚠ ${error.message}`);
1044
+ } finally { sendingMessage = false; }
982
1045
  }
983
1046
 
984
1047
  btnSend.addEventListener('click', sendMessage);
@@ -990,6 +1053,7 @@ btnStop.addEventListener('click', () => {
990
1053
  // keydown and input handlers are in the @ file reference section above
991
1054
 
992
1055
  btnClear.addEventListener('click', () => {
1056
+ if (processing || sendingMessage) { addInfoMessage('Stop the current request before clearing the conversation.'); return; }
993
1057
  send({ type: 'command', name: 'clear', args: [] });
994
1058
  // Keep welcome message, clear rest
995
1059
  const welcome = messagesEl.querySelector('.chat');
@@ -1847,8 +1911,9 @@ function selectFileItem(index) {
1847
1911
  fetchFiles(item.path).then(files => showFileDropdown(files));
1848
1912
  } else {
1849
1913
  // Insert file reference and close
1850
- userInput.value = before + '@' + item.path + ' ' + after;
1851
- const newPos = before.length + 1 + item.path.length + 1;
1914
+ const reference = quoteFileReference(item.path);
1915
+ userInput.value = before + reference + ' ' + after;
1916
+ const newPos = before.length + reference.length + 1;
1852
1917
  userInput.setSelectionRange(newPos, newPos);
1853
1918
  hideFileDropdown();
1854
1919
  }
@@ -2004,8 +2069,9 @@ function clearImagePreview() {
2004
2069
  }
2005
2070
 
2006
2071
  function addImageToPreview(file) {
2072
+ if (sendingMessage) { addInfoMessage('Wait for the current message to be accepted before adding attachments.'); return; }
2007
2073
  if (!file.type.startsWith('image/')) return;
2008
- if (file.size > 10 * 1024 * 1024) {
2074
+ if (file.size > chatLimits.maxImageBytes) {
2009
2075
  addErrorMessage(`Image too large (${(file.size / 1024 / 1024).toFixed(1)} MB, max 10 MB): ${file.name}`);
2010
2076
  return;
2011
2077
  }
@@ -2214,7 +2280,7 @@ export function insertFileRef(filePath) {
2214
2280
  const pos = userInput.selectionStart;
2215
2281
  const before = val.slice(0, pos);
2216
2282
  const after = val.slice(pos);
2217
- const ref = `@${filePath}`;
2283
+ const ref = quoteFileReference(filePath);
2218
2284
  const needsSpace = after.length > 0 && !after.startsWith(' ') && !after.startsWith('\n');
2219
2285
  userInput.value = before + ref + (needsSpace ? ' ' : '') + after;
2220
2286
  const newPos = pos + ref.length + (needsSpace ? 1 : 0);
@@ -2272,6 +2338,7 @@ function generateTabId() {
2272
2338
 
2273
2339
  /** Save current active tab's DOM state (messages, scroll, streaming state) */
2274
2340
  function snapshotActiveTab() {
2341
+ flushStreamText();
2275
2342
  if (activeTabIdx < 0 || activeTabIdx >= sessionTabs.length) return;
2276
2343
  const tab = sessionTabs[activeTabIdx];
2277
2344
  tab.messagesHtml = messagesEl.innerHTML;
@@ -2329,6 +2396,7 @@ function renderTabBar() {
2329
2396
 
2330
2397
  /** Add a new tab. If sessionId provided, load that session; otherwise create new. */
2331
2398
  function addTab(sessionId, title) {
2399
+ if (processing || sendingMessage) { addInfoMessage('Stop the current request before opening another session.'); return; }
2332
2400
  // Snapshot current tab before adding
2333
2401
  snapshotActiveTab();
2334
2402
 
@@ -2370,6 +2438,7 @@ function addTab(sessionId, title) {
2370
2438
 
2371
2439
  /** Switch to an existing tab by index */
2372
2440
  function switchToTab(index) {
2441
+ if (processing || sendingMessage) { addInfoMessage('Wait for the current send, or stop generation before switching sessions.'); return; }
2373
2442
  if (index === activeTabIdx || index < 0 || index >= sessionTabs.length) return;
2374
2443
 
2375
2444
  // Snapshot current
@@ -2398,6 +2467,7 @@ function switchToTab(index) {
2398
2467
 
2399
2468
  /** Close a tab */
2400
2469
  function closeTab(index) {
2470
+ if (processing || sendingMessage) { addInfoMessage('Stop the current request before closing a session.'); return; }
2401
2471
  if (sessionTabs.length <= 1) return; // Must keep at least 1 tab
2402
2472
 
2403
2473
  sessionTabs.splice(index, 1);
@@ -2577,6 +2647,7 @@ if (window.visualViewport) {
2577
2647
  let initialHeight = window.visualViewport.height;
2578
2648
 
2579
2649
  window.visualViewport.addEventListener('resize', () => {
2650
+ if (window.visualViewport.scale !== 1) return;
2580
2651
  const vh = window.visualViewport.height;
2581
2652
  const offset = window.visualViewport.offsetTop;
2582
2653
  // When keyboard opens, viewport height shrinks significantly
@@ -2811,6 +2882,7 @@ export async function handleLogout() {
2811
2882
  async function checkAuthStatus() {
2812
2883
  try {
2813
2884
  const data = await fetchStatus();
2885
+ if (data.chatLimits) chatLimits = data.chatLimits;
2814
2886
  const btn = document.getElementById('btn-enable-auth');
2815
2887
  if (data.auth && !data.auth.enabled && !authUsername) {
2816
2888
  btn.classList.remove('hidden');
@@ -3212,15 +3284,17 @@ function hubEvent(ev) {
3212
3284
  hubStreamEl._raw = '';
3213
3285
  }
3214
3286
  hubStreamEl._raw += ev.token;
3215
- renderMarkdown(hubStreamEl.querySelector('.hub-body'), hubStreamEl._raw);
3287
+ appendStreamText(hubStreamEl.querySelector('.hub-body'), ev.token);
3216
3288
  scrollToBottom();
3217
3289
  break;
3218
3290
  }
3219
3291
  case 'agent_spoke':
3292
+ if (hubStreamEl) renderMarkdown(hubStreamEl.querySelector('.hub-body'), ev.message?.content ?? hubStreamEl._raw);
3220
3293
  // Streaming already rendered the body; finalize the stream slot.
3221
3294
  hubStreamEl = null; hubStreamRoleId = null;
3222
3295
  break;
3223
3296
  case 'agent_passed': {
3297
+ if (hubStreamEl) { finishStreamText(hubStreamEl.querySelector('.hub-body')); hubStreamEl.remove(); }
3224
3298
  const role = hubRoles[ev.roleId] || { name: ev.roleId };
3225
3299
  const el = document.createElement('div');
3226
3300
  el.className = 'text-xs opacity-40 italic pl-2 my-1';
@@ -3262,6 +3336,7 @@ function hubEvent(ev) {
3262
3336
  }
3263
3337
 
3264
3338
  function hubDone(msg) {
3339
+ if (hubStreamEl) renderMarkdown(hubStreamEl.querySelector('.hub-body'), hubStreamEl._raw);
3265
3340
  document.getElementById('hub-start-btn').classList.remove('hidden');
3266
3341
  document.getElementById('hub-abort-btn').classList.add('hidden');
3267
3342
  const status = document.getElementById('hub-status');
Binary file
Binary file
@@ -0,0 +1,38 @@
1
+ /** Unicode paths; quote paths containing spaces, e.g. @"教案/第一课.md". */
2
+ export function fileReferences(text) {
3
+ const refs = [];
4
+ const pattern = /(^|\s)@(?:"([^"]+)"|'([^']+)'|([^\s<>"']+))/gu;
5
+ for (const match of text.matchAll(pattern)) {
6
+ const token = match[0].slice(match[1].length);
7
+ refs.push({ token, path: match[2] ?? match[3] ?? match[4], offset: match.index + match[1].length });
8
+ }
9
+ return refs;
10
+ }
11
+
12
+ export function quoteFileReference(path) {
13
+ return /\s|["']/.test(path) ? `@${path.includes('"') ? `'${path}'` : `"${path}"`}` : `@${path}`;
14
+ }
15
+
16
+ export async function resolveFileReferences(text, fetchFile, signal) {
17
+ const refs = fileReferences(text);
18
+ const paths = [...new Set(refs.map(ref => ref.path))];
19
+ const contents = new Map();
20
+ let next = 0;
21
+ await Promise.all(Array.from({ length: Math.min(4, paths.length) }, async () => {
22
+ while (next < paths.length) {
23
+ signal?.throwIfAborted();
24
+ const path = paths[next++];
25
+ contents.set(path, await fetchFile(path, signal));
26
+ }
27
+ }));
28
+ let resolved = text;
29
+ for (const ref of refs.reverse()) {
30
+ const replacement = `[File: ${ref.path}]\n\`\`\`\n${contents.get(ref.path)}\n\`\`\``;
31
+ resolved = resolved.slice(0, ref.offset) + replacement + resolved.slice(ref.offset + ref.token.length);
32
+ }
33
+ return resolved;
34
+ }
35
+
36
+ export function messageBytes(message) {
37
+ return new TextEncoder().encode(JSON.stringify(message)).byteLength;
38
+ }
Binary file
Binary file
@@ -2,7 +2,7 @@
2
2
  <html lang="en" data-theme="dark">
3
3
  <head>
4
4
  <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
6
6
  <title>ai-cli Web UI</title>
7
7
  <!-- PWA -->
8
8
  <link rel="manifest" href="manifest.json">
Binary file
Binary file
@@ -0,0 +1,33 @@
1
+ // Stream text in one append per frame; format Markdown once a response completes.
2
+ const pending = new Map();
3
+ let frame = null;
4
+ function flush() {
5
+ frame = null;
6
+ for (const [el, state] of pending) {
7
+ state.node.appendData(state.delta);
8
+ state.delta = '';
9
+ if (!el.isConnected) pending.delete(el);
10
+ }
11
+ }
12
+ export function appendStreamText(el, delta) {
13
+ let state = pending.get(el);
14
+ if (!state || state.node.parentNode !== el) {
15
+ const node = document.createTextNode('');
16
+ el.replaceChildren(node);
17
+ el.classList.add('streaming-text');
18
+ state = { node, delta: '' };
19
+ pending.set(el, state);
20
+ }
21
+ state.delta += delta;
22
+ if (frame === null) frame = requestAnimationFrame(flush);
23
+ }
24
+ export function finishStreamText(el) {
25
+ const state = pending.get(el);
26
+ if (state) state.node.appendData(state.delta);
27
+ pending.delete(el);
28
+ el.classList.remove('streaming-text');
29
+ }
30
+ export function flushStreamText() {
31
+ if (frame !== null) cancelAnimationFrame(frame);
32
+ flush();
33
+ }
@@ -1050,4 +1050,5 @@ button, a, .session-item, .file-tree-row, .template-item, .tool-item, .mcp-serve
1050
1050
  }
1051
1051
  .diff-file-details .confirm-diff { margin: 0; border-radius: 0; max-height: 180px; }
1052
1052
  .memory-meta-badges { display: flex; flex-wrap: wrap; gap: 0.25rem; margin-bottom: 0.35rem; }
1053
- .replay-step.hidden-by-filter { display: none; }
1053
+ .replay-step.hidden-by-filter { display: none; }
1054
+ .streaming-text { white-space: pre-wrap; overflow-wrap: anywhere; }