@electerm/electerm-react 3.12.0 → 3.15.28

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.
@@ -64,6 +64,7 @@ export default {
64
64
  ],
65
65
  hideIP: false,
66
66
  dataSyncSelected: 'all',
67
+ nameAI: '',
67
68
  baseURLAI: 'https://api.atlascloud.ai/v1',
68
69
  modelAI: 'deepseek-chat',
69
70
  roleAI: '终端专家,提供不同系统下命令,简要解释用法,用markdown格式',
@@ -1,3 +1,3 @@
1
1
  export default (path = '') => {
2
- return path.startsWith('/')
2
+ return path.startsWith('/') || /^[a-zA-Z]:/.test(path) || path.startsWith('\\\\')
3
3
  }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Ensure remote path always starts with /
3
+ * Windows drive letters like c: become /c:
4
+ * Also fixes mixed separators like /c:\windows → /c:/windows
5
+ * This is needed because SFTP protocol expects paths with leading /
6
+ * @param {String} path
7
+ * @return {String}
8
+ */
9
+ export default function normalizeRemotePath (path) {
10
+ if (!path) return path
11
+ // Fix mixed separators: /c:\windows → /c:/windows
12
+ if (/^\/[a-zA-Z]:\\/.test(path)) {
13
+ return path.replace(/\\/g, '/')
14
+ }
15
+ // Add leading / to bare drive letters: c: → /c:, c:\windows → /c:/windows
16
+ if (/^[a-zA-Z]:/.test(path)) {
17
+ return '/' + path.replace(/\\/g, '/')
18
+ }
19
+ return path
20
+ }
@@ -5,6 +5,13 @@
5
5
  * @return {String}
6
6
  */
7
7
 
