@electerm/electerm-react 5.5.35 → 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.
- package/client/common/bookmark-schemas.js +1 -0
- package/client/common/constants.js +1 -0
- package/client/common/parse-quick-connect.js +1 -0
- package/client/common/parse-widget-log.js +57 -0
- package/client/components/ai/agent-tool-call-card.jsx +7 -3
- package/client/components/ai/agent.js +176 -33
- package/client/components/ai/ai-auto-compress.js +119 -0
- package/client/components/ai/ai-chat-history-item.jsx +125 -40
- package/client/components/ai/ai-chat.jsx +67 -19
- package/client/components/ai/ai-compress.js +95 -0
- package/client/components/ai/ai-config.jsx +29 -16
- package/client/components/ai/ai-config.styl +20 -0
- package/client/components/ai/ai-context.js +1 -1
- package/client/components/ai/ai-output.jsx +1 -6
- package/client/components/ai/ai-presets.js +0 -15
- package/client/components/batch-op/batch-op-runner.jsx +1 -0
- package/client/components/bookmark-form/bookmark-schema.js +1 -0
- package/client/components/bookmark-form/common/ssh-agent.jsx +31 -21
- package/client/components/bookmark-form/config/ssh.js +1 -0
- package/client/components/bookmark-form/fix-bookmark-default.js +1 -0
- package/client/components/file-transfer/transfer.jsx +3 -119
- package/client/components/main/main.jsx +1 -0
- package/client/components/session/session-control.jsx +35 -2
- package/client/components/session/session-control.styl +5 -0
- package/client/components/session/session.jsx +13 -0
- package/client/components/setting-panel/tab-widgets.jsx +27 -7
- package/client/components/sftp/file-item.jsx +0 -13
- package/client/components/terminal/mixins/term-touch.js +18 -0
- package/client/components/widgets/widget-control.jsx +4 -2
- package/client/components/widgets/widget-instance-detail.jsx +370 -0
- package/client/components/widgets/widget-instance.jsx +30 -13
- package/client/components/widgets/widget-instances.jsx +3 -1
- package/client/components/widgets/widgets-list.jsx +8 -0
- package/client/components/widgets/widgets.styl +100 -0
- package/client/store/common.js +81 -23
- package/client/store/init-state.js +7 -0
- package/client/store/load-data.js +1 -0
- package/client/store/widgets.js +23 -5
- package/package.json +1 -1
- package/client/components/file-transfer/zip.js +0 -42
|
@@ -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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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,
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
371
|
+
<Popover
|
|
372
|
+
content={renderAutoCompressPopover()}
|
|
373
|
+
trigger='click'
|
|
374
|
+
placement='topLeft'
|
|
375
|
+
>
|
|
325
376
|
<Button
|
|
326
377
|
size='small'
|
|
327
378
|
icon={<CompressOutlined />}
|
|
328
|
-
|
|
329
|
-
|
|
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('
|
|
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
|
+
}
|
|
@@ -5,11 +5,11 @@ import {
|
|
|
5
5
|
Button,
|
|
6
6
|
AutoComplete,
|
|
7
7
|
Alert,
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
Select,
|
|
9
|
+
Space
|
|
10
10
|
} from 'antd'
|
|
11
11
|
import { useEffect, useMemo, useState } from 'react'
|
|
12
|
-
import {
|
|
12
|
+
import { ReloadOutlined } from '@ant-design/icons'
|
|
13
13
|
import Link from '../common/external-link'
|
|
14
14
|
import AiCache from './ai-cache'
|
|
15
15
|
import {
|
|
@@ -20,6 +20,7 @@ import AiHistory, { addHistoryItem } from './ai-history'
|
|
|
20
20
|
import message from '../common/message'
|
|
21
21
|
import { getAIPresets } from './ai-presets'
|
|
22
22
|
import { appendMandatoryGuardrails } from './ai-guardrails'
|
|
23
|
+
import './ai-config.styl'
|
|
23
24
|
|
|
24
25
|
const STORAGE_KEY_CONFIG = 'ai_config_history'
|
|
25
26
|
const EVENT_NAME_CONFIG = 'ai-config-history-update'
|
|
@@ -211,18 +212,32 @@ export default function AIConfigForm ({ initialValues, onSubmit, showAIConfig })
|
|
|
211
212
|
form.setFieldsValue(values)
|
|
212
213
|
}
|
|
213
214
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
215
|
+
// A plain antd Select, on purpose: it already scrolls its list inside the
|
|
216
|
+
// viewport (a Dropdown menu grows past the bottom of a phone screen, so the
|
|
217
|
+
// tail of the 18 presets is unreachable there) and `showSearch` filters it.
|
|
218
|
+
// `optionFilterProp` is load-bearing: without it rc-select filters on the
|
|
219
|
+
// option *value* (our id), so "gemini" or "llama" match nothing. The label
|
|
220
|
+
// carries name and model, so filtering on it finds either.
|
|
221
|
+
// The label/select sizing lives in `.ai-preset-row` (ai-config.styl), because
|
|
222
|
+
// Space's `.ant-space-item` wrapper is the flex item, not the child.
|
|
223
|
+
function renderPresetSelect () {
|
|
224
|
+
const options = presets.map(p => ({
|
|
225
|
+
value: p.id,
|
|
226
|
+
label: p.modelAI ? `${p.nameAI} · ${p.modelAI}` : p.nameAI
|
|
219
227
|
}))
|
|
220
228
|
return (
|
|
221
|
-
<
|
|
222
|
-
<
|
|
223
|
-
{e('presets')}
|
|
224
|
-
|
|
225
|
-
|
|
229
|
+
<div className='alignright'>
|
|
230
|
+
<Space className='mg1b width-100 ai-preset-row'>
|
|
231
|
+
<span>{e('presets')}</span>
|
|
232
|
+
<Select
|
|
233
|
+
showSearch
|
|
234
|
+
optionFilterProp='label'
|
|
235
|
+
options={options}
|
|
236
|
+
value={currentPreset?.id}
|
|
237
|
+
onSelect={id => handleSelectPreset(presets.find(p => p.id === id))}
|
|
238
|
+
/>
|
|
239
|
+
</Space>
|
|
240
|
+
</div>
|
|
226
241
|
)
|
|
227
242
|
}
|
|
228
243
|
|
|
@@ -294,9 +309,7 @@ export default function AIConfigForm ({ initialValues, onSubmit, showAIConfig })
|
|
|
294
309
|
type='warning'
|
|
295
310
|
className='mg2b'
|
|
296
311
|
/>
|
|
297
|
-
|
|
298
|
-
{renderPresetMenu()}
|
|
299
|
-
</div>
|
|
312
|
+
{renderPresetSelect()}
|
|
300
313
|
<p>
|
|
301
314
|
Full Url: {initialValues?.baseURLAI}{initialValues?.apiPathAI}
|
|
302
315
|
</p>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Preset row: the "Presets" label and the provider select share one line.
|
|
2
|
+
// antd's Space wraps every child in a `.ant-space-item`, and that wrapper — not
|
|
3
|
+
// the child — is the flex item, so the flex behaviour has to be set on the
|
|
4
|
+
// wrappers. Put `flex` on the Select itself and it is silently ignored, which
|
|
5
|
+
// makes the row's overflow land on the label: at 480px wide the label is
|
|
6
|
+
// squeezed into a 17px column and wraps onto four lines.
|
|
7
|
+
.ai-preset-row
|
|
8
|
+
max-width 400px
|
|
9
|
+
.ant-space-item:first-child
|
|
10
|
+
// the label keeps its full text width and never wraps or shrinks
|
|
11
|
+
flex none
|
|
12
|
+
white-space nowrap
|
|
13
|
+
|
|
14
|
+
.ant-space-item:last-child
|
|
15
|
+
// the select takes the rest of the line, shrinking to nothing if it must
|
|
16
|
+
flex 1
|
|
17
|
+
min-width 0
|
|
18
|
+
|
|
19
|
+
.ant-select
|
|
20
|
+
width 100%
|
|
@@ -23,7 +23,7 @@ const CJK_RE = /[\u2e80-\u2eff\u3000-\u303f\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9f
|
|
|
23
23
|
|
|
24
24
|
export const DEFAULT_CONTEXT_WINDOW = 128000
|
|
25
25
|
export const CONTEXT_WARN_PERCENT = 70
|
|
26
|
-
export const CONTEXT_DANGER_PERCENT =
|
|
26
|
+
export const CONTEXT_DANGER_PERCENT = 80
|
|
27
27
|
|
|
28
28
|
// Rough token count of a string. Never exact, never wildly wrong. Called on
|
|
29
29
|
// the whole conversation every time it changes, so it stays a single pass
|
|
@@ -98,12 +98,7 @@ export default function AIOutput ({ item }) {
|
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
function handleToggleFlag () {
|
|
101
|
-
|
|
102
|
-
if (index === -1) {
|
|
103
|
-
return
|
|
104
|
-
}
|
|
105
|
-
window.store.aiChatHistory[index].flagged = !window.store.aiChatHistory[index].flagged
|
|
106
|
-
window.store.aiChatHistory = [...window.store.aiChatHistory]
|
|
101
|
+
window.store.updateAiHistoryEntry(item.id, { flagged: !item.flagged })
|
|
107
102
|
}
|
|
108
103
|
|
|
109
104
|
function renderFlag () {
|
|
@@ -67,21 +67,6 @@ export const defaultAIPresets = [
|
|
|
67
67
|
authHeaderNameAI: 'Authorization: Bearer',
|
|
68
68
|
siteUrl: 'https://platform.openai.com/api-keys?utm_source=electerm_app&utm_medium=link&utm_campaign=electerm'
|
|
69
69
|
},
|
|
70
|
-
{
|
|
71
|
-
id: 'openai-responses',
|
|
72
|
-
nameAI: 'OpenAI (Responses API)',
|
|
73
|
-
baseURLAI: 'https://api.openai.com/v1',
|
|
74
|
-
apiPathAI: '/responses',
|
|
75
|
-
modelAI: 'gpt-4o',
|
|
76
|
-
authHeaderNameAI: 'Authorization: Bearer',
|
|
77
|
-
modelAIs: [
|
|
78
|
-
{ value: 'gpt-4o' },
|
|
79
|
-
{ value: 'gpt-4o-mini' },
|
|
80
|
-
{ value: 'gpt-4.1' },
|
|
81
|
-
{ value: 'o4-mini' }
|
|
82
|
-
],
|
|
83
|
-
siteUrl: 'https://platform.openai.com/api-keys?utm_source=electerm_app&utm_medium=link&utm_campaign=electerm'
|
|
84
|
-
},
|
|
85
70
|
{
|
|
86
71
|
id: 'anthropic',
|
|
87
72
|
nameAI: 'Anthropic Claude',
|
|
@@ -172,6 +172,7 @@ export default class BatchOpRunner extends Component {
|
|
|
172
172
|
enableSsh: p.enableSsh !== false,
|
|
173
173
|
useSshAgent: p.useSshAgent !== false,
|
|
174
174
|
sshAgent: p.sshAgent || '',
|
|
175
|
+
agentForward: p.agentForward || false,
|
|
175
176
|
term: p.term || 'xterm-256color',
|
|
176
177
|
encode: p.encode || 'utf8',
|
|
177
178
|
envLang: p.envLang || 'en_US.UTF-8',
|
|
@@ -20,6 +20,7 @@ const bookmarkSchema = {
|
|
|
20
20
|
connectionHoppings: 'array - connection hopping definitions',
|
|
21
21
|
useSshAgent: 'boolean - use SSH agent, default is true',
|
|
22
22
|
sshAgent: 'string - ssh agent path',
|
|
23
|
+
agentForward: 'boolean - enable SSH agent forwarding (like ssh -A, needed to hop through a jump/bastion host), requires SSH agent, default is false',
|
|
23
24
|
serverHostKey: 'array - server host key algorithms',
|
|
24
25
|
cipher: 'array - cipher list',
|
|
25
26
|
compress: 'array - compression algorithms (zlib@openssh.com, zlib, none)',
|