@electerm/electerm-react 5.5.25 → 5.5.56

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 (66) hide show
  1. package/README.md +5 -5
  2. package/client/common/bookmark-schemas.js +4 -0
  3. package/client/common/constants.js +3 -0
  4. package/client/common/create-title.jsx +5 -1
  5. package/client/common/default-setting.js +2 -0
  6. package/client/common/parse-quick-connect.js +1 -0
  7. package/client/common/parse-vv.js +119 -25
  8. package/client/common/parse-widget-log.js +57 -0
  9. package/client/common/vv-to-tab.js +120 -0
  10. package/client/components/ai/agent-tool-call-card.jsx +26 -7
  11. package/client/components/ai/agent.js +205 -45
  12. package/client/components/ai/ai-auto-compress.js +119 -0
  13. package/client/components/ai/ai-chat-history-item.jsx +133 -84
  14. package/client/components/ai/ai-chat.jsx +135 -30
  15. package/client/components/ai/ai-compress.js +95 -0
  16. package/client/components/ai/ai-config-props.js +12 -1
  17. package/client/components/ai/ai-config.jsx +43 -16
  18. package/client/components/ai/ai-config.styl +20 -0
  19. package/client/components/ai/ai-context-indicator.jsx +83 -0
  20. package/client/components/ai/ai-context.js +272 -0
  21. package/client/components/ai/ai-output.jsx +5 -6
  22. package/client/components/ai/ai-presets.js +1 -16
  23. package/client/components/ai/ai.styl +105 -6
  24. package/client/components/ai/copy-icon.jsx +31 -0
  25. package/client/components/batch-op/batch-op-runner.jsx +1 -0
  26. package/client/components/bookmark-form/bookmark-schema.js +1 -0
  27. package/client/components/bookmark-form/common/ssh-agent.jsx +31 -21
  28. package/client/components/bookmark-form/common/vv-file-field.jsx +3 -2
  29. package/client/components/bookmark-form/config/spice.js +21 -0
  30. package/client/components/bookmark-form/config/ssh.js +1 -0
  31. package/client/components/bookmark-form/fix-bookmark-default.js +1 -0
  32. package/client/components/file-transfer/transfer.jsx +3 -119
  33. package/client/components/footer/cmd-history.jsx +279 -65
  34. package/client/components/footer/cmd-history.styl +68 -1
  35. package/client/components/footer/footer-entry.jsx +7 -1
  36. package/client/components/layout/layout.jsx +12 -19
  37. package/client/components/main/main.jsx +15 -1
  38. package/client/components/quick-commands/qm.styl +58 -0
  39. package/client/components/quick-commands/quick-commands-box.jsx +180 -83
  40. package/client/components/quick-commands/quick-commands-select.jsx +11 -0
  41. package/client/components/session/session-control.jsx +35 -2
  42. package/client/components/session/session-control.styl +5 -0
  43. package/client/components/session/session.jsx +13 -0
  44. package/client/components/setting-panel/setting-common.jsx +2 -0
  45. package/client/components/setting-panel/tab-widgets.jsx +27 -7
  46. package/client/components/sftp/file-item.jsx +0 -13
  47. package/client/components/side-panel-r/side-panel-r.jsx +11 -4
  48. package/client/components/sidebar/bookmark-select.jsx +14 -2
  49. package/client/components/sidebar/history-item.jsx +20 -4
  50. package/client/components/sidebar/history.jsx +7 -0
  51. package/client/components/terminal/mixins/term-touch.js +18 -0
  52. package/client/components/tree-list/tree-list.jsx +31 -3
  53. package/client/components/widgets/widget-control.jsx +4 -2
  54. package/client/components/widgets/widget-instance-detail.jsx +370 -0
  55. package/client/components/widgets/widget-instance.jsx +30 -13
  56. package/client/components/widgets/widget-instances.jsx +3 -1
  57. package/client/components/widgets/widgets-list.jsx +8 -0
  58. package/client/components/widgets/widgets.styl +100 -0
  59. package/client/store/common.js +200 -54
  60. package/client/store/init-state.js +15 -0
  61. package/client/store/load-data.js +73 -0
  62. package/client/store/store.js +6 -0
  63. package/client/store/watch.js +3 -1
  64. package/client/store/widgets.js +23 -5
  65. package/package.json +1 -1
  66. package/client/components/file-transfer/zip.js +0 -42
