@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,9 +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, 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'
7
15
  import {
8
16
  Alert,
9
17
  Tooltip,
@@ -14,14 +22,23 @@ import {
14
22
  CloseOutlined,
15
23
  CaretDownOutlined,
16
24
  CaretRightOutlined,
25
+ CompressOutlined,
17
26
  PaperClipOutlined
18
27
  } from '@ant-design/icons'
19
28
  import { copy } from '../../common/clipboard'
20
29
  import { formatSize } from './ai-attachments'
21
30
 
22
- 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 }) {
23
37
  const [showOutput, setShowOutput] = useState(true)
24
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.
25
42
  const abortRef = useRef(false)
26
43
  const {
27
44
  prompt,
@@ -37,7 +54,8 @@ export default function AIChatHistoryItem ({ item }) {
37
54
  authHeaderNameAI,
38
55
  languageAI,
39
56
  mode,
40
- toolCalls
57
+ toolCalls,
58
+ autoCompressCount
41
59
  } = item
42
60
 
43
61
  function toggleOutput () {
@@ -49,54 +67,72 @@ export default function AIChatHistoryItem ({ item }) {
49
67
  return appendMandatoryGuardrails(roleAI + `;用[${lang}]回复`)
50
68
  }
51
69
 
52
- const conversationMessages = useMemo(() => {
53
- if (!chatSessionId) {
54
- return null
55
- }
56
- const sessionEntries = window.store.aiChatHistory
57
- .filter(h => h.chatSessionId === chatSessionId && h.timestamp <= item.timestamp)
58
- .sort((a, b) => a.timestamp - b.timestamp)
59
-
60
- // Find the last compress entry
61
- let lastCompressIndex = -1
62
- for (let i = sessionEntries.length - 1; i >= 0; i--) {
63
- if (sessionEntries[i].compressed) {
64
- lastCompressIndex = i
65
- break
66
- }
67
- }
70
+ // The context this turn sends: the session history up to and including the
71
+ // previous turns, but not this turn's own (still streaming) response.
72
+ // Shared with the context size indicator in ai-chat.jsx so the number shown
73
+ // is built from the same message list that goes out.
74
+ const conversationMessages = useMemo(() => buildSessionMessages({
75
+ history: window.store.aiChatHistory,
76
+ chatSessionId,
77
+ upto: item.timestamp,
78
+ role: buildRole(),
79
+ excludeId: item.id
80
+ }), [chatSessionId, item.id, item.timestamp])
68
81
 
69
- const messages = [
70
- { role: 'system', content: buildRole() }
71
- ]
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
+ ])
72
103
 
73
- // Start from the last compress entry (if any), skip older history
74
- const startIndex = lastCompressIndex >= 0 ? lastCompressIndex : 0
75
- for (let i = startIndex; i < sessionEntries.length; i++) {
76
- const entry = sessionEntries[i]
77
- if (entry.compressed) {
78
- messages.push({
79
- role: 'user',
80
- content: `Here is a summary of our previous conversation for context:\n\n${entry.response}`
81
- })
82
- messages.push({
83
- role: 'assistant',
84
- content: 'Understood. I will use this context as we continue.'
85
- })
86
- } else {
87
- messages.push({
88
- role: 'user',
89
- // promptWithAttachments carries the inlined file blocks;
90
- // falls back to the plain prompt for legacy entries
91
- content: entry.promptWithAttachments || entry.prompt
92
- })
93
- if (entry.response && entry.id !== item.id) {
94
- messages.push({ role: 'assistant', content: entry.response })
95
- }
96
- }
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
97
124
  }
98
- return messages
99
- }, [chatSessionId, item.id, item.timestamp])
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])
100
136
 