8
+ export const isWslPath = (path) => /^\\\\(?:wsl\$|wsl\.localhost)\\/.test(path)
9
+
10
+ export const isWslDistroRoot = (path) => {
11
+ const trimmed = path.replace(/\\$/, '')
12
+ return /^\\\\(?:wsl\$|wsl\.localhost)\\[^\\]+$/.test(trimmed)
13
+ }
14
+
8
15
  export default function resolve (basePath, nameOrDot) {
9
16
  const hasWinDrive = (path) => /^[a-zA-Z]:/.test(path)
10
17
  const isWin = basePath.includes('\\') || nameOrDot.includes('\\') || hasWinDrive(basePath) || hasWinDrive(nameOrDot)
@@ -15,7 +22,13 @@ export default function resolve (basePath, nameOrDot) {
15
22
  if (nameOrDot.startsWith('/')) {
16
23
  return nameOrDot.replace(/\\/g, sep)
17
24
  }
25
+ if (nameOrDot.startsWith('\\\\')) {
26
+ return nameOrDot
27
+ }
18
28
  if (nameOrDot === '..') {
29
+ if (isWslDistroRoot(basePath)) {
30
+ return '/'
31
+ }
19
32
  const baseEndsWithSep = basePath.endsWith(sep)
20
33
  const parts = basePath.split(sep)
21
34
  if (parts.length > 1) {
@@ -27,6 +40,9 @@ export default function resolve (basePath, nameOrDot) {
27
40
  }
28
41
  return '/'
29
42
  }
43
+ if (isWslDistroRoot(basePath) && !basePath.endsWith(sep)) {
44
+ return basePath + sep + nameOrDot
45
+ }
30
46
  const result = basePath.endsWith(sep) ? basePath + nameOrDot : basePath + sep + nameOrDot
31
47
  return isWin && result.length === 3 && result.endsWith(':\\') ? '/' : result
32
48
  }
@@ -31,7 +31,7 @@ export const agentTools = [
31
31
  type: 'function',
32
32
  function: {
33
33
  name: 'send_terminal_command',
34
- description: 'Send a command to a terminal tab and wait for it to finish. Returns the command output.',
34
+ description: 'Send a command to a terminal tab and wait for it to finish. Returns the command output. For long-running commands (builds, deployments, installations), use run_background_command instead to avoid timeouts.',
35
35
  parameters: {
36
36
  type: 'object',
37
37
  properties: {
@@ -334,6 +334,114 @@ export const agentTools = [
334
334
  properties: {}
335
335
  }
336
336
  }
337
+ },
338
+ {
339
+ type: 'function',
340
+ function: {
341
+ name: 'get_terminal_status',
342
+ description: 'Check terminal status: running (actively receiving data), idle, or password prompt. Returns last 20 lines of output. Lightweight, non-blocking.',
343
+ parameters: {
344
+ type: 'object',
345
+ properties: {
346
+ tabId: {
347
+ type: 'string',
348
+ description: 'Tab ID. Omit for active terminal.'
349
+ }
350
+ }
351
+ }
352
+ }
353
+ },
354
+ {
355
+ type: 'function',
356
+ function: {
357
+ name: 'cancel_terminal_command',
358
+ description: 'Cancel the running command in a terminal by sending Ctrl+C.',
359
+ parameters: {
360
+ type: 'object',
361
+ properties: {
362
+ tabId: {
363
+ type: 'string',
364
+ description: 'Tab ID. Omit for active terminal.'
365
+ }
366
+ }
367
+ }
368
+ }
369
+ },
370
+ {
371
+ type: 'function',
372
+ function: {
373
+ name: 'run_background_command',
374
+ description: 'Run a command in the background using nohup. The terminal is freed immediately. Returns a taskId for monitoring. Use get_background_task_status and get_background_task_log to check progress.',
375
+ parameters: {
376
+ type: 'object',
377
+ properties: {
378
+ command: {
379
+ type: 'string',
380
+ description: 'The shell command to run in the background.'
381
+ },
382
+ tabId: {
383
+ type: 'string',
384
+ description: 'Tab ID. Omit for active terminal.'
385
+ }
386
+ },
387
+ required: ['command']
388
+ }
389
+ }
390
+ },
391
+ {
392
+ type: 'function',
393
+ function: {
394
+ name: 'get_background_task_status',
395
+ description: 'Check if a background task is running, completed (with exit code), or unknown.',
396
+ parameters: {
397
+ type: 'object',
398
+ properties: {
399
+ taskId: {
400
+ type: 'string',
401
+ description: 'Task ID from run_background_command.'
402
+ }
403
+ },
404
+ required: ['taskId']
405
+ }
406
+ }
407
+ },
408
+ {
409
+ type: 'function',
410
+ function: {
411
+ name: 'get_background_task_log',
412
+ description: 'Read the output log of a background task. Returns the last N lines.',
413
+ parameters: {
414
+ type: 'object',
415
+ properties: {
416
+ taskId: {
417
+ type: 'string',
418
+ description: 'Task ID from run_background_command.'
419
+ },
420
+ lines: {
421
+ type: 'number',
422
+ description: 'Number of recent lines to read (default 100).'
423
+ }
424
+ },
425
+ required: ['taskId']
426
+ }
427
+ }
428
+ },
429
+ {
430
+ type: 'function',
431
+ function: {
432
+ name: 'cancel_background_task',
433
+ description: 'Cancel a running background task by killing its process.',
434
+ parameters: {
435
+ type: 'object',
436
+ properties: {
437
+ taskId: {
438
+ type: 'string',
439
+ description: 'Task ID from run_background_command.'
440
+ }
441
+ },
442
+ required: ['taskId']
443
+ }
444
+ }
337
445
  }
338
446
  ]
339
447
 
@@ -391,6 +499,18 @@ export async function executeToolCall (toolName, args) {
391
499
  return JSON.stringify(store.mcpSftpTransferList())
392
500
  case 'sftp_transfer_history':
393
501
  return JSON.stringify(store.mcpSftpTransferHistory())
502
+ case 'get_terminal_status':
503
+ return JSON.stringify(store.mcpGetTerminalStatus(args))
504
+ case 'cancel_terminal_command':
505
+ return JSON.stringify(store.mcpCancelTerminalCommand(args))
506
+ case 'run_background_command':
507
+ return JSON.stringify(store.mcpRunBackgroundCommand(args))
508
+ case 'get_background_task_status':
509
+ return JSON.stringify(await store.mcpGetBackgroundTaskStatus(args))
510
+ case 'get_background_task_log':
511
+ return JSON.stringify(await store.mcpGetBackgroundTaskLog(args))
512
+ case 'cancel_background_task':
513
+ return JSON.stringify(await store.mcpCancelBackgroundTask(args))
394
514
  default:
395
515
  throw new Error(`Unknown agent tool: ${toolName}`)
396
516
  }
@@ -8,7 +8,6 @@ import {
8
8
  Tooltip
9
9
  } from 'antd'
10
10
  import {
11
- UserOutlined,
12
11
  CopyOutlined,
13
12
  CloseOutlined,
14
13
  CaretDownOutlined,
@@ -23,6 +22,7 @@ export default function AIChatHistoryItem ({ item }) {
23
22
  const {
24
23
  prompt,
25
24
  sessionId,
25
+ nameAI,
26
26
  modelAI,
27
27
  roleAI,
28
28
  baseURLAI,
@@ -172,7 +172,7 @@ export default function AIChatHistoryItem ({ item }) {
172
172
  <span className='pointer mg1r' onClick={toggleOutput}>
173
173
  {showOutput ? <CaretDownOutlined /> : <CaretRightOutlined />}
174
174
  </span>
175
- <UserOutlined />: {prompt}
175
+ <span>{prompt}</span>
176
176
  {renderStopButton()}
177
177
  </div>
178
178
  ),
@@ -191,6 +191,11 @@ export default function AIChatHistoryItem ({ item }) {
191
191
  function renderTitle () {
192
192
  return (
193
193
  <div>
194
+ {nameAI && (
195
+ <p>
196
+ <b>Name:</b> {nameAI}
197
+ </p>
198
+ )}
194
199
  <p>
195
200
  <b>Model:</b> {modelAI}
196
201
  </p>
@@ -52,6 +52,7 @@ export default function AIChat (props) {
52
52
  mode,
53
53
  toolCalls: [],
54
54
  ...pick(props.config, [
55
+ 'nameAI',
55
56
  'modelAI',
56
57
  'roleAI',
57
58
  'baseURLAI',
@@ -1,4 +1,5 @@
1
1
  export const aiConfigsArr = [
2
+ 'nameAI',
2
3
  'baseURLAI',
3
4
  'modelAI',
4
5
  'roleAI',
@@ -94,10 +94,13 @@ export default function AIConfigForm ({ initialValues, onSubmit, showAIConfig })
94
94
 
95
95
  function renderHistoryItem (item) {
96
96
  if (!item || typeof item !== 'object') return { label: 'Unknown', title: 'Unknown' }
97
+ const name = item.nameAI || ''
97
98
  const model = item.modelAI || 'Default Model'
98
99
  const rolePrefix = item.roleAI ? item.roleAI.substring(0, 15) + '...' : ''
99
- const label = `[${model}] ${rolePrefix}`
100
- const title = `Model: ${item.modelAI}\nRole: ${item.roleAI}\nURL: ${item.baseURLAI}`
100
+ const label = name || `[${model}] ${rolePrefix}`
101
+ const title = name
102
+ ? `${name}\nModel: ${item.modelAI}\nURL: ${item.baseURLAI}`
103
+ : `Model: ${item.modelAI}\nRole: ${item.roleAI}\nURL: ${item.baseURLAI}`
101
104
  return { label, title }
102
105
  }
103
106
 
@@ -131,6 +134,14 @@ export default function AIConfigForm ({ initialValues, onSubmit, showAIConfig })
131
134
  layout='vertical'
132
135
  className='ai-config-form'
133
136
  >
137
+ <Form.Item
138
+ label='Name'
139
+ name='nameAI'
140
+ >
141
+ <Input
142
+ placeholder='e.g. DeepSeek Relay, Local Ollama (optional)'
143
+ />
144
+ </Form.Item>
134
145
  <Form.Item label={renderApiUrlLabel()} required>
135
146
  <Space.Compact className='width-100'>
136
147
  <Form.Item
@@ -10,7 +10,9 @@ const e = window.translate
10
10
  export default function AIOutput ({ item }) {
11
11
  const {
12
12
  response,
13
- baseURLAI
13
+ baseURLAI,
14
+ nameAI,
15
+ modelAI
14
16
  } = item
15
17
  if (!response) {
16
18
  return null
@@ -82,10 +84,12 @@ export default function AIOutput ({ item }) {
82
84
  if (!brand) {
83
85
  return null
84
86
  }
87
+ const nameLabel = nameAI || modelAI
88
+ const label = nameLabel ? `${brand}:${nameLabel}` : brand
85
89
  return (
86
90
  <div className='pd1y'>
87
91
  <Link to={brandUrl}>
88
- <Tag>{brand}</Tag>
92
+ <Tag>{label}</Tag>
89
93
  </Link>
90
94
  </div>
91
95
  )
@@ -282,7 +282,7 @@ export default auto(function Index (props) {
282
282
  <InfoModal {...infoModalProps} />
283
283
  <RightSidePanel {...rightPanelProps}>
284
284
  <AIChat {...aiChatProps} />
285
- <TerminalInfo {...terminalInfoProps} />
285
+ <TerminalInfo key={store.activeTabId} {...terminalInfoProps} />
286
286
  </RightSidePanel>
287
287
  <SshConfigLoadNotify {...sshConfigProps} />
288
288
  <LoadSshConfigs
@@ -6,7 +6,8 @@ import {
6
6
  ReloadOutlined,
7
7
  ArrowRightOutlined,
8
8
  LoadingOutlined,
9
- HomeOutlined
9
+ HomeOutlined,
10
+ PlusOutlined
10
11
  } from '@ant-design/icons'
11
12
  import {
12
13
  Input,
@@ -74,7 +75,7 @@ function renderAddonBefore (props, realPath) {
74
75
  )
75
76
  }
76
77
 
77
- function renderAddonAfter (isLoadingRemote, onGoto, GoIcon, type) {
78
+ function renderAddonAfter (isLoadingRemote, onGoto, GoIcon, type, handleUploadFromBrowser) {
78
79
  const handleClick = (e) => {
79
80
  e.stopPropagation()
80
81
  if (!isLoadingRemote) {
@@ -82,9 +83,24 @@ function renderAddonAfter (isLoadingRemote, onGoto, GoIcon, type) {
82
83
  }
83
84
  }
84
85
  return (
85
- <GoIcon
86
- onClick={handleClick}
87
- />
86
+ <>
87
+ {
88
+ type === typeMap.local && window.et.isWebApp
89
+ ? (
90
+ <PlusOutlined
91
+ className='mg1r'
92
+ onClick={(e) => {
93
+ e.stopPropagation()
94
+ handleUploadFromBrowser()
95
+ }}
96
+ />
97
+ )
98
+ : null
99
+ }
100
+ <GoIcon
101
+ onClick={handleClick}
102
+ />
103
+ </>
88
104
  )
89
105
  }
90
106
 
@@ -160,7 +176,7 @@ export default function AddressBar (props) {
160
176
  onBlur={() => props.onInputBlur(type)}
161
177
  disabled={loadingSftp}
162
178
  suffix={
163
- renderAddonAfter(isLoadingRemote, onGoto, GoIcon, type)
179
+ renderAddonAfter(isLoadingRemote, onGoto, GoIcon, type, props.handleUploadFromBrowser)
164
180
  }
165
181
  />
166
182
  {renderHistory(props, type)}
@@ -14,6 +14,7 @@ import copy from 'json-deep-copy'
14
14
  import { pick, some } from 'lodash-es'
15
15
  import Input from '../common/input-auto-focus'
16
16
  import resolve from '../../common/resolve'
17
+ import normalizeRemotePath from '../../common/normalize-remote-path'
17
18
  import { addClass, removeClass } from '../../common/class'
18
19
  import {
19
20
  mode2permission,
@@ -604,7 +605,10 @@ export default class FileSection extends React.Component {
604
605
  const { type, name, isParent } = file
605
606
  const n = `${type}Path`
606
607
  const path = isParent ? file.path : this.props[n]
607
- const np = resolve(path, name)
608
+ let np = resolve(path, name)
609
+ if (type === typeMap.remote) {
610
+ np = normalizeRemotePath(np)
611
+ }
608
612
  const op = this.props[type + 'Path']
609
613
  this.props.modifier({
610
614
  [n]: np,
@@ -693,11 +697,22 @@ export default class FileSection extends React.Component {
693
697
  const {
694
698
  path, name
695
699
  } = this.state.file
696
- const rp = path ? resolve(path, name) : this.props[`${this.props.type}Path`]
700
+ let rp = path ? resolve(path, name) : this.props[`${this.props.type}Path`]
701
+ if (this.props.type === typeMap.remote) {
702
+ rp = this.convertSftpPathToTerminalPath(rp)
703
+ }
697
704
  this.props.tab.pane = paneMap.terminal
698
705
  refs.get('term-' + this.props.tab.id)?.cd(rp)
699
706
  }
700
707
 
708
+ convertSftpPathToTerminalPath = (p) => {
709
+ const m = p.match(/^\/([a-zA-Z]:)(.*)$/)
710
+ if (m) {
711
+ return m[1] + m[2].replace(/\//g, '\\')
712
+ }
713
+ return p
714
+ }
715
+
701
716
  fetchEditorText = async (path, type) => {
702
717
  // const sftp = sftpFunc()
703
718
  const text = typeMap.remote === type
@@ -882,6 +897,25 @@ export default class FileSection extends React.Component {
882
897
  window.pre.showItemInFolder(p)
883
898
  }
884
899
 
900
+ downloadFromBrowser = async () => {
901
+ const { path, name, isDirectory } = this.state.file
902
+ const p = resolve(path, name)
903
+ const url = '/api/download?path=' + encodeURIComponent(p)
904
+ const res = await window.fetch(url, {
905
+ headers: {
906
+ token: window.store?.config.tokenElecterm
907
+ }
908
+ })
909
+ const blob = await res.blob()
910
+ const a = document.createElement('a')
911
+ a.href = URL.createObjectURL(blob)
912
+ a.download = isDirectory ? name + '.tar.gz' : name
913
+ document.body.appendChild(a)
914
+ a.click()
915
+ document.body.removeChild(a)
916
+ URL.revokeObjectURL(a.href)
917
+ }
918
+
885
919
  newItem = (isDirectory) => {
886
920
  const { type } = this.state.file
887
921
  const list = copy(this.props[type])
@@ -1060,6 +1094,13 @@ export default class FileSection extends React.Component {
1060
1094
  text: e('showInDefaultFileMananger')
1061
1095
  })
1062
1096
  }
1097
+ if (isLocal && isRealFile && window.et.isWebApp) {
1098
+ res.push({
1099
+ func: 'downloadFromBrowser',
1100
+ icon: 'DownloadOutlined',
1101
+ text: 'Download from browser'
1102
+ })
1103
+ }
1063
1104
  if (showEdit) {
1064
1105
  res.push({
1065
1106
  func: 'editFile',
@@ -7,6 +7,12 @@ import fs from '../../common/fs'
7
7
  import { isWin } from '../../common/constants'
8
8
 
9
9
  export const getFileExt = fileName => {
10
+ if (/^\\\\(?:wsl\$|wsl\.localhost)\\/.test(fileName)) {
11
+ return {
12
+ base: fileName,
13
+ ext: ''
14
+ }
15
+ }
10
16
  const sep = '.'
11
17
  const arr = fileName.split(sep)
12
18
  const len = arr.length
@@ -82,12 +88,15 @@ export const getFolderFromFilePath = (filePath, isRemote) => {
82
88
  const arr = filePath.split(sep)
83
89
  const len = arr.length
84
90
  const isWinDisk = isWin && filePath.endsWith(sep)
85
- const path = isWinDisk
91
+ const isWslRoot = isWin && /^\\\\(?:wsl\$|wsl\.localhost)\\[^\\]+$/.test(filePath.replace(/\\$/, ''))
92
+ const path = (isWinDisk || isWslRoot)
86
93
  ? '/'
87
94
  : arr.slice(0, len - 1).join(sep)
88
95
  const name = isWinDisk
89
96
  ? filePath.replace(sep, '')
90
- : arr[len - 1]
97
+ : isWslRoot
98
+ ? filePath
99
+ : arr[len - 1]
91
100
 
92
101
  return {
93
102
  path,
@@ -29,6 +29,7 @@ import fs from '../../common/fs'
29
29
  import ListTable from './list-table-ui'
30
30
  import deepCopy from 'json-deep-copy'
31
31
  import isValidPath from '../../common/is-valid-path'
32
+ import normalizeRemotePath from '../../common/normalize-remote-path'
32
33
  import { LoadingOutlined, ReloadOutlined } from '@ant-design/icons'
33
34
  import * as owner from './owner-list'
34
35
  import AddressBar from './address-bar'
@@ -292,6 +293,7 @@ export default class Sftp extends Component {
292
293
  if (!path && this.sftp) {
293
294
  path = await this.getPwd(this.props.tab.username)
294
295
  }
296
+ path = normalizeRemotePath(path)
295
297
  } else {
296
298
  path = this.getLocalHome()
297
299
  }
@@ -762,7 +764,7 @@ export default class Sftp extends Component {
762
764
 
763
765
  if (!remotePath) {
764
766
  if (startDirectory) {
765
- remotePath = startDirectory
767
+ remotePath = normalizeRemotePath(startDirectory)
766
768
  } else {
767
769
  remotePath = await this.getPwd(username)
768
770
  }
@@ -970,6 +972,31 @@ export default class Sftp extends Component {
970
972
  })
971
973
  }
972
974
 
975
+ handleUploadFromBrowser = () => {
976
+ const input = document.createElement('input')
977
+ input.type = 'file'
978
+ input.multiple = true
979
+ input.onchange = async () => {
980
+ const files = input.files
981
+ if (!files || !files.length) return
982
+ const { localPath } = this.state
983
+ for (const file of files) {
984
+ const formData = new FormData()
985
+ formData.append('file', file)
986
+ formData.append('path', localPath)
987
+ await window.fetch('/api/upload', {
988
+ method: 'POST',
989
+ body: formData,
990
+ headers: {
991
+ token: window.store?.config.tokenElecterm
992
+ }
993
+ })
994
+ }
995
+ this.localList()
996
+ }
997
+ input.click()
998
+ }
999
+
973
1000
  parsePath = async (type, pth) => {
974
1001
  const reg = /^%([^%]+)%/
975
1002
  if (!reg.test(pth)) {
@@ -995,7 +1022,10 @@ export default class Sftp extends Component {
995
1022
  const n = `${type}Path`
996
1023
  const nt = n + 'Temp'
997
1024
  const oldPath = this.state[type + 'Path']
998
- const np = await this.parsePath(type, this.state[nt])
1025
+ let np = await this.parsePath(type, this.state[nt])
1026
+ if (type === typeMap.remote) {
1027
+ np = normalizeRemotePath(np)
1028
+ }
999
1029
  if (!isValidPath(np)) {
1000
1030
  return notification.warning({
1001
1031
  message: 'path not valid'
@@ -1003,6 +1033,7 @@ export default class Sftp extends Component {
1003
1033
  }
1004
1034
  this.setState({
1005
1035
  [n]: np,
1036
+ [nt]: np,
1006
1037
  [`${type}Keyword`]: ''
1007
1038
  }, () => this[`${type}List`](undefined, undefined, oldPath))
1008
1039
  }
@@ -1010,7 +1041,10 @@ export default class Sftp extends Component {
1010
1041
  goParent = (type) => {
1011
1042
  const n = `${type}Path`
1012
1043
  const p = this.state[n]
1013
- const np = resolve(p, '..')
1044
+ let np = resolve(p, '..')
1045
+ if (type === typeMap.remote) {
1046
+ np = normalizeRemotePath(np)
1047
+ }
1014
1048
  const op = this.state[n]
1015
1049
  if (np !== p) {
1016
1050
  this.setState({
@@ -1218,6 +1252,7 @@ export default class Sftp extends Component {
1218
1252
  const addrProps = {
1219
1253
  host,
1220
1254
  type,
1255
+ handleUploadFromBrowser: this.handleUploadFromBrowser,
1221
1256
  ...pick(
1222
1257
  this,
1223
1258
  [
@@ -24,7 +24,11 @@ import {
24
24
  LockOutlined,
25
25
  ReloadOutlined,
26
26
  FileZipOutlined,
27
- AppstoreOutlined
27
+ AppstoreOutlined,
28
+ SaveOutlined,
29
+ PlayCircleFilled,
30
+ StopOutlined,
31
+ DownloadOutlined
28
32
  } from '@ant-design/icons'
29
33
  import IconHolder from './icon-holder'
30
34
 
@@ -52,5 +56,9 @@ export default {
52
56
  LockOutlined,
53
57
  ReloadOutlined,
54
58
  FileZipOutlined,
55
- AppstoreOutlined
59
+ AppstoreOutlined,
60
+ SaveOutlined,
61
+ PlayCircleFilled,
62
+ StopOutlined,
63
+ DownloadOutlined
56
64
  }