@electerm/electerm-react 5.5.35 → 5.5.66

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 (54) hide show
  1. package/client/common/bookmark-schemas.js +1 -0
  2. package/client/common/constants.js +1 -0
  3. package/client/common/dangerous-session-fields.js +39 -0
  4. package/client/common/parse-quick-connect.js +47 -6
  5. package/client/common/parse-widget-log.js +57 -0
  6. package/client/common/safe-join.js +33 -0
  7. package/client/common/sanitize-filename.js +8 -0
  8. package/client/components/ai/agent-tool-call-card.jsx +7 -3
  9. package/client/components/ai/agent.js +176 -33
  10. package/client/components/ai/ai-auto-compress.js +119 -0
  11. package/client/components/ai/ai-chat-history-item.jsx +125 -40
  12. package/client/components/ai/ai-chat.jsx +67 -19
  13. package/client/components/ai/ai-compress.js +95 -0
  14. package/client/components/ai/ai-config.jsx +29 -16
  15. package/client/components/ai/ai-config.styl +20 -0
  16. package/client/components/ai/ai-context.js +1 -1
  17. package/client/components/ai/ai-output.jsx +1 -6
  18. package/client/components/ai/ai-presets.js +0 -15
  19. package/client/components/batch-op/batch-op-runner.jsx +1 -0
  20. package/client/components/bookmark-form/bookmark-schema.js +1 -0
  21. package/client/components/bookmark-form/common/ssh-agent.jsx +31 -21
  22. package/client/components/bookmark-form/config/ssh.js +1 -0
  23. package/client/components/bookmark-form/fix-bookmark-default.js +1 -0
  24. package/client/components/file-transfer/remote2remote-handlers.jsx +8 -3
  25. package/client/components/file-transfer/transfer.jsx +3 -119
  26. package/client/components/main/main.jsx +1 -0
  27. package/client/components/session/session-control.jsx +35 -2
  28. package/client/components/session/session-control.styl +5 -0
  29. package/client/components/session/session.jsx +13 -0
  30. package/client/components/setting-panel/setting-terminal.jsx +1 -0
  31. package/client/components/setting-panel/tab-widgets.jsx +27 -7
  32. package/client/components/sftp/file-item.jsx +0 -13
  33. package/client/components/terminal/mixins/term-init.js +1 -0
  34. package/client/components/terminal/mixins/term-touch.js +18 -0
  35. package/client/components/terminal/shell.js +11 -3
  36. package/client/components/terminal/shortcut-bar-press.js +192 -0
  37. package/client/components/terminal/shortcut-bar.jsx +199 -4
  38. package/client/components/terminal/shortcut-bar.styl +40 -21
  39. package/client/components/terminal/terminal.jsx +4 -0
  40. package/client/components/widgets/widget-control.jsx +4 -2
  41. package/client/components/widgets/widget-instance-detail.jsx +370 -0
  42. package/client/components/widgets/widget-instance.jsx +30 -13
  43. package/client/components/widgets/widget-instances.jsx +3 -1
  44. package/client/components/widgets/widgets-list.jsx +8 -0
  45. package/client/components/widgets/widgets.styl +100 -0
  46. package/client/store/common.js +81 -23
  47. package/client/store/init-state.js +7 -0
  48. package/client/store/load-data.js +1 -0
  49. package/client/store/mcp-handler.js +8 -15
  50. package/client/store/sync.js +1 -0
  51. package/client/store/tab.js +2 -15
  52. package/client/store/widgets.js +23 -5
  53. package/package.json +1 -1
  54. package/client/components/file-transfer/zip.js +0 -42
@@ -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
+ }
@@ -1,10 +1,17 @@
1
- import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
1
+ import { useState, useEffect, useRef, useCallback, useMemo, memo } from 'react'
2
2
  import AIOutput from './ai-output'
3
3
  import AIStopIcon from './ai-stop-icon'
4
4
  import AgentToolCallCard from './agent-tool-call-card'
5
- import { runAgentLoop } from './agent'
5
+ import { runAgentLoop, stopAgentRun } from './agent'
6
6
  import { appendMandatoryGuardrails } from './ai-guardrails'
