@electerm/electerm-react 5.5.15 → 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 +14 -4
- package/client/common/bookmark-schemas.js +3 -0
- package/client/common/constants.js +16 -0
- package/client/common/create-title.jsx +5 -1
- package/client/common/default-setting.js +4 -2
- package/client/common/parse-vv.js +407 -0
- package/client/common/theme-defaults.js +5 -2
- package/client/common/update-check.js +5 -4
- 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 +13 -2
- package/client/components/ai/ai.styl +105 -6
- package/client/components/ai/copy-icon.jsx +31 -0
- package/client/components/bg/css-overwrite.jsx +16 -1
- package/client/components/bookmark-form/common/fields.jsx +3 -0
- package/client/components/bookmark-form/common/vv-file-field.jsx +138 -0
- package/client/components/bookmark-form/config/spice.js +22 -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 +46 -15
- package/client/components/icons/ai-icon.styl +4 -1
- package/client/components/layout/layout.jsx +12 -13
- package/client/components/main/main.jsx +23 -3
- package/client/components/main/term-fullscreen.styl +7 -0
- package/client/components/quick-commands/qm.styl +58 -0
- package/client/components/quick-commands/quick-command-ai-editor.jsx +255 -0
- package/client/components/quick-commands/quick-command-ai-editor.styl +13 -0
- package/client/components/quick-commands/quick-commands-box.jsx +197 -82
- package/client/components/quick-commands/quick-commands-form-elem.jsx +129 -65
- package/client/components/quick-commands/quick-commands-select.jsx +11 -0
- package/client/components/setting-panel/list.jsx +2 -2
- package/client/components/setting-panel/setting-common.jsx +2 -0
- package/client/components/side-panel-r/right-side-panel.styl +11 -1
- package/client/components/side-panel-r/side-panel-r.jsx +56 -13
- package/client/components/sidebar/bookmark-select.jsx +17 -3
- package/client/components/sidebar/history-item.jsx +20 -4
- package/client/components/sidebar/history.jsx +7 -0
- package/client/components/sidebar/index.jsx +13 -4
- package/client/components/sidebar/side-panel.jsx +8 -3
- package/client/components/sidebar/sidebar-panel.jsx +20 -7
- package/client/components/terminal/shortcut-bar.styl +14 -0
- package/client/components/terminal/unix-timestamp-tooltip.jsx +27 -1
- package/client/components/terminal-info/terminal-info.styl +3 -0
- package/client/components/theme/terminal-theme-list.styl +3 -0
- package/client/components/theme/theme-ai-editor.jsx +2 -2
- package/client/components/theme/theme-form.jsx +2 -2
- package/client/components/theme/theme-list-item.jsx +59 -25
- package/client/components/tree-list/tree-list.jsx +31 -3
- package/client/css/mobile.styl +28 -0
- package/client/store/common.js +164 -32
- package/client/store/init-state.js +10 -1
- package/client/store/load-data.js +72 -0
- package/client/store/sidebar.js +5 -1
- package/client/store/store.js +15 -2
- package/client/store/watch.js +3 -1
- package/package.json +1 -1
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// Context accounting for the AI panel: how big is the conversation we are
|
|
2
|
+
// about to send, and how much of the model's context window does it fill.
|
|
3
|
+
//
|
|
4
|
+
// There is deliberately no tokenizer here. A real one needs the model's
|
|
5
|
+
// vocabulary (tiktoken & friends) and would pull a dependency into the
|
|
6
|
+
// renderer for a number that only has to be good enough to answer "should I
|
|
7
|
+
// compress?". The heuristic below is CJK aware and deliberately pessimistic,
|
|
8
|
+
// so the warning arrives a little early rather than too late. Where the
|
|
9
|
+
// provider reports real usage we show that number alongside the estimate.
|
|
10
|
+
//
|
|
11
|
+
// Pure module -- no window / store access -- so it can be unit tested and so
|
|
12
|
+
// the sender and the indicator cannot drift apart.
|
|
13
|
+
|
|
14
|
+
// Latin text and code average ~4 chars per token; 3.5 is the safe side.
|
|
15
|
+
const CHARS_PER_TOKEN = 3.5
|
|
16
|
+
// CJK is roughly one token per character (a little under for Chinese).
|
|
17
|
+
const TOKENS_PER_CJK_CHAR = 0.8
|
|
18
|
+
// Per message role/delimiter overhead, as counted by the OpenAI tokenizer.
|
|
19
|
+
const PER_MESSAGE_OVERHEAD = 4
|
|
20
|
+
|
|
21
|
+
// BMP ranges that tokenize densely: CJK ideographs, kana, full width forms.
|
|
22
|
+
const CJK_RE = /[\u2e80-\u2eff\u3000-\u303f\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uff00-\uffef]/g
|
|
23
|
+
|
|
24
|
+
export const DEFAULT_CONTEXT_WINDOW = 128000
|
|
25
|
+
export const CONTEXT_WARN_PERCENT = 70
|
|
26
|
+
export const CONTEXT_DANGER_PERCENT = 90
|
|
27
|
+
|
|
28
|
+
// Rough token count of a string. Never exact, never wildly wrong. Called on
|
|
29
|
+
// the whole conversation every time it changes, so it stays a single pass
|
|
30
|
+
// with one regex sweep rather than a per character test.
|
|
31
|
+
export function estimateTokens (text) {
|
|
32
|
+
if (!text) {
|
|
33
|
+
return 0
|
|
34
|
+
}
|
|
35
|
+
const str = typeof text === 'string' ? text : String(text)
|
|
36
|
+
const cjkChars = str.match(CJK_RE)
|
|
37
|
+
const cjk = cjkChars ? cjkChars.length : 0
|
|
38
|
+
const other = str.length - cjk
|
|
39
|
+
return Math.ceil(cjk * TOKENS_PER_CJK_CHAR + other / CHARS_PER_TOKEN)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Tokens for a message array in the OpenAI chat shape (which is also what the
|
|
43
|
+
// anthropic / responses builders are fed before they re-shape it).
|
|
44
|
+
export function estimateMessagesTokens (messages) {
|
|
45
|
+
if (!Array.isArray(messages)) {
|
|
46
|
+
return 0
|
|
47
|
+
}
|
|
48
|
+
let total = 0
|
|
49
|
+
for (const m of messages) {
|
|
50
|
+
if (!m) {
|
|
51
|
+
continue
|
|
52
|
+
}
|
|
53
|
+
total += PER_MESSAGE_OVERHEAD
|
|
54
|
+
total += estimateTokens(m.content)
|
|
55
|
+
if (m.role) {
|
|
56
|
+
total += estimateTokens(m.role)
|
|
57
|
+
}
|
|
58
|
+
if (Array.isArray(m.tool_calls)) {
|
|
59
|
+
for (const tc of m.tool_calls) {
|
|
60
|
+
total += estimateTokens(tc && tc.id)
|
|
61
|
+
total += estimateTokens(tc && tc.function && tc.function.name)
|
|
62
|
+
total += estimateTokens(tc && tc.function && tc.function.arguments)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return total
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Tool schemas are re-sent with every agent request and are far from free --
|
|
70
|
+
// ignoring them would understate agent mode by a few thousand tokens.
|
|
71
|
+
export function estimateToolsTokens (tools) {
|
|
72
|
+
if (!Array.isArray(tools) || !tools.length) {
|
|
73
|
+
return 0
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
return estimateTokens(JSON.stringify(tools))
|
|
77
|
+
} catch (e) {
|
|
78
|
+
return 0
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Best effort hints, checked 2026-09. No provider publishes the window over
|
|
83
|
+
// the API, and relays rename models freely, so this is only a starting point:
|
|
84
|
+
// the "Context length" field in the AI config always wins over it.
|
|
85
|
+
// Longest / most specific pattern first -- the list is scanned in order.
|
|
86
|
+
const MODEL_CONTEXT_WINDOWS = [
|
|
87
|
+
[/qwen-?long/, 10000000],
|
|
88
|
+
|
|
89
|
+
// 1M class
|
|
90
|
+
[/gpt-6|gpt-5\.6|gpt-5-6/, 1050000],
|
|
91
|
+
[/gemini/, 1048576],
|
|
92
|
+
[/claude-(opus|sonnet|fable)-5/, 1000000],
|
|
93
|
+
[/deepseek-v4/, 1000000],
|
|
94
|
+
[/qwen3\.(7|8)|qwen3-max|qwen3\.5-max/, 1000000],
|
|
95
|
+
[/kimi-?k3/, 1048576],
|
|
96
|
+
|
|
97
|
+
// 500k / 400k / 256k class
|
|
98
|
+
[/grok-4\.6/, 500000],
|
|
99
|
+
[/gpt-5/, 400000],
|
|
100
|
+
[/kimi-?k2\.7/, 262144],
|
|
101
|
+
[/grok-4/, 256000],
|
|
102
|
+
|
|
103
|
+
// 200k class
|
|
104
|
+
[/claude/, 200000],
|
|
105
|
+
[/\bo[134]\b/, 200000],
|
|
106
|
+
|
|
107
|
+
// 128k class
|
|
108
|
+
[/gpt-4\.1|gpt-4o|gpt-4-turbo|gpt-4-1106|gpt-4-0125/, 128000],
|
|
109
|
+
[/llama-?[34]/, 131072],
|
|
110
|
+
[/qwen/, 131072],
|
|
111
|
+
[/kimi|moonshot/, 131072],
|
|
112
|
+
[/glm|zhipu|chatglm/, 131072],
|
|
113
|
+
[/ernie|wenxin/, 131072],
|
|
114
|
+
[/grok/, 131072],
|
|
115
|
+
[/mistral-large/, 131072],
|
|
116
|
+
|
|
117
|
+
// older / smaller
|
|
118
|
+
[/deepseek/, 65536],
|
|
119
|
+
[/gpt-4-32k/, 32768],
|
|
120
|
+
[/mixtral|mistral/, 32768],
|
|
121
|
+
[/gpt-3\.5/, 16385],
|
|
122
|
+
[/gpt-4/, 8192],
|
|
123
|
+
[/llama-?2/, 4096]
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
// Returns { size, source } where source is 'config' | 'model' | 'default'.
|
|
127
|
+
export function getContextWindow (model, override) {
|
|
128
|
+
const declared = Number(override)
|
|
129
|
+
if (declared > 0) {
|
|
130
|
+
return { size: declared, source: 'config' }
|
|
131
|
+
}
|
|
132
|
+
const id = String(model || '').toLowerCase()
|
|
133
|
+
for (const [re, size] of MODEL_CONTEXT_WINDOWS) {
|
|
134
|
+
if (re.test(id)) {
|
|
135
|
+
return { size, source: 'model' }
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return { size: DEFAULT_CONTEXT_WINDOW, source: 'default' }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// The single source of truth for "what gets sent": both the request in
|
|
142
|
+
// ai-chat-history-item.jsx and the indicator in ai-chat.jsx go through here,
|
|
143
|
+
// so the number on screen is the number that leaves the machine.
|
|
144
|
+
//
|
|
145
|
+
// Returns null when there is no session to build from -- the sender reads
|
|
146
|
+
// that as "no history", and the main process then falls back to a plain
|
|
147
|
+
// prompt + role request.
|
|
148
|
+
//
|
|
149
|
+
// `upto` limits the window to entries at or before a timestamp (the entry
|
|
150
|
+
// being sent); `excludeId` drops one entry's response, which is what the
|
|
151
|
+
// sender does for its own turn. The indicator omits both to describe the
|
|
152
|
+
// *next* request, which will include every response so far.
|
|
153
|
+
export function buildSessionMessages ({
|
|
154
|
+
history,
|
|
155
|
+
chatSessionId,
|
|
156
|
+
upto = Infinity,
|
|
157
|
+
role = '',
|
|
158
|
+
excludeId = null
|
|
159
|
+
}) {
|
|
160
|
+
if (!chatSessionId) {
|
|
161
|
+
return null
|
|
162
|
+
}
|
|
163
|
+
const entries = (history || [])
|
|
164
|
+
.filter(h => h.chatSessionId === chatSessionId && h.timestamp <= upto)
|
|
165
|
+
.sort((a, b) => a.timestamp - b.timestamp)
|
|
166
|
+
|
|
167
|
+
// A compressed entry replaces everything before it.
|
|
168
|
+
let lastCompressIndex = -1
|
|
169
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
170
|
+
if (entries[i].compressed) {
|
|
171
|
+
lastCompressIndex = i
|
|
172
|
+
break
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const messages = [
|
|
177
|
+
{ role: 'system', content: role }
|
|
178
|
+
]
|
|
179
|
+
|
|
180
|
+
const startIndex = lastCompressIndex >= 0 ? lastCompressIndex : 0
|
|
181
|
+
for (let i = startIndex; i < entries.length; i++) {
|
|
182
|
+
const entry = entries[i]
|
|
183
|
+
if (entry.compressed) {
|
|
184
|
+
messages.push({
|
|
185
|
+
role: 'user',
|
|
186
|
+
content: `Here is a summary of our previous conversation for context:\n\n${entry.response}`
|
|
187
|
+
})
|
|
188
|
+
messages.push({
|
|
189
|
+
role: 'assistant',
|
|
190
|
+
content: 'Understood. I will use this context as we continue.'
|
|
191
|
+
})
|
|
192
|
+
} else {
|
|
193
|
+
messages.push({
|
|
194
|
+
role: 'user',
|
|
195
|
+
// promptWithAttachments carries the inlined file blocks;
|
|
196
|
+
// falls back to the plain prompt for legacy entries
|
|
197
|
+
content: entry.promptWithAttachments || entry.prompt
|
|
198
|
+
})
|
|
199
|
+
if (entry.response && entry.id !== excludeId) {
|
|
200
|
+
messages.push({ role: 'assistant', content: entry.response })
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return messages
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Agent mode keeps the session history but swaps in its own system prompt.
|
|
208
|
+
export function buildAgentMessages ({ systemPrompt, conversationMessages, prompt }) {
|
|
209
|
+
if (conversationMessages && conversationMessages.length > 1) {
|
|
210
|
+
return [
|
|
211
|
+
{ role: 'system', content: systemPrompt },
|
|
212
|
+
...conversationMessages.filter(m => m.role !== 'system')
|
|
213
|
+
]
|
|
214
|
+
}
|
|
215
|
+
return [
|
|
216
|
+
{ role: 'system', content: systemPrompt },
|
|
217
|
+
{ role: 'user', content: prompt }
|
|
218
|
+
]
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Everything the indicator needs, from the messages that will be sent.
|
|
222
|
+
export function summarizeContext (messages, { model, contextLength, tools } = {}) {
|
|
223
|
+
const messagesTokens = estimateMessagesTokens(messages)
|
|
224
|
+
const toolsTokens = estimateToolsTokens(tools)
|
|
225
|
+
const tokens = messagesTokens + toolsTokens
|
|
226
|
+
const win = getContextWindow(model, contextLength)
|
|
227
|
+
return {
|
|
228
|
+
tokens,
|
|
229
|
+
messagesTokens,
|
|
230
|
+
toolsTokens,
|
|
231
|
+
windowSize: win.size,
|
|
232
|
+
windowSource: win.source,
|
|
233
|
+
percent: win.size ? (tokens / win.size) * 100 : null,
|
|
234
|
+
model: model || '',
|
|
235
|
+
measuredTokens: null
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function formatTokens (n) {
|
|
240
|
+
const v = Number(n) || 0
|
|
241
|
+
if (v < 1000) {
|
|
242
|
+
return String(Math.round(v))
|
|
243
|
+
}
|
|
244
|
+
if (v < 10000) {
|
|
245
|
+
return (v / 1000).toFixed(1) + 'k'
|
|
246
|
+
}
|
|
247
|
+
if (v < 1000000) {
|
|
248
|
+
return Math.round(v / 1000) + 'k'
|
|
249
|
+
}
|
|
250
|
+
return (v / 1000000).toFixed(1) + 'M'
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function formatPercent (p) {
|
|
254
|
+
if (p === null || p === undefined || !isFinite(p)) {
|
|
255
|
+
return ''
|
|
256
|
+
}
|
|
257
|
+
// below 10% a whole number would just read as 0%
|
|
258
|
+
return (p > 0 && p < 10 ? p.toFixed(1) : String(Math.round(p))) + '%'
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function getUsageLevel (percent) {
|
|
262
|
+
if (percent === null || percent === undefined || !isFinite(percent)) {
|
|
263
|
+
return 'unknown'
|
|
264
|
+
}
|
|
265
|
+
if (percent >= CONTEXT_DANGER_PERCENT) {
|
|
266
|
+
return 'danger'
|
|
267
|
+
}
|
|
268
|
+
if (percent >= CONTEXT_WARN_PERCENT) {
|
|
269
|
+
return 'warn'
|
|
270
|
+
}
|
|
271
|
+
return 'ok'
|
|
272
|
+
}
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
FlagFilled
|
|
11
11
|
} from '@ant-design/icons'
|
|
12
12
|
import getBrand from './get-brand'
|
|
13
|
+
import CopyIcon from './copy-icon'
|
|
13
14
|
|
|
14
15
|
const e = window.translate
|
|
15
16
|
|
|
@@ -160,6 +161,9 @@ export default function AIOutput ({ item }) {
|
|
|
160
161
|
<>
|
|
161
162
|
{renderBrand()}
|
|
162
163
|
<ReactMarkdown {...mdProps} />
|
|
164
|
+
<div className='ai-output-actions'>
|
|
165
|
+
<CopyIcon text={response} />
|
|
166
|
+
</div>
|
|
163
167
|
</>
|
|
164
168
|
)}
|
|
165
169
|
</div>
|
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
export const defaultAIPresets = [
|
|
2
|
+
{
|
|
3
|
+
id: 'fluxionai',
|
|
4
|
+
nameAI: 'FluxionAI',
|
|
5
|
+
baseURLAI: 'https://fluxionai.world/v1',
|
|
6
|
+
apiPathAI: '/chat/completions',
|
|
7
|
+
modelAI: 'deepseek-v4-flash-0731',
|
|
8
|
+
authHeaderNameAI: 'Authorization: Bearer',
|
|
9
|
+
siteUrl: 'https://fluxionai.world/register?source=github&campaign=electerm-20260923&promo=ELECTERM&aff=7H7DERHU3GFF'
|
|
10
|
+
},
|
|
2
11
|
{
|
|
3
12
|
id: 'atlascloud',
|
|
4
13
|
nameAI: 'AtlasCloud',
|
|
@@ -166,8 +175,10 @@ export const defaultAIPresets = [
|
|
|
166
175
|
|
|
167
176
|
export function getAIPresets () {
|
|
168
177
|
const presets = [...defaultAIPresets]
|
|
169
|
-
|
|
170
|
-
|
|
178
|
+
const customPreset = window.et?.defaultAIPreset
|
|
179
|
+
if (customPreset) {
|
|
180
|
+
// keep the first preset (sponsor) on top, insert after it
|
|
181
|
+
presets.splice(1, 0, customPreset)
|
|
171
182
|
}
|
|
172
183
|
return presets
|
|
173
184
|
}
|
|
@@ -26,11 +26,26 @@
|
|
|
26
26
|
border-radius 3px
|
|
27
27
|
pre
|
|
28
28
|
margin-bottom 0
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
29
|
+
|
|
30
|
+
// copy icons stay visible all the time, never hover only
|
|
31
|
+
.copy-icon,
|
|
32
|
+
.code-action-icon
|
|
33
|
+
color var(--text-dark)
|
|
34
|
+
font-size 14px
|
|
35
|
+
&:hover
|
|
36
|
+
color var(--primary)
|
|
37
|
+
|
|
38
|
+
.code-block-actions
|
|
39
|
+
display flex
|
|
40
|
+
justify-content flex-end
|
|
41
|
+
|
|
42
|
+
// left aligned on purpose: the streaming stop/loading icon is pinned to the
|
|
43
|
+
// bottom right of .chat-history-item, so a right aligned copy icon would sit
|
|
44
|
+
// on top of it while a response is streaming
|
|
45
|
+
.ai-output-actions
|
|
46
|
+
display flex
|
|
47
|
+
justify-content flex-start
|
|
48
|
+
margin-top 6px
|
|
34
49
|
|
|
35
50
|
.ai-stream-output
|
|
36
51
|
position relative
|
|
@@ -223,9 +238,18 @@
|
|
|
223
238
|
font-size 12px
|
|
224
239
|
color var(--text-color-2)
|
|
225
240
|
|
|
241
|
+
.agent-tool-pre-wrap
|
|
242
|
+
position relative
|
|
243
|
+
|
|
244
|
+
.agent-tool-copy
|
|
245
|
+
position absolute
|
|
246
|
+
top 5px
|
|
247
|
+
right 6px
|
|
248
|
+
z-index 1
|
|
249
|
+
|
|
226
250
|
.agent-tool-pre
|
|
227
251
|
background var(--main-dark)
|
|
228
|
-
padding 6px 8px
|
|
252
|
+
padding 6px 26px 6px 8px
|
|
229
253
|
border-radius 3px
|
|
230
254
|
font-size 12px
|
|
231
255
|
max-height 200px
|
|
@@ -313,3 +337,78 @@
|
|
|
313
337
|
&:hover
|
|
314
338
|
border-color var(--primary)
|
|
315
339
|
color var(--text)
|
|
340
|
+
|
|
341
|
+
// context size indicator, right hand side of the ai chat toolbar
|
|
342
|
+
.ai-context-indicator
|
|
343
|
+
display inline-flex
|
|
344
|
+
align-items center
|
|
345
|
+
gap 4px
|
|
346
|
+
flex-shrink 0
|
|
347
|
+
font-size 11px
|
|
348
|
+
line-height 1
|
|
349
|
+
color var(--text-color-2)
|
|
350
|
+
cursor default
|
|
351
|
+
user-select none
|
|
352
|
+
|
|
353
|
+
.ai-context-bar
|
|
354
|
+
display inline-block
|
|
355
|
+
width 34px
|
|
356
|
+
height 5px
|
|
357
|
+
border-radius 3px
|
|
358
|
+
background var(--main-darker)
|
|
359
|
+
overflow hidden
|
|
360
|
+
flex-shrink 0
|
|
361
|
+
|
|
362
|
+
.ai-context-bar-fill
|
|
363
|
+
display block
|
|
364
|
+
height 100%
|
|
365
|
+
border-radius 3px
|
|
366
|
+
background var(--primary)
|
|
367
|
+
transition width 0.2s
|
|
368
|
+
|
|
369
|
+
.ai-context-count
|
|
370
|
+
color var(--text-dark)
|
|
371
|
+
font-variant-numeric tabular-nums
|
|
372
|
+
|
|
373
|
+
.ai-context-percent
|
|
374
|
+
font-variant-numeric tabular-nums
|
|
375
|
+
|
|
376
|
+
.ai-context-warn
|
|
377
|
+
.ai-context-bar-fill
|
|
378
|
+
background #faad14
|
|
379
|
+
.ai-context-percent
|
|
380
|
+
color #faad14
|
|
381
|
+
|
|
382
|
+
.ai-context-danger
|
|
383
|
+
.ai-context-bar-fill
|
|
384
|
+
background #ff4d4f
|
|
385
|
+
.ai-context-percent
|
|
386
|
+
color #ff4d4f
|
|
387
|
+
font-weight 500
|
|
388
|
+
|
|
389
|
+
.ai-context-tip
|
|
390
|
+
font-size 12px
|
|
391
|
+
min-width 210px
|
|
392
|
+
|
|
393
|
+
.ai-context-tip-title
|
|
394
|
+
font-weight 500
|
|
395
|
+
margin-bottom 4px
|
|
396
|
+
|
|
397
|
+
.ai-context-tip-row
|
|
398
|
+
display flex
|
|
399
|
+
justify-content space-between
|
|
400
|
+
gap 12px
|
|
401
|
+
|
|
402
|
+
.ai-context-tip-total
|
|
403
|
+
font-weight 500
|
|
404
|
+
border-top 1px solid rgba(128, 128, 128, 0.35)
|
|
405
|
+
margin-top 2px
|
|
406
|
+
padding-top 2px
|
|
407
|
+
|
|
408
|
+
.ai-context-tip-note
|
|
409
|
+
margin-top 4px
|
|
410
|
+
opacity 0.75
|
|
411
|
+
|
|
412
|
+
.ai-context-tip-warn
|
|
413
|
+
margin-top 4px
|
|
414
|
+
color #faad14
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { CopyOutlined } from '@ant-design/icons'
|
|
2
|
+
import { copy } from '../../common/clipboard'
|
|
3
|
+
|
|
4
|
+
const e = window.translate
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Always visible copy icon, shared by AI output and agent tool call blocks
|
|
8
|
+
* @param {Object} props
|
|
9
|
+
* @param {string} props.text - text to copy
|
|
10
|
+
* @param {string} props.className - extra class for positioning
|
|
11
|
+
* @param {string} props.title - tooltip title
|
|
12
|
+
*/
|
|
13
|
+
export default function CopyIcon ({ text, className = '', title }) {
|
|
14
|
+
function handleCopy (evt) {
|
|
15
|
+
// agent tool card header toggles expanded state on click
|
|
16
|
+
evt.stopPropagation()
|
|
17
|
+
copy(text)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (!text) {
|
|
21
|
+
return null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<CopyOutlined
|
|
26
|
+
className={`copy-icon pointer iblock${className ? ' ' + className : ''}`}
|
|
27
|
+
onClick={handleCopy}
|
|
28
|
+
title={title || e('copy')}
|
|
29
|
+
/>
|
|
30
|
+
)
|
|
31
|
+
}
|
|
@@ -93,10 +93,25 @@ async function createStyleForTab (tab, props) {
|
|
|
93
93
|
'background-image: none'
|
|
94
94
|
)
|
|
95
95
|
}
|
|
96
|
-
} else if (st
|
|
96
|
+
} else if (st === 'none') {
|
|
97
|
+
// The [🚫] opt-out must be spelled out: this rule has a higher specificity
|
|
98
|
+
// than the global one, but an empty declaration block would still let the
|
|
99
|
+
// built-in default bg painted by createGlobalStyle show through.
|
|
100
|
+
styles.push('background-image: none')
|
|
101
|
+
} else {
|
|
97
102
|
styles.push(
|
|
98
103
|
`background-image: ${st}`,
|
|
99
104
|
'background-position: center',
|
|
105
|
+
// createGlobalStyle's built-in default bg rule carries an auto-fit
|
|
106
|
+
// `background-size` (so the 766px wide watermark is not cropped on
|
|
107
|
+
// narrow panes), and for the active tab it targets this very same
|
|
108
|
+
// ::before. `background-size` is not reset by a rule that only sets
|
|
109
|
+
// `background-image`, so it would leak onto the user's image and scale
|
|
110
|
+
// it down to at most 766px, centred — a big image that used to fill the
|
|
111
|
+
// pane would no longer cover it. Stating the initial value keeps user
|
|
112
|
+
// images at their natural size, which is what they rendered at before
|
|
113
|
+
// the auto-fit was introduced.
|
|
114
|
+
'background-size: auto',
|
|
100
115
|
`filter: ${createFilterStyle(props, tab)}`
|
|
101
116
|
)
|
|
102
117
|
}
|
|
@@ -20,6 +20,7 @@ import TriggersField from '../../triggers/triggers-field.jsx'
|
|
|
20
20
|
import ProfileItem from './profile-item.jsx'
|
|
21
21
|
import renderRunScripts from './run-scripts.jsx'
|
|
22
22
|
import SerialPathSelector from './serial-path-selector.jsx'
|
|
23
|
+
import VvFileField from './vv-file-field.jsx'
|
|
23
24
|
import SshHostSelector from './ssh-host-selector.jsx'
|
|
24
25
|
import SshAuthTypeSelector from './ssh-auth-type-selector.jsx'
|
|
25
26
|
import SshAuthSelector from './ssh-auth-selector.jsx'
|
|
@@ -150,6 +151,8 @@ export function renderFormItem (item, formItemLayout, form, ctxProps, index) {
|
|
|
150
151
|
)
|
|
151
152
|
case 'proxy':
|
|
152
153
|
return <ProxyField key={name} bookmarks={ctxProps.bookmarks} />
|
|
154
|
+
case 'vvFile':
|
|
155
|
+
return <VvFileField key={name} form={form} />
|
|
153
156
|
case 'x11':
|
|
154
157
|
return <X11Field key={name} form={form} />
|
|
155
158
|
case 'sshTunnels':
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bookmark form - load a .vv (virt-viewer) connection file
|
|
3
|
+
*
|
|
4
|
+
* Registered as field type 'vvFile' in ./fields.jsx and used by
|
|
5
|
+
* ../config/spice.js. Fills host/port/password/title/proxy/tls/ca/hostSubject
|
|
6
|
+
* from the file and reports anything it could not honour instead of dropping
|
|
7
|
+
* it silently.
|
|
8
|
+
*/
|
|
9
|
+
import { useState } from 'react'
|
|
10
|
+
import {
|
|
11
|
+
Form,
|
|
12
|
+
Button,
|
|
13
|
+
Alert
|
|
14
|
+
} from 'antd'
|
|
15
|
+
import { FileAddOutlined } from '@ant-design/icons'
|
|
16
|
+
import fs from '../../../common/fs'
|
|
17
|
+
import { tailFormItemLayout } from '../../../common/form-layout'
|
|
18
|
+
import { parseVv, describeVv } from '../../../common/parse-vv'
|
|
19
|
+
|
|
20
|
+
const FormItem = Form.Item
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* window.translate echoes an unknown key back capitalised, so an unmapped
|
|
24
|
+
* 'loadVvFile' would render as "LoadVvFile". The key IS in
|
|
25
|
+
* ~/dev/electerm-locales, but the app consumes the published
|
|
26
|
+
* @electerm/electerm-locales, so it stays unknown until that ships and the dep
|
|
27
|
+
* is bumped. Fall back to the literal, which is what en_us resolves to anyway
|
|
28
|
+
* (capitalizeFirstLetter('load .vv file')), so nothing changes visually when
|
|
29
|
+
* the translation does land.
|
|
30
|
+
*/
|
|
31
|
+
function vvLabel () {
|
|
32
|
+
const t = window.translate('loadVvFile')
|
|
33
|
+
return t === 'LoadVvFile' ? 'Load .vv file' : t
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Open the native file dialog, filtered to .vv files.
|
|
38
|
+
* window.api.openDialog is dialog.showOpenDialogSync in the main process
|
|
39
|
+
* (see src/app/lib/ipc.js), so the full Electron options object is accepted.
|
|
40
|
+
* @returns {Promise<string|null>} selected path, or null when cancelled
|
|
41
|
+
*/
|
|
42
|
+
async function pickVvFile () {
|
|
43
|
+
const files = await window.api.openDialog({
|
|
44
|
+
title: 'Choose a .vv connection file',
|
|
45
|
+
message: 'Choose a .vv connection file',
|
|
46
|
+
properties: [
|
|
47
|
+
'openFile',
|
|
48
|
+
'showHiddenFiles',
|
|
49
|
+
'noResolveAliases',
|
|
50
|
+
'treatPackageAsDirectory',
|
|
51
|
+
'dontAddToRecent'
|
|
52
|
+
],
|
|
53
|
+
filters: [
|
|
54
|
+
{ name: 'Virt Viewer connection file', extensions: ['vv'] },
|
|
55
|
+
{ name: 'All files', extensions: ['*'] }
|
|
56
|
+
]
|
|
57
|
+
}).catch(() => false)
|
|
58
|
+
if (!files || !files.length) {
|
|
59
|
+
return null
|
|
60
|
+
}
|
|
61
|
+
return files[0]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export default function VvFileField ({ form }) {
|
|
65
|
+
const [hint, setHint] = useState(null)
|
|
66
|
+
const [loading, setLoading] = useState(false)
|
|
67
|
+
|
|
68
|
+
const load = async () => {
|
|
69
|
+
setLoading(true)
|
|
70
|
+
setHint(null)
|
|
71
|
+
try {
|
|
72
|
+
const filePath = await pickVvFile()
|
|
73
|
+
if (!filePath) {
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
// fs.readFile resolves the file as a utf8 string (src/app/lib/fs.js)
|
|
77
|
+
const text = await fs.readFile(filePath)
|
|
78
|
+
const parsed = parseVv(text)
|
|
79
|
+
|
|
80
|
+
if (!parsed.ok) {
|
|
81
|
+
setHint({ type: 'error', text: describeVv(parsed) })
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Only push the keys the file actually defined, so loading a .vv on top
|
|
86
|
+
// of a partly filled form does not wipe the other fields.
|
|
87
|
+
form.setFieldsValue(parsed.fields)
|
|
88
|
+
|
|
89
|
+
const notes = parsed.warnings.slice()
|
|
90
|
+
if (parsed.ignored.length) {
|
|
91
|
+
notes.push(
|
|
92
|
+
'skipped: ' + parsed.ignored.map(d => d.key).join(', ')
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
if (parsed.unknown.length) {
|
|
96
|
+
notes.push(
|
|
97
|
+
'unrecognised: ' + parsed.unknown.map(d => d.key).join(', ')
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const summary = describeVv(parsed)
|
|
102
|
+
setHint({
|
|
103
|
+
type: notes.length ? 'warning' : 'success',
|
|
104
|
+
text: summary + (notes.length ? ' -- ' + notes.join('; ') : '')
|
|
105
|
+
})
|
|
106
|
+
} catch (err) {
|
|
107
|
+
setHint({ type: 'error', text: 'cannot read file: ' + err.message })
|
|
108
|
+
} finally {
|
|
109
|
+
setLoading(false)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<FormItem
|
|
115
|
+
{...tailFormItemLayout}
|
|
116
|
+
>
|
|
117
|
+
<Button
|
|
118
|
+
icon={<FileAddOutlined />}
|
|
119
|
+
loading={loading}
|
|
120
|
+
onClick={load}
|
|
121
|
+
>
|
|
122
|
+
{vvLabel()}
|
|
123
|
+
</Button>
|
|
124
|
+
{
|
|
125
|
+
hint && (
|
|
126
|
+
<Alert
|
|
127
|
+
className='mg1t'
|
|
128
|
+
type={hint.type}
|
|
129
|
+
title={hint.text}
|
|
130
|
+
showIcon
|
|
131
|
+
closable
|
|
132
|
+
onClose={() => setHint(null)}
|
|
133
|
+
/>
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
</FormItem>
|
|
137
|
+
)
|
|
138
|
+
}
|
|
@@ -12,6 +12,9 @@ const spiceConfig = {
|
|
|
12
12
|
initValues: (props) => {
|
|
13
13
|
return createBaseInitValues(props, terminalSpiceType, {
|
|
14
14
|
port: 5900,
|
|
15
|
+
tls: false,
|
|
16
|
+
ca: '',
|
|
17
|
+
hostSubject: '',
|
|
15
18
|
viewOnly: false,
|
|
16
19
|
scaleViewport: true,
|
|
17
20
|
connectionHoppings: [],
|
|
@@ -26,8 +29,27 @@ const spiceConfig = {
|
|
|
26
29
|
fields: [
|
|
27
30
|
commonFields.category,
|
|
28
31
|
commonFields.colorTitle,
|
|
32
|
+
{ type: 'vvFile', name: '__vv__', label: '' },
|
|
29
33
|
{ type: 'input', name: 'host', label: () => e('host'), rules: [{ required: true, message: e('host') + ' required' }] },
|
|
30
34
|
commonFields.port,
|
|
35
|
+
{ type: 'switch', name: 'tls', label: 'TLS', valuePropName: 'checked' },
|
|
36
|
+
{
|
|
37
|
+
type: 'textarea',
|
|
38
|
+
name: 'ca',
|
|
39
|
+
label: 'CA certificate (PEM)',
|
|
40
|
+
props: {
|
|
41
|
+
autoSize: { minRows: 2, maxRows: 8 },
|
|
42
|
+
placeholder: '-----BEGIN CERTIFICATE-----'
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
type: 'input',
|
|
47
|
+
name: 'hostSubject',
|
|
48
|
+
label: 'Certificate subject',
|
|
49
|
+
props: {
|
|
50
|
+
placeholder: 'CN=pve1,O=Proxmox Virtual Environment'
|
|
51
|
+
}
|
|
52
|
+
},
|
|
31
53
|
{ type: 'switch', name: 'viewOnly', label: () => e('viewOnly'), valuePropName: 'checked' },
|
|
32
54
|
{ type: 'switch', name: 'scaleViewport', label: () => e('scaleViewport'), valuePropName: 'checked' },
|
|
33
55
|
{ type: 'profileItem', name: '__profile__', label: '', profileFilter: d => !isEmpty(d.spice) },
|