@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
|
@@ -62,6 +62,7 @@ export const sshBookmarkSchema = {
|
|
|
62
62
|
enableSftp: z.boolean().optional().describe('Enable sftp, default is true'),
|
|
63
63
|
useSshAgent: z.boolean().optional().describe('Use SSH agent, default is true'),
|
|
64
64
|
sshAgent: z.string().optional().describe('SSH agent path'),
|
|
65
|
+
agentForward: z.boolean().optional().describe('Enable SSH agent forwarding (like ssh -A), requires SSH agent, default is false'),
|
|
65
66
|
serverHostKey: z.array(z.string()).optional().describe('Server host key algorithms'),
|
|
66
67
|
cipher: z.array(z.string()).optional().describe('Cipher list'),
|
|
67
68
|
compress: z.array(z.string()).optional().describe('Compression algorithms'),
|
|
@@ -302,6 +302,7 @@ export const regexHelpLink = 'https://github.com/electerm/electerm/wiki/Terminal
|
|
|
302
302
|
export const connectionHoppingWikiLink = 'https://github.com/electerm/electerm/wiki/Connection-Hopping-Behavior-Change-in-electerm-since-v1.50.65'
|
|
303
303
|
export const aiConfigWikiLink = 'https://github.com/electerm/electerm/wiki/AI-model-config-guide'
|
|
304
304
|
export const aiChatModeLsKey = 'ai-chat-mode'
|
|
305
|
+
export const aiAutoCompressLsKey = 'ai-auto-compress'
|
|
305
306
|
export const lastAiChatSessionIdKey = 'last-ai-chat-session-id'
|
|
306
307
|
export const aiTermOfUseConfirmedLsKey = 'ai-term-of-use-confirmed'
|
|
307
308
|
export const syncTermOfUseConfirmedLsKey = 'sync-term-of-use-confirmed'
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse a widget log file into the two streams the widget panel shows.
|
|
3
|
+
*
|
|
4
|
+
* The writer is src/app/widgets/instance-log.js; the format is one entry per
|
|
5
|
+
* line, pipe separated:
|
|
6
|
+
* <iso time>|log|<level>|<message>
|
|
7
|
+
* <iso time>|conn|<ok|fail|info>|<type>|<from>|<message>
|
|
8
|
+
* A message may contain pipes, so everything after the last known separator is
|
|
9
|
+
* taken as the message.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const CONN_STATE = {
|
|
13
|
+
ok: true,
|
|
14
|
+
fail: false,
|
|
15
|
+
info: null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function parseWidgetLog (text) {
|
|
19
|
+
const logs = []
|
|
20
|
+
const events = []
|
|
21
|
+
for (const line of String(text || '').split('\n')) {
|
|
22
|
+
if (!line) {
|
|
23
|
+
continue
|
|
24
|
+
}
|
|
25
|
+
const parts = line.split('|')
|
|
26
|
+
if (parts.length < 4) {
|
|
27
|
+
continue
|
|
28
|
+
}
|
|
29
|
+
const ts = Date.parse(parts[0])
|
|
30
|
+
if (!ts) {
|
|
31
|
+
continue
|
|
32
|
+
}
|
|
33
|
+
if (parts[1] === 'conn') {
|
|
34
|
+
if (parts.length < 6) {
|
|
35
|
+
continue
|
|
36
|
+
}
|
|
37
|
+
events.push({
|
|
38
|
+
ts,
|
|
39
|
+
ok: Object.prototype.hasOwnProperty.call(CONN_STATE, parts[2])
|
|
40
|
+
? CONN_STATE[parts[2]]
|
|
41
|
+
: null,
|
|
42
|
+
type: parts[3],
|
|
43
|
+
from: parts[4] === '-' ? '' : parts[4],
|
|
44
|
+
msg: parts.slice(5).join('|')
|
|
45
|
+
})
|
|
46
|
+
} else {
|
|
47
|
+
logs.push({
|
|
48
|
+
ts,
|
|
49
|
+
level: parts[2],
|
|
50
|
+
msg: parts.slice(3).join('|')
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return { logs, events }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export default parseWidgetLog
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useEffect } from 'react'
|
|
1
|
+
import { useState, useEffect, memo } from 'react'
|
|
2
2
|
import { Tag } from 'antd'
|
|
3
3
|
import {
|
|
4
4
|
CaretDownOutlined,
|
|
@@ -34,7 +34,11 @@ function formatResult (result) {
|
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
// memo()'d on the entry object: `formatResult` JSON.parses the whole tool
|
|
38
|
+
// result on every render, and an agent turn can hold dozens of these. The
|
|
39
|
+
// agent loop swaps in a new entry object when a call finishes
|
|
40
|
+
// (runAgentLoop), so an unchanged card keeps its identity and is skipped.
|
|
41
|
+
export default memo(function AgentToolCallCard ({ toolCall, autoCollapse }) {
|
|
38
42
|
const [expanded, setExpanded] = useState(toolCall.status === 'running')
|
|
39
43
|
const { name, args, status, result } = toolCall
|
|
40
44
|
const Icon = toolIcons[name] || CodeOutlined
|
|
@@ -110,4 +114,4 @@ export default function AgentToolCallCard ({ toolCall, autoCollapse }) {
|
|
|
110
114
|
)}
|
|
111
115
|
</div>
|
|
112
116
|
)
|
|
113
|
-
}
|
|
117
|
+
})
|
|
@@ -1,9 +1,31 @@
|
|
|
1
1
|
import { agentTools, executeToolCall } from './agent-tools'
|
|
2
2
|
import { appendMandatoryGuardrails } from './ai-guardrails'
|
|
3
|
-
import { buildAgentMessages, summarizeContext } from './ai-context'
|
|
3
|
+
import { buildAgentMessages, summarizeContext, CONTEXT_DANGER_PERCENT } from './ai-context'
|
|
4
|
+
import {
|
|
5
|
+
shouldAutoCompress,
|
|
6
|
+
canCompact,
|
|
7
|
+
applySummary
|
|
8
|
+
} from './ai-auto-compress'
|
|
9
|
+
import {
|
|
10
|
+
autoCompressEnabled,
|
|
11
|
+
summarizeMessages,
|
|
12
|
+
autoCompressSession
|
|
13
|
+
} from './ai-compress'
|
|
14
|
+
import uid from '../../common/uid'
|
|
4
15
|
|
|
5
16
|
const MAX_ITERATIONS = 150
|
|
6
17
|
|
|
18
|
+
// A summary request that failed (provider hiccup, rate limit) must not be
|
|
19
|
+
// retried on every following iteration -- that would turn a full window into a
|
|
20
|
+
// call per tool result. Only try again once the estimate has grown by this
|
|
21
|
+
// share of the window.
|
|
22
|
+
const AUTO_COMPRESS_RETRY_GROWTH_PERCENT = 5
|
|
23
|
+
|
|
24
|
+
// Which loop currently owns `store.agentRunning`. A stopped run keeps
|
|
25
|
+
// unwinding for a moment (its last request has to settle), and it must not
|
|
26
|
+
// clear the flag of a run the user started in the meantime.
|
|
27
|
+
let activeRunId = null
|
|
28
|
+
|
|
7
29
|
function buildAgentSystemPrompt (config) {
|
|
8
30
|
const lang = config.languageAI || window.store.getLangName()
|
|
9
31
|
const baseRole = config.roleAI || 'You are a helpful assistant.'
|
|
@@ -27,14 +49,10 @@ Reply in ${lang} language.`)
|
|
|
27
49
|
}
|
|
28
50
|
|
|
29
51
|
function updateChatEntry (chatEntry, updates) {
|
|
30
|
-
|
|
31
|
-
if (index !== -1) {
|
|
32
|
-
Object.assign(window.store.aiChatHistory[index], updates)
|
|
33
|
-
window.store.aiChatHistory = [...window.store.aiChatHistory]
|
|
34
|
-
}
|
|
52
|
+
window.store.updateAiHistoryEntry(chatEntry.id, updates)
|
|
35
53
|
}
|
|
36
54
|
|
|
37
|
-
async function callBackendAIchatWithTools (messages, config) {
|
|
55
|
+
async function callBackendAIchatWithTools (messages, config, requestId) {
|
|
38
56
|
return window.pre.runGlobalAsync(
|
|
39
57
|
'AIchatWithTools',
|
|
40
58
|
messages,
|
|
@@ -44,7 +62,11 @@ async function callBackendAIchatWithTools (messages, config) {
|
|
|
44
62
|
config.apiKeyAI,
|
|
45
63
|
config.proxyAI,
|
|
46
64
|
agentTools,
|
|
47
|
-
config.authHeaderNameAI
|
|
65
|
+
config.authHeaderNameAI,
|
|
66
|
+
// `format` stays undefined (the main process detects it); `requestId` is
|
|
67
|
+
// the last argument so that exact request can be cancelled on stop.
|
|
68
|
+
undefined,
|
|
69
|
+
requestId
|
|
48
70
|
)
|
|
49
71
|
}
|
|
50
72
|
|
|
@@ -67,7 +89,16 @@ function publishContext (messages, config, usage) {
|
|
|
67
89
|
}
|
|
68
90
|
|
|
69
91
|
export async function runAgentLoop (chatEntry, config, abortRef, setIsStreaming, conversationMessages = null) {
|
|
92
|
+
const runId = uid()
|
|
93
|
+
activeRunId = runId
|
|
70
94
|
window.store.agentRunning = true
|
|
95
|
+
const isAborted = () => !!(abortRef && abortRef.current)
|
|
96
|
+
// Auto compression bookkeeping, read by the finally block as well: how many
|
|
97
|
+
// times this run compacted itself, whether it failed on the way, and the
|
|
98
|
+
// size at the last summary attempt (AUTO_COMPRESS_RETRY_GROWTH_PERCENT)
|
|
99
|
+
let autoCompressCount = 0
|
|
100
|
+
let autoCompressAttemptTokens = 0
|
|
101
|
+
let runErrored = false
|
|
71
102
|
try {
|
|
72
103
|
const messages = buildAgentMessages({
|
|
73
104
|
systemPrompt: buildAgentSystemPrompt(config),
|
|
@@ -78,6 +109,13 @@ export async function runAgentLoop (chatEntry, config, abortRef, setIsStreaming,
|
|
|
78
109
|
let accumulatedContent = ''
|
|
79
110
|
let lastUsage = null
|
|
80
111
|
|
|
112
|
+
const markStopped = () => {
|
|
113
|
+
setIsStreaming(false)
|
|
114
|
+
updateChatEntry(chatEntry, {
|
|
115
|
+
response: accumulatedContent + '\n\n*(Agent stopped by user)*'
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
81
119
|
setIsStreaming(true)
|
|
82
120
|
updateChatEntry(chatEntry, {
|
|
83
121
|
toolCalls: [],
|
|
@@ -85,21 +123,79 @@ export async function runAgentLoop (chatEntry, config, abortRef, setIsStreaming,
|
|
|
85
123
|
})
|
|
86
124
|
|
|
87
125
|
for (let iteration = 0; iteration < MAX_ITERATIONS; iteration++) {
|
|
88
|
-
if (
|
|
89
|
-
|
|
90
|
-
updateChatEntry(chatEntry, {
|
|
91
|
-
response: accumulatedContent + '\n\n*(Agent stopped by user)*'
|
|
92
|
-
})
|
|
126
|
+
if (isAborted()) {
|
|
127
|
+
markStopped()
|
|
93
128
|
return
|
|
94
129
|
}
|
|
95
130
|
|
|
96
131
|
publishContext(messages, config, lastUsage)
|
|
97
|
-
|
|
132
|
+
|
|
133
|
+
// Auto compression, when the popover toggle is on: the request about to
|
|
134
|
+
// go out carries the whole conversation plus a tool result per step, and
|
|
135
|
+
// past ~90% of the window the provider rejects it outright. Replacing the
|
|
136
|
+
// conversation with a summary first keeps the run alive; the stored
|
|
137
|
+
// session is compacted at the end (autoCompressSession), because that is
|
|
138
|
+
// what the next turn starts from.
|
|
139
|
+
const info = window.store.aiContextInfo
|
|
140
|
+
// A failed summary is only worth retrying once the context has grown by
|
|
141
|
+
// this much -- but never more than the trigger itself, or a threshold
|
|
142
|
+
// below it would let the retry floor decide when to compress.
|
|
143
|
+
const growthPercent = Math.min(
|
|
144
|
+
AUTO_COMPRESS_RETRY_GROWTH_PERCENT,
|
|
145
|
+
CONTEXT_DANGER_PERCENT
|
|
146
|
+
)
|
|
147
|
+
const grewEnough = info &&
|
|
148
|
+
info.tokens - autoCompressAttemptTokens >=
|
|
149
|
+
(info.windowSize || 0) * growthPercent / 100
|
|
150
|
+
if (
|
|
151
|
+
autoCompressEnabled() &&
|
|
152
|
+
canCompact(messages) &&
|
|
153
|
+
shouldAutoCompress(info) &&
|
|
154
|
+
grewEnough
|
|
155
|
+
) {
|
|
156
|
+
autoCompressAttemptTokens = info.tokens
|
|
157
|
+
// Shown before the request rather than after: summarizing a nearly full
|
|
158
|
+
// window takes a while, and this badge is the only sign of why the run
|
|
159
|
+
// is waiting. Taken back if the summary comes back unusable.
|
|
160
|
+
updateChatEntry(chatEntry, { autoCompressCount: autoCompressCount + 1 })
|
|
161
|
+
const summary = await summarizeMessages(messages, config)
|
|
162
|
+
if (isAborted()) {
|
|
163
|
+
markStopped()
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
if (summary) {
|
|
167
|
+
applySummary(messages, summary)
|
|
168
|
+
autoCompressCount++
|
|
169
|
+
lastUsage = null
|
|
170
|
+
publishContext(messages, config, null)
|
|
171
|
+
} else {
|
|
172
|
+
updateChatEntry(chatEntry, { autoCompressCount })
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const requestId = uid()
|
|
177
|
+
if (abortRef) {
|
|
178
|
+
abortRef.requestId = requestId
|
|
179
|
+
}
|
|
180
|
+
const result = await callBackendAIchatWithTools(messages, config, requestId)
|
|
181
|
+
if (abortRef && abortRef.requestId === requestId) {
|
|
182
|
+
abortRef.requestId = null
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Re-check after the await, before anything from the answer is used:
|
|
186
|
+
// stopping cancels the request, and neither the partial answer nor the
|
|
187
|
+
// resulting error belongs in the transcript.
|
|
188
|
+
if (isAborted()) {
|
|
189
|
+
markStopped()
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
|
|
98
193
|
if (result.usage) {
|
|
99
194
|
lastUsage = result.usage
|
|
100
195
|
}
|
|
101
196
|
|
|
102
197
|
if (result.error) {
|
|
198
|
+
runErrored = true
|
|
103
199
|
setIsStreaming(false)
|
|
104
200
|
updateChatEntry(chatEntry, {
|
|
105
201
|
response: accumulatedContent + `\n\n**Error:** ${result.error}`
|
|
@@ -109,6 +205,7 @@ export async function runAgentLoop (chatEntry, config, abortRef, setIsStreaming,
|
|
|
109
205
|
|
|
110
206
|
const assistantMessage = result.message
|
|
111
207
|
if (!assistantMessage) {
|
|
208
|
+
runErrored = true
|
|
112
209
|
setIsStreaming(false)
|
|
113
210
|
updateChatEntry(chatEntry, {
|
|
114
211
|
response: accumulatedContent || 'No response from AI.'
|
|
@@ -135,11 +232,8 @@ export async function runAgentLoop (chatEntry, config, abortRef, setIsStreaming,
|
|
|
135
232
|
}
|
|
136
233
|
|
|
137
234
|
for (const toolCall of assistantMessage.tool_calls) {
|
|
138
|
-
if (
|
|
139
|
-
|
|
140
|
-
updateChatEntry(chatEntry, {
|
|
141
|
-
response: accumulatedContent + '\n\n*(Agent stopped by user)*'
|
|
142
|
-
})
|
|
235
|
+
if (isAborted()) {
|
|
236
|
+
markStopped()
|
|
143
237
|
return
|
|
144
238
|
}
|
|
145
239
|
|
|
@@ -150,28 +244,32 @@ export async function runAgentLoop (chatEntry, config, abortRef, setIsStreaming,
|
|
|
150
244
|
args = {}
|
|
151
245
|
}
|
|
152
246
|
|
|
153
|
-
|
|
247
|
+
toolCallsLog.push({
|
|
154
248
|
id: toolCall.id,
|
|
155
249
|
name: toolCall.function.name,
|
|
156
250
|
args,
|
|
157
251
|
status: 'running',
|
|
158
252
|
result: null
|
|
159
|
-
}
|
|
160
|
-
toolCallsLog.push(toolEntry)
|
|
253
|
+
})
|
|
161
254
|
updateChatEntry(chatEntry, {
|
|
162
255
|
toolCalls: [...toolCallsLog]
|
|
163
256
|
})
|
|
164
257
|
|
|
165
|
-
let
|
|
258
|
+
let finished
|
|
166
259
|
try {
|
|
167
|
-
toolResult = await executeToolCall(toolCall.function.name, args)
|
|
168
|
-
|
|
169
|
-
toolEntry.result = toolResult
|
|
260
|
+
const toolResult = await executeToolCall(toolCall.function.name, args)
|
|
261
|
+
finished = { status: 'completed', result: toolResult }
|
|
170
262
|
} catch (err) {
|
|
171
|
-
|
|
172
|
-
toolEntry.result = err.message
|
|
263
|
+
finished = { status: 'error', result: err.message }
|
|
173
264
|
}
|
|
174
265
|
|
|
266
|
+
// Swap in a new entry object instead of mutating the running one: the
|
|
267
|
+
// tool call card is memo()'d on that object's identity, so an in-place
|
|
268
|
+
// mutation would leave the card showing "running" forever.
|
|
269
|
+
toolCallsLog[toolCallsLog.length - 1] = {
|
|
270
|
+
...toolCallsLog[toolCallsLog.length - 1],
|
|
271
|
+
...finished
|
|
272
|
+
}
|
|
175
273
|
updateChatEntry(chatEntry, {
|
|
176
274
|
toolCalls: [...toolCallsLog]
|
|
177
275
|
})
|
|
@@ -179,7 +277,7 @@ export async function runAgentLoop (chatEntry, config, abortRef, setIsStreaming,
|
|
|
179
277
|
messages.push({
|
|
180
278
|
role: 'tool',
|
|
181
279
|
tool_call_id: toolCall.id,
|
|
182
|
-
content:
|
|
280
|
+
content: finished.result
|
|
183
281
|
})
|
|
184
282
|
}
|
|
185
283
|
}
|
|
@@ -189,9 +287,54 @@ export async function runAgentLoop (chatEntry, config, abortRef, setIsStreaming,
|
|
|
189
287
|
response: accumulatedContent + '\n\n*(Agent reached maximum iterations)*'
|
|
190
288
|
})
|
|
191
289
|
} finally {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
290
|
+
if (abortRef) {
|
|
291
|
+
abortRef.requestId = null
|
|
292
|
+
}
|
|
293
|
+
// Only the run that still owns the flag may clear it -- a stopped run
|
|
294
|
+
// unwinds a moment later and must not unlock a newer one.
|
|
295
|
+
const ownsRun = activeRunId === runId
|
|
296
|
+
if (ownsRun) {
|
|
297
|
+
activeRunId = null
|
|
298
|
+
window.store.agentRunning = false
|
|
299
|
+
// hand the panel back to the session based estimate: tool results are not
|
|
300
|
+
// carried into the next turn, so the agent figure stops being meaningful
|
|
301
|
+
window.store.aiContextInfo = null
|
|
302
|
+
}
|
|
303
|
+
// A run that compacted its live conversation leaves the stored session
|
|
304
|
+
// untouched, so the next turn would start from the full history again and
|
|
305
|
+
// pay for another summary. Compact the session as well -- once, after the
|
|
306
|
+
// run, when the turns it summarizes are complete. Skipped for a stopped or
|
|
307
|
+
// failed run: those transcripts are not a conversation worth keeping.
|
|
308
|
+
if (ownsRun && autoCompressCount > 0 && !runErrored && !isAborted()) {
|
|
309
|
+
await autoCompressSession(chatEntry.chatSessionId, config)
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Stop the running agent loop, called by the panel's stop button.
|
|
315
|
+
//
|
|
316
|
+
// The loop itself only looks at `abortRef` between iterations, so on its own
|
|
317
|
+
// that flag does nothing while a request is in flight -- and the panel keeps
|
|
318
|
+
// the composer locked for as long as the provider takes to answer (which is
|
|
319
|
+
// forever if it never does, these requests carry no timeout). So this also
|
|
320
|
+
// cancels the in-flight request and releases the composer right away; the loop
|
|
321
|
+
// then unwinds on its own and marks the entry as stopped.
|
|
322
|
+
export async function stopAgentRun (abortRef) {
|
|
323
|
+
if (abortRef) {
|
|
324
|
+
abortRef.current = true
|
|
325
|
+
}
|
|
326
|
+
const requestId = abortRef && abortRef.requestId
|
|
327
|
+
if (abortRef) {
|
|
328
|
+
abortRef.requestId = null
|
|
329
|
+
}
|
|
330
|
+
activeRunId = null
|
|
331
|
+
window.store.agentRunning = false
|
|
332
|
+
window.store.aiContextInfo = null
|
|
333
|
+
if (requestId) {
|
|
334
|
+
try {
|
|
335
|
+
await window.pre.runGlobalAsync('abortAIRequest', requestId)
|
|
336
|
+
} catch (error) {
|
|
337
|
+
console.error('Error aborting agent request:', error)
|
|
338
|
+
}
|
|
196
339
|
}
|
|
197
340
|
}
|
|
@@ -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
|
+
}
|