101
137
  const pollStreamContent = useCallback(async (sid) => {
102
138
  try {
@@ -110,20 +146,22 @@ export default function AIChatHistoryItem ({ item }) {
110
146
  return window.store.onError(new Error(streamResponse.error))
111
147
  }
112
148
 
113
- const index = window.store.aiChatHistory.findIndex(i => i.id === item.id)
114
- if (index !== -1) {
115
- window.store.aiChatHistory[index].response = streamResponse.content || ''
116
- window.store.aiChatHistory = [...window.store.aiChatHistory]
117
- }
149
+ window.store.updateAiHistoryEntry(item.id, {
150
+ response: streamResponse.content || ''
151
+ })
118
152
  setIsStreaming(streamResponse.hasMore)
119
153
  if (streamResponse.hasMore) {
120
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)
121
159
  }
122
160
  } catch (error) {
123
161
  window.store.removeAiHistory(item.id)
124
162
  window.store.onError(error)
125
163
  }
126
- }, [item.id])
164
+ }, [item.id, chatSessionId, requestConfig])
127
165
 
128
166
  const startRequest = useCallback(async () => {
129
167
  try {
@@ -138,7 +176,7 @@ export default function AIChatHistoryItem ({ item }) {
138
176
  proxyAI,
139
177
  true,
140
178
  authHeaderNameAI,
141
- conversationMessages
179
+ await buildOutgoingMessages()
142
180
  )
143
181
 
144
182
  if (aiResponse && aiResponse.error) {
@@ -148,45 +186,34 @@ export default function AIChatHistoryItem ({ item }) {
148
186
 
149
187
  if (aiResponse && aiResponse.isStream && aiResponse.sessionId) {
150
188
  setIsStreaming(true)
151
- const index = window.store.aiChatHistory.findIndex(i => i.id === item.id)
152
- if (index !== -1) {
153
- window.store.aiChatHistory[index].sessionId = aiResponse.sessionId
154
- window.store.aiChatHistory[index].response = aiResponse.content || ''
155
- }
189
+ window.store.updateAiHistoryEntry(item.id, {
190
+ sessionId: aiResponse.sessionId,
191
+ response: aiResponse.content || ''
192
+ })
156
193
  pollStreamContent(aiResponse.sessionId)
157
194
  } else if (aiResponse && aiResponse.response) {
158
- const index = window.store.aiChatHistory.findIndex(i => i.id === item.id)
159
- if (index !== -1) {
160
- window.store.aiChatHistory[index].response = aiResponse.response
161
- }
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)
162
201
  }
163
202
  } catch (error) {
164
203
  window.store.removeAiHistory(item.id)
165
204
  window.store.onError(error)
166
205
  }
167
- }, [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])
168
207
 
169
208
  const startAgentRequest = useCallback(async () => {
170
209
  abortRef.current = false
171
- const config = {
172
- modelAI,
173
- roleAI,
174
- baseURLAI,
175
- apiPathAI,
176
- apiKeyAI,
177
- proxyAI,
178
- languageAI,
179
- authHeaderNameAI
180
- }
181
- await runAgentLoop(item, config, abortRef, setIsStreaming, conversationMessages)
182
- }, [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])
183
213
 
184
214
  useEffect(() => {
185
215
  if (item.pending) {
186
- const index = window.store.aiChatHistory.findIndex(i => i.id === item.id)
187
- if (index !== -1) {
188
- window.store.aiChatHistory[index].pending = false
189
- }
216
+ window.store.updateAiHistoryEntry(item.id, { pending: false })
190
217
  if (mode === 'agent') {
191
218
  startAgentRequest()
192
219
  } else {
@@ -198,8 +225,8 @@ export default function AIChatHistoryItem ({ item }) {
198
225
  async function handleStop (e) {
199
226
  e.stopPropagation()
200
227
  if (mode === 'agent') {
201
- abortRef.current = true
202
228
  setIsStreaming(false)
229
+ await stopAgentRun(abortRef)
203
230
  return
204
231
  }
205
232
  if (!sessionId) return
@@ -294,6 +321,27 @@ export default function AIChatHistoryItem ({ item }) {
294
321
  )
295
322
  }
296
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
+
297
345
  function renderToolCalls () {
298
346
  if (mode !== 'agent' || !toolCalls || !toolCalls.length) {
299
347
  return null
@@ -315,9 +363,10 @@ export default function AIChatHistoryItem ({ item }) {
315
363
  </Tooltip>
316
364
  </div>
317
365
  {renderAttachments()}
366
+ {renderAutoCompressBadge()}
318
367
  {renderToolCalls()}
319
368
  {showOutput && <AIOutput item={item} />}
320
369
  {renderStopButton()}
321
370
  </div>
322
371
  )
323
- }
372
+ })
@@ -1,8 +1,12 @@
1
- import { useState, useCallback, useEffect, useRef } from 'react'
2
- import { Flex, Input, Segmented, Button, Tag, message } from 'antd'
1
+ import { useState, useCallback, useEffect, useMemo, useRef } from 'react'
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
+ import AiContextIndicator from './ai-context-indicator'
7
+ import SwitchLabel from '../common/switch'
8
+ import { buildSessionMessages, summarizeContext, getUsageLevel } from './ai-context'
9
+ import { appendMandatoryGuardrails } from './ai-guardrails'
6
10
  import uid from '../../common/uid'
7
11
  import { pick } from 'lodash-es'
8
12
  import {
@@ -39,6 +43,7 @@ export default function AIChat (props) {
39
43
  const fileInputRef = useRef(null)
40
44
  const [mode, setMode] = useState(() => getItem(aiChatModeLsKey) || 'ask')
41
45
  const isAgent = mode === 'agent'
46
+ const autoCompress = !!props.aiAutoCompress
42
47
  const submitDisabled = isAgent && props.agentRunning
43
48
 
44
49
  const currentChatSessionId = props.currentChatSessionId || ''
@@ -49,10 +54,61 @@ export default function AIChat (props) {
49
54
  }
50
55
  }, [currentChatSessionId, props.rightPanelTab])
51
56
 
52
- const sessionHistory = (props.aiChatHistory || []).filter(
53
- 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]
54
68
  )
55
69
 
70
+ const config = props.config || {}
71
+
72
+ // Size of the context the *next* message will carry: every entry of this
73
+ // session, from the last compression onwards, built by the same helper the
74
+ // sender uses. While an agent loop is running its own, much larger, figure
75
+ // is published to the store (tool schemas and tool results included) and
76
+ // takes precedence.
77
+ const sessionContextInfo = useMemo(() => {
78
+ if (!currentChatSessionId) {
79
+ return null
80
+ }
81
+ const lang = config.languageAI || window.store.getLangName()
82
+ const role = appendMandatoryGuardrails(
83
+ (config.roleAI || '') + `;用[${lang}]回复`
84
+ )
85
+ const messages = buildSessionMessages({
86
+ history: props.aiChatHistory,
87
+ chatSessionId: currentChatSessionId,
88
+ role
89
+ })
90
+ if (!messages || messages.length < 2) {
91
+ return null
92
+ }
93
+ return summarizeContext(messages, {
94
+ model: config.modelAI,
95
+ contextLength: config.contextLengthAI
96
+ })
97
+ }, [
98
+ props.aiChatHistory,
99
+ // length as well as identity: sending a message pushes onto the existing
100
+ // array, so the reference alone would not change
101
+ props.aiChatHistory.length,
102
+ currentChatSessionId,
103
+ config.roleAI,
104
+ config.languageAI,
105
+ config.modelAI,
106
+ config.contextLengthAI
107
+ ])
108
+
109
+ const contextInfo = props.aiContextInfo || sessionContextInfo
110
+ const contextLevel = getUsageLevel(contextInfo && contextInfo.percent)
111
+
56
112
  function handlePromptChange (e) {
57
113
  setPrompt(e.target.value)
58
114
  }
@@ -99,6 +155,12 @@ export default function AIChat (props) {
99
155
  if (window.store.aiConfigMissing()) {
100
156
  window.store.toggleAIConfig()
101
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
102
164
  if (!prompt.trim() && !attachments.length) return
103
165
 
104
166
  const chatId = uid()
@@ -134,12 +196,14 @@ export default function AIChat (props) {
134
196
  id: chatId
135
197
  }
136
198
 
137
- 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]
138
202
  setPrompt('')
139
203
  setAttachments([])
140
204
 
141
205
  if (window.store.aiChatHistory.length > MAX_HISTORY) {
142
- window.store.aiChatHistory.splice(MAX_HISTORY)
206
+ window.store.aiChatHistory = window.store.aiChatHistory.slice(0, MAX_HISTORY)
143
207
  }
144
208
  }, [prompt, attachments, mode, currentChatSessionId])
145
209
 
@@ -179,6 +243,41 @@ export default function AIChat (props) {
179
243
  }
180
244
  }
181
245
 
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
+ )
279
+ }
280
+
182
281
  function handleShowHistory () {
183
282
  window.store.toggleChatSessions()
184
283
  }
