@electerm/electerm-react 5.5.9 → 5.5.25

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.
Files changed (62) hide show
  1. package/README.md +12 -2
  2. package/client/common/constants.js +14 -0
  3. package/client/common/default-setting.js +2 -2
  4. package/client/common/download.jsx +12 -0
  5. package/client/common/parse-vv.js +313 -0
  6. package/client/common/resolve.js +20 -14
  7. package/client/common/theme-defaults.js +5 -2
  8. package/client/common/update-check.js +5 -4
  9. package/client/components/ai/ai-history-item.jsx +1 -1
  10. package/client/components/ai/ai-history.jsx +1 -0
  11. package/client/components/ai/ai-history.styl +23 -0
  12. package/client/components/ai/ai-presets.js +13 -2
  13. package/client/components/bg/css-overwrite.jsx +16 -1
  14. package/client/components/bookmark-form/common/color-picker.styl +3 -0
  15. package/client/components/bookmark-form/common/fields.jsx +3 -0
  16. package/client/components/bookmark-form/common/vv-file-field.jsx +137 -0
  17. package/client/components/bookmark-form/config/spice.js +1 -0
  18. package/client/components/common/remote-pointer.js +49 -0
  19. package/client/components/common/remote-session-shell.jsx +137 -0
  20. package/client/components/common/remote-session-shell.styl +80 -0
  21. package/client/components/common/responsive-tabs.jsx +89 -0
  22. package/client/components/common/responsive-tabs.styl +34 -0
  23. package/client/components/footer/footer-entry.jsx +39 -14
  24. package/client/components/icons/ai-icon.styl +4 -1
  25. package/client/components/layout/layout.jsx +7 -1
  26. package/client/components/main/main.jsx +9 -2
  27. package/client/components/main/term-fullscreen.styl +7 -0
  28. package/client/components/quick-commands/quick-command-ai-editor.jsx +255 -0
  29. package/client/components/quick-commands/quick-command-ai-editor.styl +13 -0
  30. package/client/components/quick-commands/quick-commands-box.jsx +23 -5
  31. package/client/components/quick-commands/quick-commands-form-elem.jsx +129 -65
  32. package/client/components/rdp/rdp-session.jsx +94 -124
  33. package/client/components/rdp/rdp.styl +13 -36
  34. package/client/components/session/session.styl +0 -16
  35. package/client/components/setting-panel/list.jsx +2 -2
  36. package/client/components/setting-panel/setting-modal.jsx +8 -3
  37. package/client/components/sftp/sftp-entry.jsx +26 -0
  38. package/client/components/side-panel-r/right-side-panel.styl +11 -1
  39. package/client/components/side-panel-r/side-panel-r.jsx +45 -9
  40. package/client/components/sidebar/bookmark-select.jsx +3 -1
  41. package/client/components/sidebar/index.jsx +13 -4
  42. package/client/components/sidebar/side-panel.jsx +8 -3
  43. package/client/components/sidebar/sidebar-panel.jsx +20 -7
  44. package/client/components/spice/spice-session.jsx +69 -72
  45. package/client/components/spice/spice.styl +10 -19
  46. package/client/components/terminal/shortcut-bar.styl +14 -0
  47. package/client/components/terminal/unix-timestamp-tooltip.jsx +27 -1
  48. package/client/components/terminal-info/terminal-info.styl +3 -0
  49. package/client/components/theme/terminal-theme-list.styl +3 -0
  50. package/client/components/theme/theme-ai-editor.jsx +2 -2
  51. package/client/components/theme/theme-form.jsx +20 -23
  52. package/client/components/theme/theme-form.styl +8 -10
  53. package/client/components/theme/theme-list-item.jsx +59 -25
  54. package/client/components/vnc/vnc-session.jsx +72 -104
  55. package/client/components/vnc/vnc.styl +25 -16
  56. package/client/css/mobile.styl +28 -0
  57. package/client/store/common.js +44 -0
  58. package/client/store/init-state.js +2 -1
  59. package/client/store/setting.js +4 -0
  60. package/client/store/sidebar.js +5 -1
  61. package/client/store/store.js +9 -2
  62. package/package.json +1 -1
@@ -37,6 +37,13 @@
37
37
  right 10px !important
