@electerm/electerm-react 5.5.25 → 5.5.35
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 +3 -0
- package/client/common/constants.js +2 -0
- package/client/common/create-title.jsx +5 -1
- package/client/common/default-setting.js +2 -0
- package/client/common/parse-vv.js +119 -25
- package/client/common/vv-to-tab.js +120 -0
- package/client/components/ai/agent-tool-call-card.jsx +19 -4
- package/client/components/ai/agent.js +33 -16
- package/client/components/ai/ai-chat-history-item.jsx +12 -48
- package/client/components/ai/ai-chat.jsx +82 -25
- package/client/components/ai/ai-config-props.js +12 -1
- package/client/components/ai/ai-config.jsx +14 -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 +4 -0
- package/client/components/ai/ai-presets.js +1 -1
- package/client/components/ai/ai.styl +105 -6
- package/client/components/ai/copy-icon.jsx +31 -0
- 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/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 +14 -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/setting-panel/setting-common.jsx +2 -0
- 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/tree-list/tree-list.jsx +31 -3
- package/client/store/common.js +120 -32
- package/client/store/init-state.js +8 -0
- package/client/store/load-data.js +72 -0
- package/client/store/store.js +6 -0
- package/client/store/watch.js +3 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
</h1>
|
|
6
6
|
|
|
7
7
|
[](https://github.com/electerm/electerm/releases)
|
|
8
|
-
[](https://github.com/electerm/electerm/actions)
|
|
9
8
|
[](https://github.com/electerm/electerm/blob/master/LICENSE)
|
|
10
9
|
[](https://github.com/sponsors/electerm)
|
|
11
10
|
[](https://atomgit.com/electerm/electerm)
|
|
@@ -41,7 +40,7 @@ Besides mainstream Windows/macOS/Linux/Android, electerm also supports HarmonyOS
|
|
|
41
40
|
|
|
42
41
|
<div align="center">
|
|
43
42
|
<a href="https://www.atlascloud.ai/?ref=PCAEL2&utm_source=github&utm_medium=link&utm_campaign=electerm">
|
|
44
|
-
<img src="https://github.com/electerm/electerm-resource/blob/master/static/images/atlas-cloud.png?raw=true" alt="Atlas Cloud"
|
|
43
|
+
<img src="https://github.com/electerm/electerm-resource/blob/master/static/images/atlas-cloud.png?raw=true" alt="Atlas Cloud" width="260" />
|
|
45
44
|
</a>
|
|
46
45
|
</div>
|
|
47
46
|
|
|
@@ -50,12 +49,13 @@ Besides mainstream Windows/macOS/Linux/Android, electerm also supports HarmonyOS
|
|
|
50
49
|
----
|
|
51
50
|
|
|
52
51
|
<div align="center">
|
|
53
|
-
<a href="https://fluxionai.world/register?
|
|
54
|
-
<img src="https://github.com/electerm/electerm-resource/blob/master/static/images/fluxionai.png?raw=true" alt="FluxionAI"
|
|
52
|
+
<a href="https://fluxionai.world/register?source=github&campaign=electerm-20260923&promo=ELECTERM&aff=7H7DERHU3GFF">
|
|
53
|
+
<img src="https://github.com/electerm/electerm-resource/blob/master/static/images/fluxionai.png?raw=true" alt="FluxionAI" width="210" />
|
|
55
54
|
</a>
|
|
56
55
|
</div>
|
|
57
56
|
|
|
58
|
-
[Fluxion AI](https://fluxionai.world/register?
|
|
57
|
+
[Fluxion AI](https://fluxionai.world/register?source=github&campaign=electerm-20260923&promo=ELECTERM&aff=7H7DERHU3GFF) — One gateway to the world's leading AI models, One API, multi-route delivery, transparent usage and pricing, Chinese Models — 60% OFF, Claude & OpenAI — 70% OFF [Register through the partner link
|
|
58
|
+
Get $3 in API credit](https://fluxionai.world/register?source=github&campaign=electerm-20260923&promo=ELECTERM&aff=7H7DERHU3GFF).
|
|
59
59
|
|
|
60
60
|
----
|
|
61
61
|
|
|
@@ -152,6 +152,9 @@ export const spiceBookmarkSchema = {
|
|
|
152
152
|
...commonNetworkBookmarkProps,
|
|
153
153
|
host: z.string().describe('Spice host address'),
|
|
154
154
|
port: z.number().optional().describe('Spice port (default 5900)'),
|
|
155
|
+
tls: z.boolean().optional().describe('Connect over TLS, default false'),
|
|
156
|
+
ca: z.string().optional().describe('CA certificate in PEM format, used to verify the TLS peer'),
|
|
157
|
+
hostSubject: z.string().optional().describe('Expected TLS certificate subject, like CN=pve1,O=Example'),
|
|
155
158
|
viewOnly: z.boolean().optional().describe('View only mode'),
|
|
156
159
|
scaleViewport: z.boolean().optional().describe('Scale viewport to window, default is true')
|
|
157
160
|
}
|
|
@@ -158,6 +158,8 @@ export const pinnedQuickCommandBarKey = 'pinned-quick-command-bar'
|
|
|
158
158
|
export const leftSidePanelWidthKey = 'left-sidebar-width'
|
|
159
159
|
export const rightSidebarWidthKey = 'right-sidebar-width'
|
|
160
160
|
export const rightPanelPinnedKey = 'right-panel-pinned'
|
|
161
|
+
export const cmdHistoryInRightPanelKey = 'cmd-history-in-right-panel'
|
|
162
|
+
export const quickCommandsInRightPanelKey = 'quick-commands-in-right-panel'
|
|
161
163
|
export const addPanelWidthLsKey = 'addPanelWidth'
|
|
162
164
|
export const sftpDefaultSortSettingKey = 'sftp-default-sort'
|
|
163
165
|
export const qmSortByFrequencyKey = 'qm-sort-by-frequency'
|
|
@@ -28,8 +28,12 @@ export default function createTitle (res, hide = true) {
|
|
|
28
28
|
const h = hide && window.store.config.hideIP ? maskHost(host) : host
|
|
29
29
|
const fixTitle = `${username || ''}@${h}:${port}`
|
|
30
30
|
const extra = host || path ? (path || fixTitle) : (url || '')
|
|
31
|
+
// onlyShowTitleInTab: a session that was given its own title shows just that
|
|
32
|
+
// title. One without a title is left alone and keeps the host/user fallback,
|
|
33
|
+
// otherwise the tab would read as empty.
|
|
34
|
+
const onlyTitle = title && window.store.config.onlyShowTitleInTab
|
|
31
35
|
let f = title
|
|
32
|
-
? `${title}` + (extra ? ` - ${extra}` : '')
|
|
36
|
+
? `${title}` + (extra && !onlyTitle ? ` - ${extra}` : '')
|
|
33
37
|
: extra
|
|
34
38
|
if (connectionHoppings && connectionHoppings.length) {
|
|
35
39
|
f = `[⋙]${f}`
|
|
@@ -94,8 +94,10 @@ export default {
|
|
|
94
94
|
dragDropBehavior: 'ask',
|
|
95
95
|
switchTabOnHover: false,
|
|
96
96
|
disableTabIndex: false,
|
|
97
|
+
onlyShowTitleInTab: false,
|
|
97
98
|
disableShortcutBar: false,
|
|
98
99
|
disableConfirmForLargeClipboardContent: false,
|
|
100
|
+
doubleClickToOpenBookmark: false,
|
|
99
101
|
leftSideBarIcons: [
|
|
100
102
|
'newBookmark',
|
|
101
103
|
'quickConnect',
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Behaviour is deliberately matched against:
|
|
10
10
|
* - src/virt-viewer-file.c (gitlab.com/virt-viewer/virt-viewer)
|
|
11
|
+
* - src/virt-viewer-session-spice.c, for which port wins over tls-port
|
|
11
12
|
* - remote-viewer(1), "CONNECTION FILE" section
|
|
12
13
|
*
|
|
13
14
|
* Rejection rules copied from virt_viewer_file_new():
|
|
@@ -16,8 +17,9 @@
|
|
|
16
17
|
* unknown keys/groups -> kept, never an error (the format is
|
|
17
18
|
* extensible; the convention is an `x-` prefix)
|
|
18
19
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
20
|
+
* This is the only copy. .vv loading happens in the renderer only, so there
|
|
21
|
+
* is no src/app/common twin -- unlike sanitize-filename.js, which the main
|
|
22
|
+
* process and the renderer both need.
|
|
21
23
|
*/
|
|
22
24
|
|
|
23
25
|
const MAIN_GROUP = 'virt-viewer'
|
|
@@ -26,24 +28,37 @@ const OVIRT_GROUP = 'ovirt'
|
|
|
26
28
|
/**
|
|
27
29
|
* .vv key -> bookmark field understood by
|
|
28
30
|
* src/client/components/bookmark-form/config/spice.js
|
|
31
|
+
*
|
|
32
|
+
* `port` and `tls-port` are NOT here: which one wins depends on whether the
|
|
33
|
+
* other is present, so they are resolved in parseVv().
|
|
29
34
|
*/
|
|
30
35
|
const FIELD_MAP = {
|
|
31
36
|
host: 'host',
|
|
32
|
-
port: 'port',
|
|
33
37
|
password: 'password',
|
|
34
38
|
title: 'title',
|
|
35
|
-
proxy: 'proxy'
|
|
39
|
+
proxy: 'proxy',
|
|
40
|
+
ca: 'ca',
|
|
41
|
+
'host-subject': 'hostSubject'
|
|
36
42
|
}
|
|
37
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Keys parseVv() consumes itself, so the "not supported" pass must not report
|
|
46
|
+
* them again.
|
|
47
|
+
*/
|
|
48
|
+
const HANDLED = new Set([
|
|
49
|
+
'type',
|
|
50
|
+
'delete-this-file',
|
|
51
|
+
'port',
|
|
52
|
+
'tls-port',
|
|
53
|
+
...Object.keys(FIELD_MAP)
|
|
54
|
+
])
|
|
55
|
+
|
|
38
56
|
/**
|
|
39
57
|
* Keys the .vv format defines but electerm cannot honour for a spice session.
|
|
40
58
|
* Each becomes a user-visible note instead of being dropped silently.
|
|
41
59
|
*/
|
|
42
60
|
const UNSUPPORTED = {
|
|
43
|
-
'tls-
|
|
44
|
-
'tls-ciphers': 'TLS is not supported',
|
|
45
|
-
ca: 'TLS certificate verification is not supported',
|
|
46
|
-
'host-subject': 'TLS certificate verification is not supported',
|
|
61
|
+
'tls-ciphers': 'TLS is used, but the cipher list is not configurable',
|
|
47
62
|
'unix-path': 'Unix-socket transport is not supported',
|
|
48
63
|
username: 'spice sessions authenticate with the password only',
|
|
49
64
|
'disable-channels': 'spice channel selection is not supported',
|
|
@@ -70,6 +85,14 @@ const UNSUPPORTED = {
|
|
|
70
85
|
'usb-device-reset': 'hotkey rebinding is not supported'
|
|
71
86
|
}
|
|
72
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Proxmox writes the SPICE proxy ticket into `host` (see
|
|
90
|
+
* PVE::AccessControl::remote_viewer_config) and puts the real node name inside
|
|
91
|
+
* it. PVE::Ticket::verify_spice_connect_url() only accepts a ticket younger
|
|
92
|
+
* than 40 seconds, so a .vv from the Proxmox UI is strictly single-use.
|
|
93
|
+
*/
|
|
94
|
+
const PROXMOX_TICKET = /^pvespiceproxy:/i
|
|
95
|
+
|
|
73
96
|
const BOOL_TRUE = new Set(['1', 'true', 'yes', 'on'])
|
|
74
97
|
|
|
75
98
|
function stripBom (text) {
|
|
@@ -80,6 +103,49 @@ function isComment (line) {
|
|
|
80
103
|
return line.startsWith('#') || line.startsWith(';')
|
|
81
104
|
}
|
|
82
105
|
|
|
106
|
+
/**
|
|
107
|
+
* GKeyFile escapes \\n, \\t, \\r, \\s and \\\\ inside values, and
|
|
108
|
+
* g_key_file_get_string() expands them on read. parseIni keeps values
|
|
109
|
+
* verbatim (a trailing space in a password is significant), so the one value
|
|
110
|
+
* that is handed to a parser rather than used as-is is expanded here.
|
|
111
|
+
*
|
|
112
|
+
* Proxmox relies on this: it writes the whole CA on a single line with literal
|
|
113
|
+
* "\\n" separators (`$cacert =~ s/\n/\\n/g`), which no PEM parser accepts
|
|
114
|
+
* unexpanded.
|
|
115
|
+
*
|
|
116
|
+
* @param {string} value
|
|
117
|
+
* @returns {string}
|
|
118
|
+
*/
|
|
119
|
+
function unescapeValue (value) {
|
|
120
|
+
return value.replace(/\\(.)/g, (match, char) => {
|
|
121
|
+
switch (char) {
|
|
122
|
+
case 'n': return '\n'
|
|
123
|
+
case 't': return '\t'
|
|
124
|
+
case 'r': return '\r'
|
|
125
|
+
case 's': return ' '
|
|
126
|
+
case '\\': return '\\'
|
|
127
|
+
default: return match
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Read and range-check one of the two port keys.
|
|
134
|
+
* @returns {number|null}
|
|
135
|
+
*/
|
|
136
|
+
function readPort (main, key, result) {
|
|
137
|
+
if (!(key in main)) {
|
|
138
|
+
return null
|
|
139
|
+
}
|
|
140
|
+
const raw = main[key].trim()
|
|
141
|
+
const port = parseInt(raw, 10)
|
|
142
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
143
|
+
result.warnings.push('ignoring invalid ' + key + ' "' + raw + '"')
|
|
144
|
+
return null
|
|
145
|
+
}
|
|
146
|
+
return port
|
|
147
|
+
}
|
|
148
|
+
|
|
83
149
|
/**
|
|
84
150
|
* Parse INI text into { groupName: { key: value } }.
|
|
85
151
|
*
|
|
@@ -192,6 +258,30 @@ function parseVv (text) {
|
|
|
192
258
|
return result
|
|
193
259
|
}
|
|
194
260
|
|
|
261
|
+
// spice-gtk dials the plain port and only falls back to TLS when the file
|
|
262
|
+
// offers no plain port at all (spice-channel.c: "trying with TLS port"), so
|
|
263
|
+
// a file that sets both keeps its plain port here too.
|
|
264
|
+
//
|
|
265
|
+
// Which port key the file used also decides the transport, so `tls` is set
|
|
266
|
+
// on both branches: the form only overwrites the keys it is handed, and a
|
|
267
|
+
// switch left over from a previous file would silently break the connection.
|
|
268
|
+
const plainPort = readPort(main, 'port', result)
|
|
269
|
+
const tlsPort = readPort(main, 'tls-port', result)
|
|
270
|
+
|
|
271
|
+
if (plainPort) {
|
|
272
|
+
result.fields.port = plainPort
|
|
273
|
+
result.fields.tls = false
|
|
274
|
+
result.applied.push({ key: 'port', value: main.port, field: 'port' })
|
|
275
|
+
} else if (tlsPort) {
|
|
276
|
+
result.fields.port = tlsPort
|
|
277
|
+
result.fields.tls = true
|
|
278
|
+
result.applied.push({
|
|
279
|
+
key: 'tls-port',
|
|
280
|
+
value: main['tls-port'],
|
|
281
|
+
field: 'port'
|
|
282
|
+
})
|
|
283
|
+
}
|
|
284
|
+
|
|
195
285
|
Object.keys(FIELD_MAP).forEach(key => {
|
|
196
286
|
if (!(key in main)) {
|
|
197
287
|
return
|
|
@@ -199,14 +289,7 @@ function parseVv (text) {
|
|
|
199
289
|
const raw = main[key]
|
|
200
290
|
const field = FIELD_MAP[key]
|
|
201
291
|
|
|
202
|
-
if (key === '
|
|
203
|
-
const port = parseInt(raw.trim(), 10)
|
|
204
|
-
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
205
|
-
result.warnings.push('ignoring invalid port "' + raw.trim() + '"')
|
|
206
|
-
return
|
|
207
|
-
}
|
|
208
|
-
result.fields.port = port
|
|
209
|
-
} else if (key === 'password') {
|
|
292
|
+
if (key === 'password') {
|
|
210
293
|
result.fields.password = raw
|
|
211
294
|
} else {
|
|
212
295
|
const value = raw.trim()
|
|
@@ -214,7 +297,7 @@ function parseVv (text) {
|
|
|
214
297
|
result.warnings.push('ignoring empty "' + key + '"')
|
|
215
298
|
return
|
|
216
299
|
}
|
|
217
|
-
result.fields[field] = value
|
|
300
|
+
result.fields[field] = key === 'ca' ? unescapeValue(value) : value
|
|
218
301
|
}
|
|
219
302
|
result.applied.push({ key, value: raw, field })
|
|
220
303
|
})
|
|
@@ -224,12 +307,12 @@ function parseVv (text) {
|
|
|
224
307
|
return result
|
|
225
308
|
}
|
|
226
309
|
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
if (
|
|
310
|
+
// The Proxmox ticket in `host` is signed and time limited: the proxy rejects
|
|
311
|
+
// anything older than 40 seconds, so a saved bookmark can never work.
|
|
312
|
+
if (PROXMOX_TICKET.test(result.fields.host)) {
|
|
230
313
|
result.warnings.push(
|
|
231
|
-
'this
|
|
232
|
-
'so
|
|
314
|
+
'this is a Proxmox SPICE proxy ticket: it is only valid for about 40 ' +
|
|
315
|
+
'seconds, so connect right away -- saving it as a bookmark will not work'
|
|
233
316
|
)
|
|
234
317
|
}
|
|
235
318
|
|
|
@@ -248,7 +331,7 @@ function parseVv (text) {
|
|
|
248
331
|
}
|
|
249
332
|
|
|
250
333
|
Object.keys(main).forEach(key => {
|
|
251
|
-
if (key
|
|
334
|
+
if (HANDLED.has(key)) {
|
|
252
335
|
return
|
|
253
336
|
}
|
|
254
337
|
const value = main[key]
|
|
@@ -259,6 +342,17 @@ function parseVv (text) {
|
|
|
259
342
|
}
|
|
260
343
|
})
|
|
261
344
|
|
|
345
|
+
// Both ports offered: the plain one wins (see above), but say so instead of
|
|
346
|
+
// dropping the TLS port in silence.
|
|
347
|
+
if (plainPort && tlsPort) {
|
|
348
|
+
result.ignored.push({
|
|
349
|
+
key: 'tls-port',
|
|
350
|
+
value: main['tls-port'],
|
|
351
|
+
reason:
|
|
352
|
+
'this file also offers a plain port, which is the one remote-viewer uses'
|
|
353
|
+
})
|
|
354
|
+
}
|
|
355
|
+
|
|
262
356
|
if (groups[OVIRT_GROUP]) {
|
|
263
357
|
result.ignored.push({
|
|
264
358
|
key: '[' + OVIRT_GROUP + ']',
|
|
@@ -288,8 +382,8 @@ function describeVv (parsed) {
|
|
|
288
382
|
if (!parsed || !parsed.ok) {
|
|
289
383
|
return (parsed && parsed.error) || 'invalid .vv file'
|
|
290
384
|
}
|
|
291
|
-
const { host, port, title, proxy } = parsed.fields
|
|
292
|
-
const bits = ['spice://' + host + (port ? ':' + port : '')]
|
|
385
|
+
const { host, port, title, proxy, tls } = parsed.fields
|
|
386
|
+
const bits = ['spice' + (tls ? '+tls' : '') + '://' + host + (port ? ':' + port : '')]
|
|
293
387
|
if (title) {
|
|
294
388
|
bits.push('"' + title + '"')
|
|
295
389
|
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* .vv connection file -> electerm Spice tab options
|
|
3
|
+
*
|
|
4
|
+
* Kept apart from parse-vv.js on purpose: parse-vv.js knows the file format
|
|
5
|
+
* and nothing about electerm, this module knows electerm's Spice tab shape and
|
|
6
|
+
* nothing about the format. Staying free of window/api access is what makes it
|
|
7
|
+
* unit-testable, and it is shared by both entry points for a .vv file --
|
|
8
|
+
* the bookmark form's "Load .vv file" button and a file handed to electerm
|
|
9
|
+
* from outside (`electerm /path/to/console.vv`).
|
|
10
|
+
*
|
|
11
|
+
* Deliberately imports nothing: constants.js pulls in the logo .png files and
|
|
12
|
+
* `window`, which would make this unimportable under `node --test`.
|
|
13
|
+
* parse-quick-connect.js spells the type out for the same reason.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The `spice` value of terminalSpiceType (src/client/common/constants.js).
|
|
18
|
+
* A .vv file is a virt-viewer file, and the only type this client implements
|
|
19
|
+
* is spice -- parse-vv.js rejects every other `type=` already.
|
|
20
|
+
*/
|
|
21
|
+
const SPICE_TYPE = 'spice'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Mirrors the Spice defaults in components/bookmark-form/config/spice.js
|
|
25
|
+
* initValues(), so a .vv that omits them behaves exactly like a Spice bookmark
|
|
26
|
+
* added by hand.
|
|
27
|
+
*/
|
|
28
|
+
const SPICE_DEFAULTS = {
|
|
29
|
+
viewOnly: false,
|
|
30
|
+
scaleViewport: true
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Port used when the file names a host but no port at all. Matches
|
|
35
|
+
* DEFAULT_PORTS.spice in parse-quick-connect.js.
|
|
36
|
+
*/
|
|
37
|
+
const DEFAULT_SPICE_PORT = 5900
|
|
38
|
+
|
|
39
|
+
const VV_EXT = /\.vv$/i
|
|
40
|
+
const URL_LIKE = /:\/\//
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Does this look like a .vv file path?
|
|
44
|
+
*
|
|
45
|
+
* A suffix test rather than an exists()/stat() call on purpose: this is asked
|
|
46
|
+
* synchronously while scanning a command line, and the read that follows
|
|
47
|
+
* reports a missing file far better than a bare boolean could.
|
|
48
|
+
*
|
|
49
|
+
* `://` is excluded so a deep link that happens to end in .vv
|
|
50
|
+
* (spice://host/console.vv) is not mistaken for a path -- deep links have their
|
|
51
|
+
* own parser and would otherwise be read off the disk.
|
|
52
|
+
*
|
|
53
|
+
* @param {string} value
|
|
54
|
+
* @returns {boolean}
|
|
55
|
+
*/
|
|
56
|
+
function isVvFile (value) {
|
|
57
|
+
if (typeof value !== 'string') {
|
|
58
|
+
return false
|
|
59
|
+
}
|
|
60
|
+
const text = value.trim()
|
|
61
|
+
return VV_EXT.test(text) && !URL_LIKE.test(text)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Last path segment, for a tab title when the file carries no `title` key.
|
|
66
|
+
* Done by hand because the renderer has no path module.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} filePath
|
|
69
|
+
* @returns {string}
|
|
70
|
+
*/
|
|
71
|
+
function basename (filePath) {
|
|
72
|
+
return String(filePath).split(/[/\\]/).pop() || String(filePath)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Build tab options from a parse result.
|
|
77
|
+
*
|
|
78
|
+
* Only keys the file actually provided are set. An absent `ca` or `hostSubject`
|
|
79
|
+
* must stay absent rather than become an empty string, because the session code
|
|
80
|
+
* reads "no ca" as "do not verify against a pinned CA" -- an empty string there
|
|
81
|
+
* would mean "pin nothing", which fails every handshake.
|
|
82
|
+
*
|
|
83
|
+
* @param {Object} parsed - result of parseVv()
|
|
84
|
+
* @param {Object} [options] - { filePath } plus any tab overrides
|
|
85
|
+
* @returns {Object} options for store.addTab()
|
|
86
|
+
*/
|
|
87
|
+
function vvToTab (parsed, options = {}) {
|
|
88
|
+
const { filePath, ...extra } = options
|
|
89
|
+
const f = parsed.fields
|
|
90
|
+
const tab = {
|
|
91
|
+
type: SPICE_TYPE,
|
|
92
|
+
host: f.host,
|
|
93
|
+
port: f.port || DEFAULT_SPICE_PORT,
|
|
94
|
+
tls: !!f.tls,
|
|
95
|
+
...SPICE_DEFAULTS
|
|
96
|
+
}
|
|
97
|
+
const optional = {
|
|
98
|
+
ca: f.ca,
|
|
99
|
+
hostSubject: f.hostSubject,
|
|
100
|
+
password: f.password,
|
|
101
|
+
proxy: f.proxy,
|
|
102
|
+
title: f.title || (filePath ? basename(filePath) : '')
|
|
103
|
+
}
|
|
104
|
+
Object.keys(optional).forEach(key => {
|
|
105
|
+
if (optional[key]) {
|
|
106
|
+
tab[key] = optional[key]
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
return { ...tab, ...extra }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export {
|
|
113
|
+
isVvFile,
|
|
114
|
+
SPICE_TYPE,
|
|
115
|
+
vvToTab,
|
|
116
|
+
basename,
|
|
117
|
+
VV_EXT,
|
|
118
|
+
DEFAULT_SPICE_PORT,
|
|
119
|
+
SPICE_DEFAULTS
|
|
120
|
+
}
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
CodeOutlined,
|
|
10
10
|
DatabaseOutlined
|
|
11
11
|
} from '@ant-design/icons'
|
|
12
|
+
import CopyIcon from './copy-icon'
|
|
12
13
|
|
|
13
14
|
const toolIcons = {
|
|
14
15
|
send_terminal_command: CodeOutlined,
|
|
@@ -65,6 +66,20 @@ export default function AgentToolCallCard ({ toolCall, autoCollapse }) {
|
|
|
65
66
|
)
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
function renderPre (text) {
|
|
70
|
+
return (
|
|
71
|
+
<div className='agent-tool-pre-wrap'>
|
|
72
|
+
<CopyIcon text={text} className='agent-tool-copy' />
|
|
73
|
+
<pre className='agent-tool-pre'>{text}</pre>
|
|
74
|
+
</div>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const argsText = args && Object.keys(args).length > 0
|
|
79
|
+
? JSON.stringify(args, null, 2)
|
|
80
|
+
: ''
|
|
81
|
+
const resultText = formatResult(result)
|
|
82
|
+
|
|
68
83
|
return (
|
|
69
84
|
<div className={`agent-tool-call-card agent-tool-${status}`}>
|
|
70
85
|
<div
|
|
@@ -79,16 +94,16 @@ export default function AgentToolCallCard ({ toolCall, autoCollapse }) {
|
|
|
79
94
|
</div>
|
|
80
95
|
{expanded && (
|
|
81
96
|
<div className='agent-tool-detail'>
|
|
82
|
-
{
|
|
97
|
+
{argsText && (
|
|
83
98
|
<div className='agent-tool-args'>
|
|
84
99
|
<div className='agent-tool-label'>Arguments:</div>
|
|
85
|
-
|
|
100
|
+
{renderPre(argsText)}
|
|
86
101
|
</div>
|
|
87
102
|
)}
|
|
88
|
-
{
|
|
103
|
+
{resultText && (
|
|
89
104
|
<div className='agent-tool-result'>
|
|
90
105
|
<div className='agent-tool-label'>Result:</div>
|
|
91
|
-
|
|
106
|
+
{renderPre(resultText)}
|
|
92
107
|
</div>
|
|
93
108
|
)}
|
|
94
109
|
</div>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { agentTools, executeToolCall } from './agent-tools'
|
|
2
2
|
import { appendMandatoryGuardrails } from './ai-guardrails'
|
|
3
|
+
import { buildAgentMessages, summarizeContext } from './ai-context'
|
|
3
4
|
|
|
4
5
|
const MAX_ITERATIONS = 150
|
|
5
6
|
|
|
@@ -47,27 +48,35 @@ async function callBackendAIchatWithTools (messages, config) {
|
|
|
47
48
|
)
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
// Publish the size of the context the agent is carrying so the panel can show
|
|
52
|
+
// it. An agent request is not just the conversation: it also carries every
|
|
53
|
+
// tool schema, and each iteration appends the tool results, which is what
|
|
54
|
+
// actually fills the window. `usage` is the provider's own count for the
|
|
55
|
+
// previous request and is kept for comparison -- the estimate is what the
|
|
56
|
+
// *next* request will cost, so that is the number on display.
|
|
57
|
+
function publishContext (messages, config, usage) {
|
|
58
|
+
const info = summarizeContext(messages, {
|
|
59
|
+
model: config.modelAI,
|
|
60
|
+
contextLength: window.store.config.contextLengthAI,
|
|
61
|
+
tools: agentTools
|
|
62
|
+
})
|
|
63
|
+
if (usage && usage.promptTokens) {
|
|
64
|
+
info.measuredTokens = usage.promptTokens
|
|
65
|
+
}
|
|
66
|
+
window.store.aiContextInfo = info
|
|
67
|
+
}
|
|
68
|
+
|
|
50
69
|
export async function runAgentLoop (chatEntry, config, abortRef, setIsStreaming, conversationMessages = null) {
|
|
51
70
|
window.store.agentRunning = true
|
|
52
71
|
try {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
...conversationMessages.filter(m => m.role !== 'system')
|
|
59
|
-
]
|
|
60
|
-
} else {
|
|
61
|
-
messages = [
|
|
62
|
-
{ role: 'system', content: buildAgentSystemPrompt(config) },
|
|
63
|
-
{
|
|
64
|
-
role: 'user',
|
|
65
|
-
content: chatEntry.promptWithAttachments || chatEntry.prompt
|
|
66
|
-
}
|
|
67
|
-
]
|
|
68
|
-
}
|
|
72
|
+
const messages = buildAgentMessages({
|
|
73
|
+
systemPrompt: buildAgentSystemPrompt(config),
|
|
74
|
+
conversationMessages,
|
|
75
|
+
prompt: chatEntry.promptWithAttachments || chatEntry.prompt
|
|
76
|
+
})
|
|
69
77
|
const toolCallsLog = []
|
|
70
78
|
let accumulatedContent = ''
|
|
79
|
+
let lastUsage = null
|
|
71
80
|
|
|
72
81
|
setIsStreaming(true)
|
|
73
82
|
updateChatEntry(chatEntry, {
|
|
@@ -84,7 +93,11 @@ export async function runAgentLoop (chatEntry, config, abortRef, setIsStreaming,
|
|
|
84
93
|
return
|
|
85
94
|
}
|
|
86
95
|
|
|
96
|
+
publishContext(messages, config, lastUsage)
|
|
87
97
|
const result = await callBackendAIchatWithTools(messages, config)
|
|
98
|
+
if (result.usage) {
|
|
99
|
+
lastUsage = result.usage
|
|
100
|
+
}
|
|
88
101
|
|
|
89
102
|
if (result.error) {
|
|
90
103
|
setIsStreaming(false)
|
|
@@ -104,6 +117,7 @@ export async function runAgentLoop (chatEntry, config, abortRef, setIsStreaming,
|
|
|
104
117
|
}
|
|
105
118
|
|
|
106
119
|
messages.push(assistantMessage)
|
|
120
|
+
publishContext(messages, config, lastUsage)
|
|
107
121
|
|
|
108
122
|
if (assistantMessage.content) {
|
|
109
123
|
accumulatedContent += (accumulatedContent ? '\n\n' : '') + assistantMessage.content
|
|
@@ -176,5 +190,8 @@ export async function runAgentLoop (chatEntry, config, abortRef, setIsStreaming,
|
|
|
176
190
|
})
|
|
177
191
|
} finally {
|
|
178
192
|
window.store.agentRunning = false
|
|
193
|
+
// hand the panel back to the session based estimate: tool results are not
|
|
194
|
+
// carried into the next turn, so the agent figure stops being meaningful
|
|
195
|
+
window.store.aiContextInfo = null
|
|
179
196
|
}
|
|
180
197
|
}
|
|
@@ -4,6 +4,7 @@ import AIStopIcon from './ai-stop-icon'
|
|
|
4
4
|
import AgentToolCallCard from './agent-tool-call-card'
|
|
5
5
|
import { runAgentLoop } from './agent'
|
|
6
6
|
import { appendMandatoryGuardrails } from './ai-guardrails'
|
|
7
|
+
import { buildSessionMessages } from './ai-context'
|
|
7
8
|
import {
|
|
8
9
|
Alert,
|
|
9
10
|
Tooltip,
|
|
@@ -49,54 +50,17 @@ export default function AIChatHistoryItem ({ item }) {
|
|
|
49
50
|
return appendMandatoryGuardrails(roleAI + `;用[${lang}]回复`)
|
|
50
51
|
}
|
|
51
52
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
if (sessionEntries[i].compressed) {
|
|
64
|
-
lastCompressIndex = i
|
|
65
|
-
break
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const messages = [
|
|
70
|
-
{ role: 'system', content: buildRole() }
|
|
71
|
-
]
|
|
72
|
-
|
|
73
|
-
// Start from the last compress entry (if any), skip older history
|
|
74
|
-
const startIndex = lastCompressIndex >= 0 ? lastCompressIndex : 0
|
|
75
|
-
for (let i = startIndex; i < sessionEntries.length; i++) {
|
|
76
|
-
const entry = sessionEntries[i]
|
|
77
|
-
if (entry.compressed) {
|
|
78
|
-
messages.push({
|
|
79
|
-
role: 'user',
|
|
80
|
-
content: `Here is a summary of our previous conversation for context:\n\n${entry.response}`
|
|
81
|
-
})
|
|
82
|
-
messages.push({
|
|
83
|
-
role: 'assistant',
|
|
84
|
-
content: 'Understood. I will use this context as we continue.'
|
|
85
|
-
})
|
|
86
|
-
} else {
|
|
87
|
-
messages.push({
|
|
88
|
-
role: 'user',
|
|
89
|
-
// promptWithAttachments carries the inlined file blocks;
|
|
90
|
-
// falls back to the plain prompt for legacy entries
|
|
91
|
-
content: entry.promptWithAttachments || entry.prompt
|
|
92
|
-
})
|
|
93
|
-
if (entry.response && entry.id !== item.id) {
|
|
94
|
-
messages.push({ role: 'assistant', content: entry.response })
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
return messages
|
|
99
|
-
}, [chatSessionId, item.id, item.timestamp])
|
|
53
|
+
// The context this turn sends: the session history up to and including the
|
|
54
|
+
// previous turns, but not this turn's own (still streaming) response.
|
|
55
|
+
// Shared with the context size indicator in ai-chat.jsx so the number shown
|
|
56
|
+
// is built from the same message list that goes out.
|
|
57
|
+
const conversationMessages = useMemo(() => buildSessionMessages({
|
|
58
|
+
history: window.store.aiChatHistory,
|
|
59
|
+
chatSessionId,
|
|
60
|
+
upto: item.timestamp,
|
|
61
|
+
role: buildRole(),
|
|
62
|
+
excludeId: item.id
|
|
63
|
+
}), [chatSessionId, item.id, item.timestamp])
|
|
100
64
|
|
|
101
65
|
const pollStreamContent = useCallback(async (sid) => {
|
|
102
66
|
try {
|