@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.
- package/README.md +5 -5
- package/client/common/bookmark-schemas.js +4 -0
- package/client/common/constants.js +3 -0
- package/client/common/create-title.jsx +5 -1
- package/client/common/default-setting.js +2 -0
- package/client/common/parse-quick-connect.js +1 -0
- package/client/common/parse-vv.js +119 -25
- package/client/common/parse-widget-log.js +57 -0
- package/client/common/vv-to-tab.js +120 -0
- package/client/components/ai/agent-tool-call-card.jsx +26 -7
- package/client/components/ai/agent.js +205 -45
- package/client/components/ai/ai-auto-compress.js +119 -0
- package/client/components/ai/ai-chat-history-item.jsx +133 -84
- package/client/components/ai/ai-chat.jsx +135 -30
- package/client/components/ai/ai-compress.js +95 -0
- package/client/components/ai/ai-config-props.js +12 -1
- package/client/components/ai/ai-config.jsx +43 -16
- package/client/components/ai/ai-config.styl +20 -0
- package/client/components/ai/ai-context-indicator.jsx +83 -0
- package/client/components/ai/ai-context.js +272 -0
- package/client/components/ai/ai-output.jsx +5 -6
- package/client/components/ai/ai-presets.js +1 -16
- package/client/components/ai/ai.styl +105 -6
- package/client/components/ai/copy-icon.jsx +31 -0
- 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/common/vv-file-field.jsx +3 -2
- package/client/components/bookmark-form/config/spice.js +21 -0
- 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/footer/cmd-history.jsx +279 -65
- package/client/components/footer/cmd-history.styl +68 -1
- package/client/components/footer/footer-entry.jsx +7 -1
- package/client/components/layout/layout.jsx +12 -19
- package/client/components/main/main.jsx +15 -1
- package/client/components/quick-commands/qm.styl +58 -0
- package/client/components/quick-commands/quick-commands-box.jsx +180 -83
- package/client/components/quick-commands/quick-commands-select.jsx +11 -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/setting-common.jsx +2 -0
- package/client/components/setting-panel/tab-widgets.jsx +27 -7
- package/client/components/sftp/file-item.jsx +0 -13
- package/client/components/side-panel-r/side-panel-r.jsx +11 -4
- package/client/components/sidebar/bookmark-select.jsx +14 -2
- package/client/components/sidebar/history-item.jsx +20 -4
- package/client/components/sidebar/history.jsx +7 -0
- package/client/components/terminal/mixins/term-touch.js +18 -0
- package/client/components/tree-list/tree-list.jsx +31 -3
- 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 +200 -54
- package/client/store/init-state.js +15 -0
- package/client/store/load-data.js +73 -0
- package/client/store/store.js +6 -0
- package/client/store/watch.js +3 -1
- package/client/store/widgets.js +23 -5
- package/package.json +1 -1
- 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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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,
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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.
|
|
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='
|
|
263
|
-
<
|
|
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={<
|
|
275
|
-
onClick={
|
|
276
|
-
|
|
277
|
-
className='mg1r'
|
|
365
|
+
icon={<PlusOutlined />}
|
|
366
|
+
onClick={handleNewChat}
|
|
367
|
+
className='new-chat-btn'
|
|
278
368
|
>
|
|
279
|
-
{e('
|
|
369
|
+
{e('new')}
|
|
280
370
|
</Button>
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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 {
|