38
38
  bottom 10px !important
39
39
 
40
+ // RDP/VNC/Spice fullscreen has no footer (it is in the hide list above), so
41
+ // the right panel has nothing to clear and may use the full height. When the
42
+ // footer IS kept, see .fullscreen.fs-with-footer below.
43
+ .fullscreen:not(.fs-with-footer)
44
+ .right-side-panel
45
+ bottom 0
46
+
40
47
  // Terminal fullscreen keeps the footer (and the quick-command popup above it)
41
48
  // visible; the session shrinks to sit on top of the footer. RDP/VNC/Spice
42
49
  // fullscreen stays footer-less — inActiveTerminal is false for those tabs, so
@@ -0,0 +1,255 @@
1
+ /**
2
+ * AI-powered quick command generator.
3
+ *
4
+ * This is the "create with AI" tab of the quick command form: describe the
5
+ * quick command in natural language, the configured AI provider returns the
6
+ * quick command (name + sub commands in JSON), and the result is written back
7
+ * into the very same form through `onChange`, so the user reviews/edits it in
8
+ * the manual tab before saving.
9
+ */
10
+ import { useState, useEffect } from 'react'
11
+ import { Button, Input, Space, Alert } from 'antd'
12
+ import {
13
+ RobotOutlined,
14
+ LoadingOutlined
15
+ } from '@ant-design/icons'
16
+ import message from '../common/message'
17
+ import { appendMandatoryGuardrails } from '../ai/ai-guardrails'
18
+ import AiHistory, { addHistoryItem } from '../ai/ai-history.jsx'
19
+ import HelpIcon from '../common/help-icon'
20
+ import { getItem, setItem } from '../../common/safe-local-storage'
21
+ import templates from './templates'
22
+ import './quick-command-ai-editor.styl'
23
+
24
+ const STORAGE_KEY_DESC = 'ai_quick_command_description'
25
+ const STORAGE_KEY_HISTORY = 'ai_quick_command_history'
26
+ const EVENT_NAME_HISTORY = 'ai-quick-command-history-update'
27
+ const defaultDelay = 100
28
+ const maxDelay = 65535
29
+ const maxNameLength = 60
30
+ const { TextArea } = Input
31
+ const e = window.translate
32
+
33
+ const wiki = 'https://github.com/electerm/electerm/wiki/Create-quick-command-by-AI'
34
+
35
+ /**
36
+ * build the prompt that asks the AI for one quick command definition
37
+ * @param {string} description
38
+ * @return {string}
39
+ */
40
+ export function buildPrompt (description) {
41
+ const lang = window.store.config.languageAI || window.store.getLangName()
42
+ const templateList = templates.map(t => `{{${t}}}`).join(', ')
43
+ return `You are an electerm quick command generator. Based on the user's natural language description, generate ONE quick command definition in JSON format.
44
+
45
+ The quick command JSON shape:
46
+ {
47
+ "name": "string (required) - short name of the quick command",
48
+ "commands": [
49
+ {
50
+ "name": "string - short name of this step",
51
+ "command": "string (required) - the exact shell command to send to the terminal",
52
+ "delay": "number - milliseconds to wait before sending this step, 1-${maxDelay}, default ${defaultDelay}"
53
+ }
54
+ ],
55
+ "inputOnly": "boolean - true means only type the command into the terminal without pressing enter, default false",
56
+ "labels": "array of strings - optional tags used to group quick commands"
57
+ }
58
+
59
+ Important rules:
60
+ 1. Respond with ONE quick command object, never an array of quick commands
61
+ 2. Split a multi step operation into several "commands" items, one shell command per item, and give a later step a longer "delay" when the step before it takes time
62
+ 3. "command" must be a plain shell command, no markdown, no code fence, no trailing newline
63
+ 4. Keep "name" short (max ${maxNameLength} chars) and write it in ${lang} language, labels included
64
+ 5. You may use the built in templates ${templateList} inside a command when the user asks for clipboard content, current time or current date
65
+ 6. Never invent hosts, paths, ports or credentials the user did not mention
66
+ 7. Respond ONLY with valid JSON, no markdown formatting and no extra explanation
67
+
68
+ User description: ${description}
69
+
70
+ Generate the quick command JSON:`
71
+ }
72
+
73
+ function stripFence (str) {
74
+ let s = str.trim()
75
+ if (s.startsWith('```json')) {
76
+ s = s.slice(7)
77
+ } else if (s.startsWith('```')) {
78
+ s = s.slice(3)
79
+ }
80
+ if (s.endsWith('```')) {
81
+ s = s.slice(0, -3)
82
+ }
83
+ return s.trim()
84
+ }
85
+
86
+ /**
87
+ * Normalize whatever the model returned into a quick command definition the
88
+ * quick command form can consume. Tolerates markdown fences, prose around the
89
+ * JSON, an array payload and string commands.
90
+ * @param {string|object} raw
91
+ * @return {object|null} {name, commands, inputOnly, labels}
92
+ */
93
+ export function normalizeQuickCommand (raw) {
94
+ if (!raw) {
95
+ return null
96
+ }
97
+ let data = raw
98
+ if (typeof data === 'string') {
99
+ const txt = stripFence(data)
100
+ try {
101
+ data = JSON.parse(txt)
102
+ } catch (err) {
103
+ // the model sometimes wraps the JSON in explanation text
104
+ const match = txt.match(/[{[][\s\S]*[}\]]/)
105
+ if (!match) {
106
+ return null
107
+ }
108
+ try {
109
+ data = JSON.parse(match[0])
110
+ } catch (err2) {
111
+ return null
112
+ }
113
+ }
114
+ }
115
+ if (Array.isArray(data)) {
116
+ data = data[0]
117
+ }
118
+ if (!data || typeof data !== 'object') {
119
+ return null
120
+ }
121
+ const rawCommands = Array.isArray(data.commands)
122
+ ? data.commands
123
+ : (data.command ? [data] : [])
124
+ const commands = rawCommands.map(c => {
125
+ if (typeof c === 'string') {
126
+ return {
127
+ name: '',
128
+ command: c,
129
+ delay: defaultDelay
130
+ }
131
+ }
132
+ const delay = Number(c.delay)
133
+ return {
134
+ name: (c.name || '').toString(),
135
+ command: (c.command || c.cmd || '').toString(),
136
+ delay: delay >= 1 ? Math.min(Math.round(delay), maxDelay) : defaultDelay
137
+ }
138
+ }).filter(c => c.command.trim())
139
+ if (!commands.length) {
140
+ return null
141
+ }
142
+ const name = (data.name || data.title || '').toString().trim()
143
+ return {
144
+ name: name || commands[0].command.slice(0, maxNameLength),
145
+ commands,
146
+ inputOnly: !!data.inputOnly,
147
+ labels: Array.isArray(data.labels)
148
+ ? data.labels.map(l => l.toString()).filter(Boolean)
149
+ : []
150
+ }
151
+ }
152
+
153
+ export default function QuickCommandAiEditor (props) {
154
+ const { onChange } = props
155
+ const [description, setDescription] = useState(() => getItem(STORAGE_KEY_DESC) || '')
156
+ const [loading, setLoading] = useState(false)
157
+ const [preview, setPreview] = useState('')
158
+
159
+ useEffect(() => {
160
+ setItem(STORAGE_KEY_DESC, description)
161
+ }, [description])
162
+
163
+ async function handleGenerate () {
164
+ if (window.store.aiConfigMissing()) {
165
+ window.store.toggleAIConfig()
166
+ return
167
+ }
168
+ if (!description.trim()) {
169
+ return message.warning(e('description') + ' ' + e('required'))
170
+ }
171
+ setLoading(true)
172
+ try {
173
+ const config = window.store.config
174
+ const aiResponse = await window.pre.runGlobalAsync(
175
+ 'AIchat',
176
+ buildPrompt(description),
177
+ config.modelAI,
178
+ appendMandatoryGuardrails('You are a helpful assistant that generates electerm quick commands.'),
179
+ config.baseURLAI,
180
+ config.apiPathAI,
181
+ config.apiKeyAI,
182
+ config.proxyAI,
183
+ false, // disable streaming, we need the whole JSON at once
184
+ config.authHeaderNameAI
185
+ )
186
+ if (aiResponse && aiResponse.error) {
187
+ throw new Error(aiResponse.error)
188
+ }
189
+ const data = normalizeQuickCommand(aiResponse && aiResponse.response)
190
+ if (!data) {
191
+ throw new Error('no quick command found in AI response')
192
+ }
193
+ setPreview(JSON.stringify(data, null, 2))
194
+ addHistoryItem(STORAGE_KEY_HISTORY, description, EVENT_NAME_HISTORY)
195
+ onChange(data)
196
+ message.success(e('Done'))
197
+ } catch (err) {
198
+ console.error('AI quick command generation error:', err)
199
+ message.error('Can not generate quick command from AI response: ' + err.message)
200
+ } finally {
201
+ setLoading(false)
202
+ }
203
+ }
204
+
205
+ const textAreaProps = {
206
+ value: description,
207
+ onChange: ev => setDescription(ev.target.value),
208
+ placeholder: e('description'),
209
+ autoSize: { minRows: 4, maxRows: 8 },
210
+ disabled: loading
211
+ }
212
+
213
+ const generateBtnProps = {
214
+ type: 'primary',
215
+ onClick: handleGenerate,
216
+ disabled: !description.trim(),
217
+ icon: loading ? <LoadingOutlined /> : <RobotOutlined />,
218
+ loading
219
+ }
220
+
221
+ return (
222
+ <div className='qm-ai-editor'>
223
+ <div className='pd1b'>
224
+ <Alert
225
+ type='info'
226
+ showIcon
227
+ title={e('aiSecurityNotice')}
228
+ />
229
+ </div>
230
+ <div className='pd1b'>
231
+ <TextArea {...textAreaProps} />
232
+ </div>
233
+ <AiHistory
234
+ storageKey={STORAGE_KEY_HISTORY}
235
+ eventName={EVENT_NAME_HISTORY}
236
+ onSelect={setDescription}
237
+ />
238
+ <div className='pd1t'>
239
+ <Space>
240
+ <Button {...generateBtnProps}>
241
+ {e('submit')}
242
+ </Button>
243
+ <HelpIcon link={wiki} />
244
+ </Space>
245
+ </div>
246
+ {
247
+ !!preview && (
248
+ <div className='pd1t'>
249
+ <pre className='qm-ai-preview'>{preview}</pre>
250
+ </div>
251
+ )
252
+ }
253
+ </div>
254
+ )
255
+ }
@@ -0,0 +1,13 @@
1
+ // "create with AI" tab of the quick command form
2
+ .qm-ai-editor
3
+ // the generated quick command, kept visible for reference after it has been
4
+ // written back into the form
5
+ .qm-ai-preview
6
+ padding 12px
7
+ margin 0
8
+ max-height 300px
9
+ overflow auto
10
+ font-size 12px
11
+ font-family monospace
12
+ white-space pre-wrap
13
+ word-break break-word
@@ -163,7 +163,11 @@ export default function QuickCommandsFooterBox (props) {
163
163
  inActiveTerminal,
164
164
  leftSidePanelWidth,
165
165
  leftSideBarWidth,
166
- openedSideBar
166
+ openedSideBar,
167
+ rightPanelVisible,
168
+ rightPanelPinned,
169
+ rightPanelWidth,
170
+ isMobile
167
171
  } = props