7
- import { buildSessionMessages } from './ai-context'
7
+ import { buildSessionMessages, summarizeContext } from './ai-context'
8
+ import {
9
+ shouldAutoCompress,
10
+ canCompact,
11
+ splitPendingTurn,
12
+ compactAskMessages
13
+ } from './ai-auto-compress'
14
+ import { summarizeMessages, autoCompressSession, autoCompressEnabled } from './ai-compress'
8
15
  import {
9
16
  Alert,
10
17
  Tooltip,
@@ -15,14 +22,23 @@ import {
15
22
  CloseOutlined,
16
23
  CaretDownOutlined,
17
24
  CaretRightOutlined,
25
+ CompressOutlined,
18
26
  PaperClipOutlined
19
27
  } from '@ant-design/icons'
20
28
  import { copy } from '../../common/clipboard'
21
29
  import { formatSize } from './ai-attachments'
22
30
 
23
- export default function AIChatHistoryItem ({ item }) {
31
+ // memo()'d: the panel holds the draft prompt in its own state, so every
32
+ // keystroke re-renders the whole transcript. Without this the keystroke also
33
+ // re-rendered (and re-parsed the markdown of) every message in it -- 50ms+ per
34
+ // character in a long agent session. Entries are updated immutably
35
+ // (store.updateAiHistoryEntry), so the reference comparison is meaningful.
36
+ export default memo(function AIChatHistoryItem ({ item }) {
24
37
  const [showOutput, setShowOutput] = useState(true)
25
38
  const [isStreaming, setIsStreaming] = useState(false)
39
+ // `current` is the stop flag; `requestId` is the HTTP request currently in
40
+ // flight (agent mode), so that stopping can cancel it rather than wait for it
41
+ // to come back.
26
42
  const abortRef = useRef(false)
27
43
  const {
28
44
  prompt,
@@ -38,7 +54,8 @@ export default function AIChatHistoryItem ({ item }) {
38
54
  authHeaderNameAI,
39
55
  languageAI,
40
56
  mode,
41
- toolCalls
57
+ toolCalls,
58
+ autoCompressCount
42
59
  } = item
43
60
 
44
61
  function toggleOutput () {
@@ -62,6 +79,61 @@ export default function AIChatHistoryItem ({ item }) {
62
79
  excludeId: item.id
63
80
  }), [chatSessionId, item.id, item.timestamp])
64
81
 
82
+ // Everything a request needs beyond the message list; also what the
83
+ // compressor uses to reach the same provider (see ai-compress.js).
84
+ const requestConfig = useMemo(() => ({
85
+ modelAI,
86
+ roleAI,
87
+ baseURLAI,
88
+ apiPathAI,
89
+ apiKeyAI,
90
+ proxyAI,
91
+ languageAI,
92
+ authHeaderNameAI
93
+ }), [
94
+ modelAI,
95
+ roleAI,
96
+ baseURLAI,
97
+ apiPathAI,
98
+ apiKeyAI,
99
+ proxyAI,
100
+ languageAI,
101
+ authHeaderNameAI
102
+ ])
103
+
104
+ // The message list this turn actually sends. Normally the memoized session
105
+ // messages above, but when auto compress is on and the request would not fit
106
+ // the window, the earlier turns are replaced by a summary first. The live
107
+ // prompt survives untouched -- it is the question being answered, not
108
+ // history. The stored session is compacted separately once the answer is in
109
+ // (autoCompressSession below), so this is only about the request leaving now.
110
+ const buildOutgoingMessages = useCallback(async () => {
111
+ if (!autoCompressEnabled() || !conversationMessages) {
112
+ return conversationMessages
113
+ }
114
+ const info = summarizeContext(conversationMessages, {
115
+ model: modelAI,
116
+ contextLength: window.store.config.contextLengthAI
117
+ })
118
+ if (!shouldAutoCompress(info)) {
119
+ return conversationMessages
120
+ }
121
+ const { context } = splitPendingTurn(conversationMessages)
122
+ if (!canCompact(context)) {
123
+ return conversationMessages
124
+ }
125
+ // Marked before the request: summarizing a nearly full window takes a
126
+ // while, and the badge is the only sign that this turn is waiting on it.
127
+ // Cleared again if the summary comes back unusable.
128
+ window.store.updateAiHistoryEntry(item.id, { autoCompressCount: 1 })
129
+ const summary = await summarizeMessages(context, requestConfig)
130
+ if (!summary) {
131
+ window.store.updateAiHistoryEntry(item.id, { autoCompressCount: 0 })
132
+ return conversationMessages
133
+ }
134
+ return compactAskMessages(conversationMessages, summary)
135
+ }, [conversationMessages, modelAI, requestConfig, item.id])
136
+
65
137
  const pollStreamContent = useCallback(async (sid) => {
66
138
  try {
67
139
  const streamResponse = await window.pre.runGlobalAsync('getStreamContent', sid)
@@ -74,20 +146,22 @@ export default function AIChatHistoryItem ({ item }) {
74
146
  return window.store.onError(new Error(streamResponse.error))
75
147
  }
76
148
 
77
- const index = window.store.aiChatHistory.findIndex(i => i.id === item.id)
78
- if (index !== -1) {
79
- window.store.aiChatHistory[index].response = streamResponse.content || ''
80
- window.store.aiChatHistory = [...window.store.aiChatHistory]
81
- }
149
+ window.store.updateAiHistoryEntry(item.id, {
150
+ response: streamResponse.content || ''
151
+ })
82
152
  setIsStreaming(streamResponse.hasMore)
83
153
  if (streamResponse.hasMore) {
84
154
  setTimeout(() => pollStreamContent(sid), 200)
155
+ } else {
156
+ // The answer is in the store now, so the session can be summarized for
157
+ // the turns that follow this one.
158
+ autoCompressSession(chatSessionId, requestConfig)
85
159
  }
86
160
  } catch (error) {
87
161
  window.store.removeAiHistory(item.id)
88
162
  window.store.onError(error)
89
163
  }
90
- }, [item.id])
164
+ }, [item.id, chatSessionId, requestConfig])
91
165
 
92
166
  const startRequest = useCallback(async () => {
93
167
  try {
@@ -102,7 +176,7 @@ export default function AIChatHistoryItem ({ item }) {
102
176
  proxyAI,
103
177
  true,
104
178
  authHeaderNameAI,
105
- conversationMessages
179
+ await buildOutgoingMessages()
106
180
  )
107
181
 
108
182
  if (aiResponse && aiResponse.error) {
@@ -112,45 +186,34 @@ export default function AIChatHistoryItem ({ item }) {
112
186
 
113
187
  if (aiResponse && aiResponse.isStream && aiResponse.sessionId) {
114
188
  setIsStreaming(true)
115
- const index = window.store.aiChatHistory.findIndex(i => i.id === item.id)
116
- if (index !== -1) {
117
- window.store.aiChatHistory[index].sessionId = aiResponse.sessionId
118
- window.store.aiChatHistory[index].response = aiResponse.content || ''
119
- }
189
+ window.store.updateAiHistoryEntry(item.id, {
190
+ sessionId: aiResponse.sessionId,
191
+ response: aiResponse.content || ''
192
+ })
120
193
  pollStreamContent(aiResponse.sessionId)
121
194
  } else if (aiResponse && aiResponse.response) {
122
- const index = window.store.aiChatHistory.findIndex(i => i.id === item.id)
123
- if (index !== -1) {
124
- window.store.aiChatHistory[index].response = aiResponse.response
125
- }
195
+ window.store.updateAiHistoryEntry(item.id, {
196
+ response: aiResponse.response
197
+ })
198
+ // No stream to poll: the answer is in the store, so the session can be
199
+ // summarized for the turns that follow this one.
200
+ autoCompressSession(chatSessionId, requestConfig)
126
201
  }
127
202
  } catch (error) {
128
203
  window.store.removeAiHistory(item.id)
129
204
  window.store.onError(error)
130
205
  }
131
- }, [prompt, modelAI, baseURLAI, apiPathAI, apiKeyAI, proxyAI, authHeaderNameAI, item.id, pollStreamContent, conversationMessages])
206
+ }, [prompt, modelAI, baseURLAI, apiPathAI, apiKeyAI, proxyAI, authHeaderNameAI, item.id, chatSessionId, requestConfig, pollStreamContent, buildOutgoingMessages])
132
207
 
133
208
  const startAgentRequest = useCallback(async () => {
134
209
  abortRef.current = false
135
- const config = {
136
- modelAI,
137
- roleAI,
138
- baseURLAI,
139
- apiPathAI,
140
- apiKeyAI,
141
- proxyAI,
142
- languageAI,
143
- authHeaderNameAI
144
- }
145
- await runAgentLoop(item, config, abortRef, setIsStreaming, conversationMessages)
146
- }, [modelAI, roleAI, baseURLAI, apiPathAI, apiKeyAI, proxyAI, languageAI, authHeaderNameAI, item.id, conversationMessages])
210
+ abortRef.requestId = null
211
+ await runAgentLoop(item, requestConfig, abortRef, setIsStreaming, conversationMessages)
212
+ }, [item, requestConfig, conversationMessages])
147
213
 