@@ -1,8 +1,31 @@
1
1
  import { agentTools, executeToolCall } from './agent-tools'
2
2
  import { appendMandatoryGuardrails } from './ai-guardrails'
3
+ import { buildAgentMessages, summarizeContext, CONTEXT_DANGER_PERCENT } from './ai-context'
4
+ import {
5
+ shouldAutoCompress,
6
+ canCompact,
7
+ applySummary
8
+ } from './ai-auto-compress'
9
+ import {
10
+ autoCompressEnabled,
11
+ summarizeMessages,
12
+ autoCompressSession
13
+ } from './ai-compress'
14
+ import uid from '../../common/uid'
3
15
 
4
16
  const MAX_ITERATIONS = 150
5
17
 
18
+ // A summary request that failed (provider hiccup, rate limit) must not be
19
+ // retried on every following iteration -- that would turn a full window into a
20
+ // call per tool result. Only try again once the estimate has grown by this
21
+ // share of the window.
22
+ const AUTO_COMPRESS_RETRY_GROWTH_PERCENT = 5
23
+
24
+ // Which loop currently owns `store.agentRunning`. A stopped run keeps
25
+ // unwinding for a moment (its last request has to settle), and it must not
26
+ // clear the flag of a run the user started in the meantime.
27
+ let activeRunId = null
28
+
6
29
  function buildAgentSystemPrompt (config) {
7
30
  const lang = config.languageAI || window.store.getLangName()
8
31
  const baseRole = config.roleAI || 'You are a helpful assistant.'
@@ -26,14 +49,10 @@ Reply in ${lang} language.`)
26
49
  }
27
50
 
28
51
  function updateChatEntry (chatEntry, updates) {
29
- const index = window.store.aiChatHistory.findIndex(i => i.id === chatEntry.id)
30
- if (index !== -1) {
31
- Object.assign(window.store.aiChatHistory[index], updates)
32
- window.store.aiChatHistory = [...window.store.aiChatHistory]
33
- }
52
+ window.store.updateAiHistoryEntry(chatEntry.id, updates)
34
53
  }
35
54
 
36
- async function callBackendAIchatWithTools (messages, config) {
55
+ async function callBackendAIchatWithTools (messages, config, requestId) {
37
56
  return window.pre.runGlobalAsync(
38
57
  'AIchatWithTools',
39
58
  messages,
@@ -43,31 +62,59 @@ async function callBackendAIchatWithTools (messages, config) {
43
62
  config.apiKeyAI,
44
63
  config.proxyAI,
45
64
  agentTools,
46
- config.authHeaderNameAI
65
+ config.authHeaderNameAI,
66
+ // `format` stays undefined (the main process detects it); `requestId` is
67
+ // the last argument so that exact request can be cancelled on stop.
68
+ undefined,
69
+ requestId
47
70
  )
48
71
  }
49
72
 
73
+ // Publish the size of the context the agent is carrying so the panel can show
74
+ // it. An agent request is not just the conversation: it also carries every
75
+ // tool schema, and each iteration appends the tool results, which is what
76
+ // actually fills the window. `usage` is the provider's own count for the
77
+ // previous request and is kept for comparison -- the estimate is what the
78
+ // *next* request will cost, so that is the number on display.
79
+ function publishContext (messages, config, usage) {
80
+ const info = summarizeContext(messages, {
81
+ model: config.modelAI,
82
+ contextLength: window.store.config.contextLengthAI,
83
+ tools: agentTools
84
+ })
85
+ if (usage && usage.promptTokens) {
86
+ info.measuredTokens = usage.promptTokens
87
+ }
88
+ window.store.aiContextInfo = info
89
+ }
90
+
50
91
  export async function runAgentLoop (chatEntry, config, abortRef, setIsStreaming, conversationMessages = null) {
92
+ const runId = uid()
93
+ activeRunId = runId
51
94
  window.store.agentRunning = true
95
+ const isAborted = () => !!(abortRef && abortRef.current)
96
+ // Auto compression bookkeeping, read by the finally block as well: how many
97
+ // times this run compacted itself, whether it failed on the way, and the
98
+ // size at the last summary attempt (AUTO_COMPRESS_RETRY_GROWTH_PERCENT)
99
+ let autoCompressCount = 0
100
+ let autoCompressAttemptTokens = 0
101
+ let runErrored = false
52
102
  try {
53
- let messages
54
- if (conversationMessages && conversationMessages.length > 1) {
55
- // Replace system message with agent system prompt, keep conversation history
56
- messages = [
57
- { role: 'system', content: buildAgentSystemPrompt(config) },
58
- ...conversationMessages.filter(m => m.role !== 'system')
59
- ]
60
- } else {
61
- messages = [
62
- { role: 'system', content: buildAgentSystemPrompt(config) },
63
- {
64
- role: 'user',
65
- content: chatEntry.promptWithAttachments || chatEntry.prompt
66
- }
67
- ]
68
- }
103
+ const messages = buildAgentMessages({
104
+ systemPrompt: buildAgentSystemPrompt(config),
105
+ conversationMessages,
106
+ prompt: chatEntry.promptWithAttachments || chatEntry.prompt
107
+ })
69
108
  const toolCallsLog = []
70
109
  let accumulatedContent = ''
110
+ let lastUsage = null
111
+
112
+ const markStopped = () => {
113
+ setIsStreaming(false)
114
+ updateChatEntry(chatEntry, {
115
+ response: accumulatedContent + '\n\n*(Agent stopped by user)*'
116
+ })
117
+ }
71
118
 
72
119
  setIsStreaming(true)
73
120
  updateChatEntry(chatEntry, {
@@ -76,17 +123,79 @@ export async function runAgentLoop (chatEntry, config, abortRef, setIsStreaming,
76
123
  })
77
124
 
78
125
  for (let iteration = 0; iteration < MAX_ITERATIONS; iteration++) {
79
- if (abortRef && abortRef.current) {
80
- setIsStreaming(false)
81
- updateChatEntry(chatEntry, {
82
- response: accumulatedContent + '\n\n*(Agent stopped by user)*'
83
- })
126
+ if (isAborted()) {
127
+ markStopped()
84
128
  return
85
129
  }
86
130
 
87
- const result = await callBackendAIchatWithTools(messages, config)
131
+ publishContext(messages, config, lastUsage)
132
+
133
+ // Auto compression, when the popover toggle is on: the request about to
134
+ // go out carries the whole conversation plus a tool result per step, and
135
+ // past ~90% of the window the provider rejects it outright. Replacing the
136
+ // conversation with a summary first keeps the run alive; the stored
137
+ // session is compacted at the end (autoCompressSession), because that is
138
+ // what the next turn starts from.
139
+ const info = window.store.aiContextInfo
140
+ // A failed summary is only worth retrying once the context has grown by
141
+ // this much -- but never more than the trigger itself, or a threshold
142
+ // below it would let the retry floor decide when to compress.
143
+ const growthPercent = Math.min(
144
+ AUTO_COMPRESS_RETRY_GROWTH_PERCENT,
145
+ CONTEXT_DANGER_PERCENT
146
+ )
147
+ const grewEnough = info &&
148
+ info.tokens - autoCompressAttemptTokens >=
149
+ (info.windowSize || 0) * growthPercent / 100
150
+ if (
151
+ autoCompressEnabled() &&
152
+ canCompact(messages) &&
153
+ shouldAutoCompress(info) &&
154
+ grewEnough
155
+ ) {
156
+ autoCompressAttemptTokens = info.tokens
157
+ // Shown before the request rather than after: summarizing a nearly full
158
+ // window takes a while, and this badge is the only sign of why the run
159
+ // is waiting. Taken back if the summary comes back unusable.
160
+ updateChatEntry(chatEntry, { autoCompressCount: autoCompressCount + 1 })
161
+ const summary = await summarizeMessages(messages, config)
162
+ if (isAborted()) {
163
+ markStopped()
164
+ return
165
+ }
166
+ if (summary) {
167
+ applySummary(messages, summary)
168
+ autoCompressCount++
169
+ lastUsage = null
170
+ publishContext(messages, config, null)
171
+ } else {
172
+ updateChatEntry(chatEntry, { autoCompressCount })
173
+ }
174
+ }
175
+
176
+ const requestId = uid()
177
+ if (abortRef) {
178
+ abortRef.requestId = requestId
179
+ }
180
+ const result = await callBackendAIchatWithTools(messages, config, requestId)
181
+ if (abortRef && abortRef.requestId === requestId) {
182
+ abortRef.requestId = null
183
+ }
184
+
185
+ // Re-check after the await, before anything from the answer is used:
186
+ // stopping cancels the request, and neither the partial answer nor the
187
+ // resulting error belongs in the transcript.
188
+ if (isAborted()) {
189
+ markStopped()
190
+ return
191
+ }
192
+
193
+ if (result.usage) {
194
+ lastUsage = result.usage
195
+ }
88
196
 
89
197
  if (result.error) {
198
+ runErrored = true
90
199
  setIsStreaming(false)
91
200
  updateChatEntry(chatEntry, {
92
201
  response: accumulatedContent + `\n\n**Error:** ${result.error}`
@@ -96,6 +205,7 @@ export async function runAgentLoop (chatEntry, config, abortRef, setIsStreaming,
96
205
 
97
206
  const assistantMessage = result.message
98
207
  if (!assistantMessage) {
208
+ runErrored = true
99
209
  setIsStreaming(false)
100
210
  updateChatEntry(chatEntry, {
101
211
  response: accumulatedContent || 'No response from AI.'
@@ -104,6 +214,7 @@ export async function runAgentLoop (chatEntry, config, abortRef, setIsStreaming,
104
214
  }
105
215
 
106
216
  messages.push(assistantMessage)
217
+ publishContext(messages, config, lastUsage)
107
218
 
108
219
  if (assistantMessage.content) {
109
220
  accumulatedContent += (accumulatedContent ? '\n\n' : '') + assistantMessage.content
@@ -121,11 +232,8 @@ export async function runAgentLoop (chatEntry, config, abortRef, setIsStreaming,
121
232
  }
122
233
 
123
234
  for (const toolCall of assistantMessage.tool_calls) {
124
- if (abortRef && abortRef.current) {
125
- setIsStreaming(false)
126
- updateChatEntry(chatEntry, {
127
- response: accumulatedContent + '\n\n*(Agent stopped by user)*'
128
- })
235
+ if (isAborted()) {
236
+ markStopped()
129
237
  return
130
238
  }
131
239
 
@@ -136,28 +244,32 @@ export async function runAgentLoop (chatEntry, config, abortRef, setIsStreaming,
136
244
  args = {}
137
245
  }
138
246
 
139
- const toolEntry = {
247
+ toolCallsLog.push({
140
248
  id: toolCall.id,
141
249
  name: toolCall.function.name,
142
250
  args,
143
251
  status: 'running',
144
252
  result: null
145
- }
146
- toolCallsLog.push(toolEntry)
253
+ })
147
254
  updateChatEntry(chatEntry, {
148
255
  toolCalls: [...toolCallsLog]
149
256
  })
150
257
 
151
- let toolResult
258
+ let finished
152
259
  try {
153
- toolResult = await executeToolCall(toolCall.function.name, args)
154
- toolEntry.status = 'completed'
155
- toolEntry.result = toolResult
260
+ const toolResult = await executeToolCall(toolCall.function.name, args)
261
+ finished = { status: 'completed', result: toolResult }
156
262
  } catch (err) {
157
- toolEntry.status = 'error'
158
- toolEntry.result = err.message
263
+ finished = { status: 'error', result: err.message }
159
264
  }
160
265
 
266
+ // Swap in a new entry object instead of mutating the running one: the
267
+ // tool call card is memo()'d on that object's identity, so an in-place
268
+ // mutation would leave the card showing "running" forever.
269
+ toolCallsLog[toolCallsLog.length - 1] = {
270
+ ...toolCallsLog[toolCallsLog.length - 1],
271
+ ...finished
272
+ }
161
273
  updateChatEntry(chatEntry, {
162
274
  toolCalls: [...toolCallsLog]
163
275
  })
@@ -165,7 +277,7 @@ export async function runAgentLoop (chatEntry, config, abortRef, setIsStreaming,
165
277
  messages.push({
166
278
  role: 'tool',
167
279
  tool_call_id: toolCall.id,
168
- content: toolEntry.result
280
+ content: finished.result
169
281
  })
170
282
  }
171
283
  }
@@ -175,6 +287,54 @@ export async function runAgentLoop (chatEntry, config, abortRef, setIsStreaming,
175
287
  response: accumulatedContent + '\n\n*(Agent reached maximum iterations)*'
176
288
  })
177
289
  } finally {
178
- window.store.agentRunning = false
290
+ if (abortRef) {
291
+ abortRef.requestId = null
292
+ }
293
+ // Only the run that still owns the flag may clear it -- a stopped run
294
+ // unwinds a moment later and must not unlock a newer one.
295
+ const ownsRun = activeRunId === runId
296
+ if (ownsRun) {
297
+ activeRunId = null
298
+ window.store.agentRunning = false
299
+ // hand the panel back to the session based estimate: tool results are not
300
+ // carried into the next turn, so the agent figure stops being meaningful
301
+ window.store.aiContextInfo = null
302
+ }
303
+ // A run that compacted its live conversation leaves the stored session
304
+ // untouched, so the next turn would start from the full history again and
305
+ // pay for another summary. Compact the session as well -- once, after the
306
+ // run, when the turns it summarizes are complete. Skipped for a stopped or
307
+ // failed run: those transcripts are not a conversation worth keeping.
308
+ if (ownsRun && autoCompressCount > 0 && !runErrored && !isAborted()) {
309
+ await autoCompressSession(chatEntry.chatSessionId, config)
310
+ }
311
+ }
312
+ }
313
+
314
+ // Stop the running agent loop, called by the panel's stop button.
315
+ //
316
+ // The loop itself only looks at `abortRef` between iterations, so on its own
317
+ // that flag does nothing while a request is in flight -- and the panel keeps
318
+ // the composer locked for as long as the provider takes to answer (which is
319
+ // forever if it never does, these requests carry no timeout). So this also
320
+ // cancels the in-flight request and releases the composer right away; the loop
321
+ // then unwinds on its own and marks the entry as stopped.
322
+ export async function stopAgentRun (abortRef) {
323
+ if (abortRef) {
324
+ abortRef.current = true
325
+ }
326
+ const requestId = abortRef && abortRef.requestId
327
+ if (abortRef) {
328
+ abortRef.requestId = null
329
+ }
330
+ activeRunId = null
331
+ window.store.agentRunning = false
332
+ window.store.aiContextInfo = null
333
+ if (requestId) {
334
+ try {
335
+ await window.pre.runGlobalAsync('abortAIRequest', requestId)
336
+ } catch (error) {
337
+ console.error('Error aborting agent request:', error)
338
+ }
179
339
  }
180
340
  }
@@ -0,0 +1,119 @@
1
+ // Automatic context compression, shared by agent mode and ask mode.
2
+ //
3
+ // A request is not just the conversation: agent runs append the assistant's
4
+ // tool calls and their results on every iteration, ask turns append the answer
5
+ // of every turn. Either way the request that leaves the machine grows past what
6
+ // the session transcript suggests, and once it approaches the model's window
7
+ // the provider starts rejecting it outright, which kills the run mid-way. The
8
+ // fix is the same one the manual Compress button performs -- replace the
9
+ // history with a summary -- except it has to happen before the request that
10
+ // would overflow.
11
+ //
12
+ // Pure module (no window / store access) so the decision and the rewrite can
13
+ // be unit tested, and so the manual and the automatic path share one summary
14
+ // prompt and one message shape. The request that asks for a summary lives in
15
+ // ai-compress.js.
16
+
17
+ import { CONTEXT_DANGER_PERCENT } from './ai-context.js'
18
+
19
+ // Sent as the closing user turn when asking for a summary. Shared with the
20
+ // manual path (store.compressChatSession) so both produce the same thing.
21
+ export const COMPRESS_SUMMARY_PROMPT = 'Please summarize the above conversation concisely. Include key information, decisions, context, and any important details that would be needed to continue this conversation effectively.'
22
+
23
+ // `info` is the object summarizeContext returns. Percent is null/absent when
24
+ // the window size is unknown, which must not read as "0, no compression
25
+ // needed" -- there is simply nothing to decide on.
26
+ //
27
+ // The threshold is the danger level from ai-context.js, read here rather than
28
+ // copied into a constant of this module: the same value is what turns the
29
+ // context indicator red, and a live read cannot be left behind in a stale copy
30
+ // of this file when that value is edited (which is how "I set it to 5 and it
31
+ // still did not compress" happens).
32
+ export function shouldAutoCompress (info, percent = CONTEXT_DANGER_PERCENT) {
33
+ if (!info) {
34
+ return false
35
+ }
36
+ const p = info.percent
37
+ if (p === null || p === undefined || !isFinite(p)) {
38
+ return false
39
+ }
40
+ return p >= percent
41
+ }
42
+
43
+ // There has to be something a summary can replace. A fresh request is just the
44
+ // system prompt plus the prompt being sent; compacting that would only re-word
45
+ // the question, so a model whose tool schemas alone fill the window is left
46
+ // alone rather than sent a summary request on every turn.
47
+ export function canCompact (messages) {
48
+ return Array.isArray(messages) && messages.length > 2
49
+ }
50
+
51
+ // An ask-mode request ends with the prompt being answered, because
52
+ // buildSessionMessages appends each turn's prompt after the earlier ones.
53
+ // Compaction must keep that last message out of the summary: it is the live
54
+ // question, and folding it into "here is a summary of our previous
55
+ // conversation" would ask the model to answer a question it only sees quoted
56
+ // back at it.
57
+ export function splitPendingTurn (messages) {
58
+ if (!Array.isArray(messages)) {
59
+ return { context: [], pending: null }
60
+ }
61
+ const last = messages[messages.length - 1]
62
+ if (last && last.role === 'user') {
63
+ return { context: messages.slice(0, -1), pending: last }
64
+ }
65
+ return { context: messages, pending: null }
66
+ }
67
+
68
+ // How a summary re-enters the conversation. Deliberately the same pair
69
+ // buildSessionMessages emits for a compressed session entry, so a live loop
70
+ // that compacted itself and a session compressed between turns read
71
+ // identically to the model.
72
+ export function summaryMessages (summary) {
73
+ return [
74
+ {
75
+ role: 'user',
76
+ content: `Here is a summary of our previous conversation for context:\n\n${summary}`
77
+ },
78
+ {
79
+ role: 'assistant',
80
+ content: 'Understood. I will use this context as we continue.'
81
+ }
82
+ ]
83
+ }
84
+
85
+ // Compact `messages` in place: the system prompt stays (it carries the agent
86
+ // instructions and guardrails), everything the summary covers is dropped.
87
+ // In place because the agent loop holds this exact array for its next request.
88
+ export function applySummary (messages, summary) {
89
+ if (!Array.isArray(messages) || !summary) {
90
+ return messages
91
+ }
92
+ const system = messages.find(m => m && m.role === 'system')
93
+ messages.length = 0
94
+ if (system) {
95
+ messages.push(system)
96
+ }
97
+ messages.push(...summaryMessages(summary))
98
+ return messages
99
+ }
100
+
101
+ // The ask-mode counterpart, returning a new list: the memoized session messages
102
+ // this turn was built from must not be modified, and the live prompt has to be
103
+ // re-attached after the summary.
104
+ export function compactAskMessages (messages, summary) {
105
+ if (!Array.isArray(messages) || !summary) {
106
+ return messages
107
+ }
108
+ const { pending } = splitPendingTurn(messages)
109
+ const system = messages.find(m => m && m.role === 'system')
110
+ const next = []
111
+ if (system) {
112
+ next.push(system)
113
+ }
114
+ next.push(...summaryMessages(summary))
115
+ if (pending) {
116
+ next.push(pending)
117
+ }
118
+ return next
119
+ }