168
172
  if ((!openQuickCommandBar && !pinnedQuickCommandBar) || !inActiveTerminal) {
169
173
  return null
@@ -189,12 +193,26 @@ export default function QuickCommandsFooterBox (props) {
189
193
  : 'text'
190
194
  const cls = classNames('qm-list-wrap')
191
195
  const type = qmSortByFrequency ? 'primary' : 'default'
192
- const w = openedSideBar ? leftSideBarWidth + leftSidePanelWidth : leftSideBarWidth
196
+ // Mirrors the footer's left offset. Mobile reserves nothing for the side
197
+ // panel — it is a full-screen drawer there (store.leftSidePanelWidth returns
198
+ // the viewport width), so adding it would push the popup off-screen.
199
+ const w = openedSideBar && !isMobile
200
+ ? leftSideBarWidth + leftSidePanelWidth
201
+ : leftSideBarWidth
202
+ // Keep the popup's horizontal extent in step with the footer (same `w`
203
+ // expression above). A pinned right panel is a dock that reserves its width
204
+ // in layout.jsx, so give that width up here too — otherwise the popup's right
205
+ // end slides under the dock. Unpinned is an overlay and reserves nothing, and
206
+ // mobile is excluded to match layout.jsx.
207
+ const qmStyle = {
208
+ left: w
209
+ }
210
+ if (rightPanelVisible && rightPanelPinned && !isMobile) {
211
+ qmStyle.right = rightPanelWidth
212
+ }
193
213
  const qmProps = {
194
214
  className: 'qm-wrap-tooltip',
195
- style: {
196
- left: w
197
- }
215
+ style: qmStyle
198
216
  }
199
217
  return (
200
218
  <div
@@ -4,12 +4,16 @@ import {
4
4
  Select,
5
5
  Input
6
6
  } from 'antd'
7
+ import { RobotOutlined } from '@ant-design/icons'
7
8
  import message from '../common/message'
8
9
  import SwitchLabel from '../common/switch'
9
10
  import { useState } from 'react'
10
11
  import generate from '../../common/uid'
11
12
  import InputAutoFocus from '../common/input-auto-focus'
12
13
  import renderQm from './quick-commands-list-form'
14
+ import QuickCommandAiEditor from './quick-command-ai-editor'
15
+ import ResponsiveTabs from '../common/responsive-tabs'
16
+ import { isAIDisabled } from '../../common/ai-feature'
13
17
  import ShortcutEdit from '../shortcuts/shortcut-editor'
14
18
  import { getKeysTakenData } from '../shortcuts/shortcut-utils'
15
19
  import deepCopy from 'json-deep-copy'
@@ -18,6 +22,8 @@ import HelpIcon from '../common/help-icon'
18
22
 
19
23
  const FormItem = Form.Item
20
24
  const { Option } = Select
25
+ const manualTab = 'qm-form-manual'
26
+ const aiTab = 'qm-form-ai'
21
27
  const e = window.translate
22
28
 
23
29
  export default function QuickCommandForm (props) {
@@ -25,6 +31,7 @@ export default function QuickCommandForm (props) {
25
31
  const { store, formData } = props
26
32
  const { quickCommandTags = [] } = store
27
33
  const [shortcut, setShortcut] = useState(formData.shortcut || '')
34
+ const [tab, setTab] = useState(manualTab)
28
35
  const uid = formData.id || generate()
29
36
  const updateConfig = (name, value) => {
30
37
  form.setFieldsValue({
@@ -86,6 +93,22 @@ export default function QuickCommandForm (props) {
86
93
  message.success(e('saved'))
87
94
  props.onSaved?.()
88
95
  }
96
+
97
+ // AI generated quick command: write it into the same form (so the user can
98
+ // review/edit it) and jump back to the manual tab, where the save button is
99
+ function handleAiGenerated (data) {
100
+ const update = {
101
+ name: data.name,
102
+ commands: data.commands,
103
+ inputOnly: data.inputOnly
104
+ }
105
+ if (data.labels.length) {
106
+ update.labels = data.labels
107
+ }
108
+ form.setFieldsValue(update)
109
+ setTab(manualTab)
110
+ }
111
+
89
112
  const initialValues = formData
90
113
  if (!initialValues.labels) {
91
114
  initialValues.labels = []
@@ -112,6 +135,111 @@ export default function QuickCommandForm (props) {
112
135
  return `{{${t}}}`
113
136
  }).join(', ')
114
137
  const wiki = 'https://github.com/electerm/electerm/wiki/quick-command-templates'
138
+ const aiDisabled = isAIDisabled()
139
+ // renderQm() uses hooks internally, so it must run on every render
140
+ const qmItem = renderQm(form)
141
+ const manualPane = (
142
+ <>
143
+ <FormItem
144
+ label={e('quickCommandName')}
145
+ rules={[{
146
+ max: 60, message: '60 chars max'
147
+ }, {
148
+ required: true, message: 'Name required'
149
+ }]}
150
+ hasFeedback
151
+ name='name'
152
+ >
153
+ <InputAutoFocus />
154
+ </FormItem>
155
+ {qmItem}
156
+ <FormItem
157
+ name='labels'
158
+ label={e('label')}
159
+ >
160
+ <Select
161
+ mode='tags'
162
+ >
163
+ {
164
+ quickCommandTags.map(q => {
165
+ return (
166
+ <Option value={q} key={'qmt-' + q}>
167
+ {q}
168
+ </Option>
169
+ )
170
+ })
171
+ }
172
+ </Select>
173
+ </FormItem>
174
+ <FormItem
175
+ label={e('settingShortcuts')}
176
+ name='shortcut'
177
+ >
178
+ <div>
179
+ <Input className='hide' />
180
+ <ShortcutEdit
181
+ {...editorProps}
182
+ />
183
+ </div>
184
+ </FormItem>
185
+ <FormItem
186
+ label={e('inputOnly')}
187
+ name='inputOnly'
188
+ valuePropName='checked'
189
+ >
190
+ <SwitchLabel />
191
+ </FormItem>
192
+ <FormItem>
193
+ <p>
194
+ <Button
195
+ type='primary'
196
+ htmlType='submit'
197
+ >{e('save')}
198
+ </Button>
199
+ </p>
200
+ </FormItem>
201
+ <p>
202
+ <b className='mg1r'>{e('templates')}:</b>
203
+ <span className='mg1r'>{templatesStr}</span>
204
+ <HelpIcon
205
+ link={wiki}
206
+ />
207
+ </p>
208
+ </>
209
+ )
210
+ const tabsProps = {
211
+ activeKey: tab,
212
+ onChange: setTab,
213
+ className: 'qm-form-tabs',
214
+ size: 'small',
215
+ items: [
216
+ {
217
+ key: manualTab,
218
+ label: e('quickCommand')
219
+ },
220
+ ...(!aiDisabled
221
+ ? [{
222
+ key: aiTab,
223
+ label: (
224
+ <span className='qm-form-tab-ai'>
225
+ <RobotOutlined className='mg1r' />
226
+ AI
227
+ </span>
228
+ )
229
+ }]
230
+ : [])
231
+ ]
232
+ }
233
+ const aiPane = (
234
+ <>
235
+ <ResponsiveTabs {...tabsProps} />
236
+ {
237
+ tab === aiTab
238
+ ? <QuickCommandAiEditor onChange={handleAiGenerated} />
239
+ : manualPane
240
+ }
241
+ </>
242
+ )
115
243
  return (
116
244
  <>
117
245
  <Form
@@ -121,71 +249,7 @@ export default function QuickCommandForm (props) {
121
249
  layout='vertical'
122
250
  initialValues={initialValues}
123
251
  >
124
- <FormItem
125
- label={e('quickCommandName')}
126
- rules={[{
127
- max: 60, message: '60 chars max'
128
- }, {
129
- required: true, message: 'Name required'
130
- }]}
131
- hasFeedback
132
- name='name'
133
- >
134
- <InputAutoFocus />
135
- </FormItem>
136
- {renderQm(form)}
137
- <FormItem
138
- name='labels'
139
- label={e('label')}
140
- >
141
- <Select
142
- mode='tags'
143
- >
144
- {
145
- quickCommandTags.map(q => {
146
- return (
147
- <Option value={q} key={'qmt-' + q}>
148
- {q}
149
- </Option>
150
- )
151
- })
152
- }
153
- </Select>
154
- </FormItem>
155
- <FormItem
156
- label={e('settingShortcuts')}
157
- name='shortcut'
158
- >
159
- <div>
160
- <Input className='hide' />
161
- <ShortcutEdit
162
- {...editorProps}
163
- />
164
- </div>
165
- </FormItem>
166
- <FormItem
167
- label={e('inputOnly')}
168
- name='inputOnly'
169
- valuePropName='checked'
170
- >
171
- <SwitchLabel />
172
- </FormItem>
173
- <FormItem>
174
- <p>
175
- <Button
176
- type='primary'
177
- htmlType='submit'
178
- >{e('save')}
179
- </Button>
180
- </p>
181
- </FormItem>
182
- <p>
183
- <b className='mg1r'>{e('templates')}:</b>
184
- <span className='mg1r'>{templatesStr}</span>
185
- <HelpIcon
186
- link={wiki}
187
- />
188
- </p>
252
+ {aiDisabled ? manualPane : aiPane}
189
253
  </Form>
190
254
  </>
191
255
  )