148
214
  useEffect(() => {
149
215
  if (item.pending) {
150
- const index = window.store.aiChatHistory.findIndex(i => i.id === item.id)
151
- if (index !== -1) {
152
- window.store.aiChatHistory[index].pending = false
153
- }
216
+ window.store.updateAiHistoryEntry(item.id, { pending: false })
154
217
  if (mode === 'agent') {
155
218
  startAgentRequest()
156
219
  } else {
@@ -162,8 +225,8 @@ export default function AIChatHistoryItem ({ item }) {
162
225
  async function handleStop (e) {
163
226
  e.stopPropagation()
164
227
  if (mode === 'agent') {
165
- abortRef.current = true
166
228
  setIsStreaming(false)
229
+ await stopAgentRun(abortRef)
167
230
  return
168
231
  }
169
232
  if (!sessionId) return
@@ -258,6 +321,27 @@ export default function AIChatHistoryItem ({ item }) {
258
321
  )
259
322
  }
260
323
 
324
+ // Auto compress happens on its own, with no visible step of its own, so the
325
+ // turn says so: the conversation was summarized and the request continued
326
+ // from the summary instead of the full history. Ask mode summarizes before
327
+ // sending, agent mode in the middle of the run (possibly more than once).
328
+ function renderAutoCompressBadge () {
329
+ if (!autoCompressCount) {
330
+ return null
331
+ }
332
+ return (
333
+ <div className='ai-auto-compress-badge mg1b'>
334
+ <Tag
335
+ color='blue'
336
+ title='The context filled up and was summarized automatically, this turn continued from the summary'
337
+ >
338
+ <CompressOutlined /> {window.translate('autoCompress')}
339
+ {autoCompressCount > 1 ? ` x${autoCompressCount}` : ''}
340
+ </Tag>
341
+ </div>
342
+ )
343
+ }
344
+
261
345
  function renderToolCalls () {
262
346
  if (mode !== 'agent' || !toolCalls || !toolCalls.length) {
263
347
  return null
@@ -279,9 +363,10 @@ export default function AIChatHistoryItem ({ item }) {
279
363
  </Tooltip>
280
364
  </div>
281
365
  {renderAttachments()}
366
+ {renderAutoCompressBadge()}
282
367
  {renderToolCalls()}
283
368
  {showOutput && <AIOutput item={item} />}
284
369
  {renderStopButton()}
285
370
  </div>
286
371
  )
287
- }
372
+ })
@@ -1,10 +1,11 @@
1
1
  import { useState, useCallback, useEffect, useMemo, useRef } from 'react'
2
- import { Flex, Input, Segmented, Button, Tag, message } from 'antd'
2
+ import { Flex, Input, Segmented, Button, Tag, message, Popover } from 'antd'
3
3
  import TabSelect from '../footer/tab-select'
4
4
  import AiChatHistory from './ai-chat-history'
5
5
  import AiChatSessions from './ai-chat-sessions'
6
6
  import AiContextIndicator from './ai-context-indicator'
7
- import { buildSessionMessages, summarizeContext, getUsageLevel, formatPercent } from './ai-context'
7
+ import SwitchLabel from '../common/switch'
8
+ import { buildSessionMessages, summarizeContext, getUsageLevel } from './ai-context'
8
9
  import { appendMandatoryGuardrails } from './ai-guardrails'
9
10
  import uid from '../../common/uid'
10
11
  import { pick } from 'lodash-es'
@@ -42,6 +43,7 @@ export default function AIChat (props) {
42
43
  const fileInputRef = useRef(null)
43
44
  const [mode, setMode] = useState(() => getItem(aiChatModeLsKey) || 'ask')
44
45
  const isAgent = mode === 'agent'
46
+ const autoCompress = !!props.aiAutoCompress
45
47
  const submitDisabled = isAgent && props.agentRunning
46
48
 
47
49
  const currentChatSessionId = props.currentChatSessionId || ''
@@ -52,8 +54,17 @@ export default function AIChat (props) {
52
54
  }
53
55
  }, [currentChatSessionId, props.rightPanelTab])
54
56
 
55
- const sessionHistory = (props.aiChatHistory || []).filter(
56
- h => h.chatSessionId === currentChatSessionId
57
+ // Memoized on purpose: `prompt` lives in this component's state, so every
58
+ // keystroke re-renders it. `AIChatHistory` is memo()'d, and a fresh array on
59
+ // each keystroke defeated that -- which also meant its scroll-to-bottom
60
+ // layout effect re-ran while typing, yanking the view down mid-scroll.
61
+ // Entries are updated immutably, so a new array identity really does mean
62
+ // the transcript changed.
63
+ const sessionHistory = useMemo(
64
+ () => (props.aiChatHistory || []).filter(
65
+ h => h.chatSessionId === currentChatSessionId
66
+ ),
67
+ [props.aiChatHistory, currentChatSessionId]
57
68
  )
58
69
 
59
70
  const config = props.config || {}
@@ -144,6 +155,12 @@ export default function AIChat (props) {
144
155
  if (window.store.aiConfigMissing()) {
145
156
  window.store.toggleAIConfig()
146
157
  }
158
+ // Read the live flag rather than the rendered one. `submitDisabled` can be
159
+ // stale: the loop sets `agentRunning` from the new turn's mount effect, and
160
+ // manate's `auto` re-subscribes its listener on every render, so a write
161
+ // landing in that window never reaches the panel -- leaving the icon looking
162
+ // enabled while a run is in flight, which let a second agent turn through.
163
+ if (isAgent && window.store.agentRunning) return
147
164
  if (!prompt.trim() && !attachments.length) return
148
165
 
149
166
  const chatId = uid()
@@ -179,12 +196,14 @@ export default function AIChat (props) {
179
196
  id: chatId
180
197
  }
181
198
 
182
- window.store.aiChatHistory.push(chatEntry)
199
+ // Reassign rather than push: the store only notifies on a property write,
200
+ // and the transcript is keyed off the array identity.
201
+ window.store.aiChatHistory = [...window.store.aiChatHistory, chatEntry]
183
202
  setPrompt('')
184
203
  setAttachments([])
185
204
 
186
205
  if (window.store.aiChatHistory.length > MAX_HISTORY) {
187
- window.store.aiChatHistory.splice(MAX_HISTORY)
206
+ window.store.aiChatHistory = window.store.aiChatHistory.slice(0, MAX_HISTORY)
188
207
  }
189
208
  }, [prompt, attachments, mode, currentChatSessionId])
190
209
 
@@ -224,11 +243,39 @@ export default function AIChat (props) {
224
243
  }
225
244
  }
226
245
 
227
- function renderCompressTitle () {
228
- if ((contextLevel === 'warn' || contextLevel === 'danger') && contextInfo) {
229
- return `Context is ${formatPercent(contextInfo.percent)} full — compress to summarize the session`
230
- }
231
- return 'Summarize this session into a single message, dropping the older history'
246
+ function handleAutoCompressChange (val) {
247
+ window.store.setAiAutoCompress(val)
248
+ }
249
+
250
+ // The toolbar button is the auto compress button: the popover behind it holds
251
+ // the persistent toggle plus the manual compress action, so one button covers
252
+ // both "keep this session small for me" and "compact it right now".
253
+ function renderAutoCompressPopover () {
254
+ return (
255
+ <div className='ai-auto-compress-popover'>
256
+ <Flex align='center' justify='space-between' gap={12}>
257
+ <span className='ai-auto-compress-label'>{e('autoCompress')}</span>
258
+ <SwitchLabel
259
+ checked={autoCompress}
260
+ onChange={handleAutoCompressChange}
261
+ size='small'
262
+ />
263
+ </Flex>
264
+ <hr />
265
+ <Flex vertical gap={4} align='flex-start'>
266
+ <Button
267
+ size='small'
268
+ icon={<CompressOutlined />}
269
+ onClick={handleCompressSession}
270
+ loading={compressing}
271
+ disabled={sessionHistory.length < 2}
272
+ type={contextLevel === 'warn' || contextLevel === 'danger' ? 'primary' : 'default'}
273
+ >
274
+ {e('compress')}
275
+ </Button>
276
+ </Flex>
277
+ </div>
278
+ )
232
279
  }
233
280
 
234
281
  function handleShowHistory () {
@@ -321,19 +368,20 @@ export default function AIChat (props) {
321
368
  >
322
369
  {e('new')}
323
370
  </Button>
324
- {sessionHistory.length >= 2 && (
371
+ <Popover
372
+ content={renderAutoCompressPopover()}
373
+ trigger='click'
374
+ placement='topLeft'
375
+ >
325
376
  <Button
326
377
  size='small'
327
378
  icon={<CompressOutlined />}
328
- onClick={handleCompressSession}
329
- loading={compressing}
330
- type={contextLevel === 'warn' ? 'primary' : 'default'}
331
- danger={contextLevel === 'danger'}
332
- title={renderCompressTitle()}
379
+ type={autoCompress ? 'primary' : 'default'}
380
+ danger={!autoCompress && contextLevel === 'danger'}
333
381
  >
334
- {e('compress')}
382
+ {e('autoCompress')}
335
383
  </Button>
336
- )}
384
+ </Popover>
337
385
  <Button
338
386
  size='small'
339
387
  icon={<HistoryOutlined />}
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Runtime side of AI context compression.
3
+ *
4
+ * The pure decisions and message rewrites live in ai-auto-compress.js; this
5
+ * module owns the parts that need the provider (the summary request) and the
6
+ * store (whether the toggle is on, how big a stored session is, compressing it).
7
+ * Both the agent loop and ask mode go through here so "too full" means the same
8
+ * number in both.
9
+ */
10
+
11
+ import { buildSessionMessages, summarizeContext } from './ai-context'
12
+ import { appendMandatoryGuardrails } from './ai-guardrails'
13
+ import {
14
+ COMPRESS_SUMMARY_PROMPT,
15
+ shouldAutoCompress
16
+ } from './ai-auto-compress'
17
+
18
+ // Read at the moment of the decision, not captured: the toggle can be flipped
19
+ // from the popover while a run is in flight.
20
+ export function autoCompressEnabled () {
21
+ return !!(window.store && window.store.aiAutoCompress)
22
+ }
23
+
24
+ // Ask the model for the summary that replaces a conversation. Goes through the
25
+ // plain chat path rather than the tool loop: this request only has to produce
26
+ // text, and giving it tools would let the summarizer act on the terminal while
27
+ // it is supposed to be reading. Returns '' when there is nothing usable, which
28
+ // callers read as "leave the conversation alone".
29
+ export async function summarizeMessages (messages, config = {}) {
30
+ try {
31
+ const result = await window.pre.runGlobalAsync(
32
+ 'AIchat',
33
+ COMPRESS_SUMMARY_PROMPT,
34
+ config.modelAI,
35
+ config.roleAI,
36
+ config.baseURLAI,
37
+ config.apiPathAI,
38
+ config.apiKeyAI,
39
+ config.proxyAI,
40
+ false,
41
+ config.authHeaderNameAI,
42
+ [...messages, { role: 'user', content: COMPRESS_SUMMARY_PROMPT }]
43
+ )
44
+ if (!result || result.error || !result.response) {
45
+ return ''
46
+ }
47
+ return result.response
48
+ } catch (error) {
49
+ console.error('Error summarizing conversation:', error)
50
+ return ''
51
+ }
52
+ }
53
+
54
+ // How big the stored session is, i.e. what the next ask-mode turn would carry.
55
+ // Same figure the panel's own indicator shows (components/ai/ai-chat.jsx), minus
56
+ // agent tools, which only agent mode sends.
57
+ export function sessionContextInfo (sessionId, config = {}) {
58
+ const store = window.store
59
+ if (!sessionId || !store) {
60
+ return null
61
+ }
62
+ const lang = config.languageAI || store.getLangName()
63
+ const role = appendMandatoryGuardrails((config.roleAI || '') + `;用[${lang}]回复`)
64
+ const messages = buildSessionMessages({
65
+ history: store.aiChatHistory,
66
+ chatSessionId: sessionId,
67
+ role
68
+ })
69
+ if (!messages || messages.length < 2) {
70
+ return null
71
+ }
72
+ return summarizeContext(messages, {
73
+ model: config.modelAI,
74
+ contextLength: store.config.contextLengthAI
75
+ })
76
+ }
77
+
78
+ // Compact a stored session when the toggle is on and it is over the line.
79
+ // Called once a turn or a run is finished, so the *next* request starts from the
80
+ // summary instead of the full history. Returns true when it compressed.
81
+ export async function autoCompressSession (sessionId, config = {}) {
82
+ if (!autoCompressEnabled() || !sessionId) {
83
+ return false
84
+ }
85
+ if (!shouldAutoCompress(sessionContextInfo(sessionId, config))) {
86
+ return false
87
+ }
88
+ try {
89
+ await window.store.compressChatSession(sessionId)
90
+ return true
91
+ } catch (error) {
92
+ console.error('Error auto compressing session:', error)
93
+ return false
94
+ }
95
+ }