@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.
- package/client/common/bookmark-schemas.js +1 -0
- package/client/common/constants.js +1 -0
- package/client/common/dangerous-session-fields.js +39 -0
- package/client/common/parse-quick-connect.js +47 -6
- package/client/common/parse-widget-log.js +57 -0
- package/client/common/safe-join.js +33 -0
- package/client/common/sanitize-filename.js +8 -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/remote2remote-handlers.jsx +8 -3
- 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/setting-terminal.jsx +1 -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-init.js +1 -0
- package/client/components/terminal/mixins/term-touch.js +18 -0
- package/client/components/terminal/shell.js +11 -3
- package/client/components/terminal/shortcut-bar-press.js +192 -0
- package/client/components/terminal/shortcut-bar.jsx +199 -4
- package/client/components/terminal/shortcut-bar.styl +40 -21
- package/client/components/terminal/terminal.jsx +4 -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/mcp-handler.js +8 -15
- package/client/store/sync.js +1 -0
- package/client/store/tab.js +2 -15
- package/client/store/widgets.js +23 -5
- package/package.json +1 -1
- 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
|
-
|
|
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
|
+
}
|