@@ -259,34 +358,40 @@ export default function AIChat (props) {
259
358
  </Flex>
260
359
 
261
360
  <Flex vertical className='ai-chat-input'>
262
- <Flex className='ai-chat-toolbar mg1b' align='left' gap={4}>
263
- <Button
264
- size='small'
265
- icon={<PlusOutlined />}
266
- onClick={handleNewChat}
267
- className='mg1r new-chat-btn'
268
- >
269
- {e('new')}
270
- </Button>
271
- {sessionHistory.length >= 2 && (
361
+ <Flex className='ai-chat-toolbar mg1b' align='center' justify='space-between' gap={4}>
362
+ <Flex align='center' gap={4}>
272
363
  <Button
273
364
  size='small'
274
- icon={<CompressOutlined />}
275
- onClick={handleCompressSession}
276
- loading={compressing}
277
- className='mg1r'
365
+ icon={<PlusOutlined />}
366
+ onClick={handleNewChat}
367
+ className='new-chat-btn'
278
368
  >
279
- {e('compress')}
369
+ {e('new')}
280
370
  </Button>
281
- )}
282
- <Button
283
- size='small'
284
- icon={<HistoryOutlined />}
285
- onClick={handleShowHistory}
286
- type={props.showChatSessions ? 'primary' : 'default'}
287
- >
288
- {e('history')}
289
- </Button>
371
+ <Popover
372
+ content={renderAutoCompressPopover()}
373
+ trigger='click'
374
+ placement='topLeft'
375
+ >
376
+ <Button
377
+ size='small'
378
+ icon={<CompressOutlined />}
379
+ type={autoCompress ? 'primary' : 'default'}
380
+ danger={!autoCompress && contextLevel === 'danger'}
381
+ >
382
+ {e('autoCompress')}
383
+ </Button>
384
+ </Popover>
385
+ <Button
386
+ size='small'
387
+ icon={<HistoryOutlined />}
388
+ onClick={handleShowHistory}
389
+ type={props.showChatSessions ? 'primary' : 'default'}
390
+ >
391
+ {e('history')}
392
+ </Button>
393
+ </Flex>
394
+ <AiContextIndicator info={contextInfo} />
290
395
  </Flex>
291
396
  {attachments.length > 0 && (
292
397
  <Flex wrap='wrap' gap={4} className='ai-chat-attachments mg1b'>
@@ -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
+ }
@@ -7,7 +7,18 @@ export const aiConfigsArr = [
7
7
  'authHeaderNameAI',
8
8
  'apiPathAI',
9
9
  'languageAI',
10
- 'proxyAI'
10
+ 'proxyAI',
11
+ 'contextLengthAI'
12
+ ]
13
+
14
+ // Keys that may legitimately be empty. `aiConfigMissing` only forces the
15
+ // config modal open when one of the *other* keys is unset, so anything
16
+ // optional has to be listed here or the modal would pop up forever.
17
+ export const optionalAIConfigsArr = [
18
+ 'apiKeyAI',
19
+ 'proxyAI',
20
+ 'nameAI',
21
+ 'contextLengthAI'
11
22
  ]
12
23
 
13
24
  export {