@electerm/electerm-react 1.51.21 → 1.60.16

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 (39) hide show
  1. package/client/common/constants.js +2 -0
  2. package/client/common/default-setting.js +12 -1
  3. package/client/components/ai/ai-chat-history-item.jsx +69 -0
  4. package/client/components/ai/ai-chat-history.jsx +31 -0
  5. package/client/components/ai/ai-chat.jsx +172 -0
  6. package/client/components/ai/ai-config.jsx +145 -0
  7. package/client/components/ai/ai-output.jsx +118 -0
  8. package/client/components/ai/ai.styl +70 -0
  9. package/client/components/ai/get-brand.js +34 -0
  10. package/client/components/ai/providers.js +14 -0
  11. package/client/components/bookmark-form/rdp-form-ui.jsx +1 -1
  12. package/client/components/footer/batch-input.jsx +13 -67
  13. package/client/components/footer/footer-entry.jsx +19 -3
  14. package/client/components/footer/footer.styl +4 -0
  15. package/client/components/footer/tab-select.jsx +9 -3
  16. package/client/components/layout/layout.jsx +5 -4
  17. package/client/components/main/main.jsx +20 -4
  18. package/client/components/shortcuts/shortcut-control.jsx +17 -2
  19. package/client/components/shortcuts/shortcut-handler.js +24 -8
  20. package/client/components/shortcuts/shortcuts-defaults.js +6 -0
  21. package/client/components/side-panel-r/right-side-panel.styl +6 -7
  22. package/client/components/side-panel-r/side-panel-r.jsx +32 -10
  23. package/client/components/sidebar/app-running-time.jsx +35 -0
  24. package/client/components/sidebar/history-item.jsx +20 -3
  25. package/client/components/sidebar/history.jsx +4 -1
  26. package/client/components/sidebar/info-modal.jsx +2 -0
  27. package/client/components/tabs/app-drag.jsx +1 -1
  28. package/client/components/tabs/index.jsx +16 -43
  29. package/client/components/tabs/no-session.jsx +40 -0
  30. package/client/components/tabs/tabs.styl +6 -0
  31. package/client/components/terminal/index.jsx +2 -2
  32. package/client/store/common.js +37 -2
  33. package/client/store/index.js +2 -290
  34. package/client/store/init-state.js +7 -1
  35. package/client/store/store.js +313 -0
  36. package/client/store/sync.js +4 -1
  37. package/client/store/tab.js +56 -2
  38. package/client/store/watch.js +9 -2
  39. package/package.json +1 -1
@@ -307,6 +307,7 @@ export const batchOpHelpLink = 'https://github.com/electerm/electerm/wiki/batch-
307
307
  export const proxyHelpLink = 'https://github.com/electerm/electerm/wiki/proxy-format'
308
308
  export const regexHelpLink = 'https://github.com/electerm/electerm/wiki/Terminal-keywords-highlight-regular-expression-exmaples'
309
309
  export const connectionHoppingWikiLink = 'https://github.com/electerm/electerm/wiki/Connection-Hopping-Behavior-Change-in-electerm-since-v1.50.65'
310
+ export const aiConfigWikiLink = 'https://github.com/electerm/electerm/wiki/AI-model-config-guide'
310
311
  export const modals = {
311
312
  hide: 0,
312
313
  setting: 1,
@@ -412,3 +413,4 @@ export const terminalTypes = [
412
413
  export const sshConfigLoadKey = 'ssh-config-loaded'
413
414
  export const sshConfigKey = 'ignore-ssh-config'
414
415
  export const connectionHoppingWarnKey = 'connectionHoppingWarnned'
416
+ export const aiChatHistoryKey = 'ai-chat-history'
@@ -58,5 +58,16 @@ export default {
58
58
  'modifyTime'
59
59
  ],
60
60
  hideIP: false,
61
- dataSyncSelected: 'all'
61
+ dataSyncSelected: 'all',
62
+ baseURLAI: 'https://api.deepseek.com',
63
+ modelAI: 'deepseek-chat',
64
+ roleAI: `You are a terminal command expert.
65
+ - Provide clear, safe, and efficient shell commands
66
+ - Always explain what each command does
67
+ - Warn about potentially dangerous operations
68
+ - Format command output with markdown code blocks
69
+ - If multiple steps are needed, number them
70
+ - Mention any prerequisites or dependencies
71
+ - Include common flags and options
72
+ - Specify which OS (Linux/Mac/Windows) the command is for`
62
73
  }
@@ -0,0 +1,69 @@
1
+ // ai-chat-history-item.jsx
2
+ import AIOutput from './ai-output'
3
+ import {
4
+ Alert,
5
+ Tooltip
6
+ } from 'antd'
7
+ import {
8
+ UserOutlined,
9
+ CopyOutlined,
10
+ CloseOutlined
11
+ } from '@ant-design/icons'
12
+ import { copy } from '../../common/clipboard'
13
+
14
+ export default function AIChatHistoryItem ({ item }) {
15
+ const {
16
+ prompt
17
+ } = item
18
+ const alertProps = {
19
+ message: (
20
+ <div><UserOutlined />: {prompt}</div>
21
+ ),
22
+ type: 'info'
23
+ }
24
+ function handleDel (e) {
25
+ e.stopPropagation()
26
+ window.store.removeAiHistory(item.id)
27
+ }
28
+ function handleCopy () {
29
+ copy(prompt)
30
+ }
31
+ function renderTitle () {
32
+ return (
33
+ <div>
34
+ <p>
35
+ <b>Model:</b> {item.modelAI}
36
+ </p>
37
+ <p>
38
+ <b>Role:</b> {item.roleAI}
39
+ </p>
40
+ <p>
41
+ <b>Base URL:</b> {item.baseURLAI}
42
+ </p>
43
+ <p>
44
+ <b>Time:</b> {new Date(item.timestamp).toLocaleString()}
45
+ </p>
46
+ <p>
47
+ <CopyOutlined
48
+ className='pointer'
49
+ onClick={handleCopy}
50
+ />
51
+ <CloseOutlined
52
+ className='pointer mg1l'
53
+ onClick={handleDel}
54
+ />
55
+ </p>
56
+ </div>
57
+ )
58
+ }
59
+ return (
60
+ <div className='chat-history-item'>
61
+ <div className='mg1y'>
62
+ <Tooltip title={renderTitle()}>
63
+ <Alert {...alertProps} />
64
+ </Tooltip>
65
+ </div>
66
+ <AIOutput item={item} />
67
+ </div>
68
+ )
69
+ }
@@ -0,0 +1,31 @@
1
+ // ai-chat-history.jsx
2
+ import { useLayoutEffect, useRef } from 'react'
3
+ import { auto } from 'manate/react'
4
+ import AIChatHistoryItem from './ai-chat-history-item'
5
+
6
+ export default auto(function AIChatHistory ({ history }) {
7
+ const historyRef = useRef(null)
8
+
9
+ useLayoutEffect(() => {
10
+ if (historyRef.current) {
11
+ historyRef.current.scrollTop = historyRef.current.scrollHeight
12
+ }
13
+ }, [history.length])
14
+ if (!history.length) {
15
+ return <div />
16
+ }
17
+ return (
18
+ <div ref={historyRef} className='ai-history-wrap'>
19
+ {
20
+ history.map((item) => {
21
+ return (
22
+ <AIChatHistoryItem
23
+ key={item.id}
24
+ item={item}
25
+ />
26
+ )
27
+ })
28
+ }
29
+ </div>
30
+ )
31
+ })
@@ -0,0 +1,172 @@
1
+ import { useState, useCallback, useEffect } from 'react'
2
+ import { Flex, Input, message } from 'antd'
3
+ import AIConfigForm from './ai-config'
4
+ import TabSelect from '../footer/tab-select'
5
+ import AiChatHistory from './ai-chat-history'
6
+ import uid from '../../common/uid'
7
+ import { pick } from 'lodash-es'
8
+ import {
9
+ SettingOutlined,
10
+ LoadingOutlined,
11
+ SendOutlined,
12
+ UnorderedListOutlined
13
+ } from '@ant-design/icons'
14
+ import {
15
+ aiConfigWikiLink
16
+ } from '../../common/constants'
17
+ import HelpIcon from '../common/help-icon'
18
+ import './ai.styl'
19
+
20
+ const { TextArea } = Input
21
+ const MAX_HISTORY = 100
22
+ const aiConfigsArr = [
23
+ 'baseURLAI',
24
+ 'modelAI',
25
+ 'roleAI',
26
+ 'apiKeyAI'
27
+ ]
28
+
29
+ export default function AIChat (props) {
30
+ const [prompt, setPrompt] = useState('')
31
+ const [isLoading, setIsLoading] = useState(false)
32
+
33
+ function handlePromptChange (e) {
34
+ setPrompt(e.target.value)
35
+ }
36
+
37
+ async function handleSubmit () {
38
+ if (aiConfigMissing()) {
39
+ window.store.toggleAIConfig()
40
+ }
41
+ if (!prompt.trim() || isLoading) return
42
+ setIsLoading(true)
43
+ const aiResponse = await window.pre.runGlobalAsync(
44
+ 'AIchat',
45
+ prompt,
46
+ props.config.modelAI,
47
+ props.config.roleAI,
48
+ props.config.baseURLAI,
49
+ props.config.apiKeyAI
50
+ ).catch(
51
+ window.store.onError
52
+ )
53
+ if (aiResponse && aiResponse.error) {
54
+ return window.store.onError(
55
+ new Error(aiResponse.error)
56
+ )
57
+ }
58
+ window.store.aiChatHistory.push({
59
+ prompt,
60
+ response: aiResponse.response,
61
+ ...pick(props.config, [
62
+ 'modelAI',
63
+ 'roleAI',
64
+ 'baseURLAI'
65
+ ]),
66
+ timestamp: Date.now(),
67
+ id: uid()
68
+ })
69
+
70
+ if (window.store.aiChatHistory.length > MAX_HISTORY) {
71
+ window.store.aiChatHistory.splice(MAX_HISTORY)
72
+ }
73
+ setPrompt('')
74
+ setIsLoading(false)
75
+ }
76
+
77
+ function handleConfigSubmit (values) {
78
+ window.store.updateConfig(values)
79
+ message.success('Saved')
80
+ }
81
+
82
+ const renderConfig = useCallback(() => {
83
+ if (!props.showAIConfig) return null
84
+ const aiConfigs = pick(props.config, aiConfigsArr)
85
+ return (
86
+ <AIConfigForm
87
+ initialValues={aiConfigs}
88
+ onSubmit={handleConfigSubmit}
89
+ showAIConfig={props.showAIConfig}
90
+ />
91
+ )
92
+ }, [props.showAIConfig, props.config])
93
+
94
+ function renderHistory () {
95
+ return (
96
+ <AiChatHistory
97
+ history={props.aiChatHistory}
98
+ />
99
+ )
100
+ }
101
+
102
+ function toggleConfig () {
103
+ window.store.toggleAIConfig()
104
+ }
105
+
106
+ function clearHistory () {
107
+ window.store.aiChatHistory = []
108
+ }
109
+
110
+ function aiConfigMissing () {
111
+ return aiConfigsArr.some(k => !props.config[k])
112
+ }
113
+
114
+ function renderSendIcon () {
115
+ if (isLoading) {
116
+ return <LoadingOutlined />
117
+ }
118
+ return (
119
+ <SendOutlined
120
+ onClick={handleSubmit}
121
+ className='mg1l pointer icon-hover'
122
+ />
123
+ )
124
+ }
125
+
126
+ useEffect(() => {
127
+ if (aiConfigMissing()) {
128
+ window.store.toggleAIConfig()
129
+ }
130
+ }, [])
131
+
132
+ return (
133
+ <Flex vertical className='ai-chat-container'>
134
+ <Flex className='ai-chat-history' flex='auto'>
135
+ {renderHistory()}
136
+ </Flex>
137
+
138
+ <Flex className='ai-chat-input'>
139
+ <TextArea
140
+ value={prompt}
141
+ onChange={handlePromptChange}
142
+ placeholder='Enter your prompt here'
143
+ autoSize={{ minRows: 3, maxRows: 10 }}
144
+ disabled={isLoading}
145
+ />
146
+ <Flex className='ai-chat-terminals' justify='space-between' align='center'>
147
+ <Flex align='center'>
148
+ <TabSelect
149
+ selectedTabIds={props.selectedTabIds}
150
+ tabs={props.tabs}
151
+ activeTabId={props.activeTabId}
152
+ />
153
+ <SettingOutlined
154
+ onClick={toggleConfig}
155
+ className='mg1l pointer icon-hover toggle-ai-setting-icon'
156
+ />
157
+ <UnorderedListOutlined
158
+ onClick={clearHistory}
159
+ className='mg2x pointer clear-ai-icon icon-hover'
160
+ title='Clear AI chat history'
161
+ />
162
+ <HelpIcon
163
+ link={aiConfigWikiLink}
164
+ />
165
+ {renderConfig()}
166
+ </Flex>
167
+ {renderSendIcon()}
168
+ </Flex>
169
+ </Flex>
170
+ </Flex>
171
+ )
172
+ }
@@ -0,0 +1,145 @@
1
+ import {
2
+ Form,
3
+ Input,
4
+ Button,
5
+ AutoComplete,
6
+ Modal,
7
+ Alert
8
+ } from 'antd'
9
+ import { useEffect, useState } from 'react'
10
+ import Link from '../common/external-link'
11
+ import {
12
+ aiConfigWikiLink
13
+ } from '../../common/constants'
14
+
15
+ // Comprehensive API provider configurations
16
+ import providers from './providers'
17
+
18
+ const e = window.translate
19
+
20
+ export default function AIConfigForm ({ initialValues, onSubmit, showAIConfig }) {
21
+ const [form] = Form.useForm()
22
+ const [modelOptions, setModelOptions] = useState([])
23
+
24
+ useEffect(() => {
25
+ if (initialValues) {
26
+ form.setFieldsValue(initialValues)
27
+ }
28
+ }, [initialValues])
29
+
30
+ function filter () {
31
+ return true
32
+ }
33
+
34
+ const getBaseURLOptions = () => {
35
+ return providers.map(provider => ({
36
+ value: provider.baseURL,
37
+ label: provider.label
38
+ }))
39
+ }
40
+
41
+ const getModelOptions = (baseURL) => {
42
+ const provider = providers.find(p => p.baseURL === baseURL)
43
+ if (!provider) return []
44
+
45
+ return provider.models.map(model => ({
46
+ value: model,
47
+ label: model
48
+ }))
49
+ }
50
+
51
+ const handleSubmit = async (values) => {
52
+ onSubmit(values)
53
+ }
54
+
55
+ function handleCancel () {
56
+ window.store.toggleAIConfig()
57
+ }
58
+
59
+ function handleChange (v) {
60
+ const options = getModelOptions(v)
61
+ setModelOptions(options)
62
+ form.setFieldsValue({
63
+ modelAI: options[0]?.value || ''
64
+ })
65
+ }
66
+
67
+ if (!showAIConfig) {
68
+ return null
69
+ }
70
+ const title = 'AI ' + e('setting')
71
+ return (
72
+ <Modal
73
+ title={title}
74
+ open
75
+ onCancel={handleCancel}
76
+ footer={null}
77
+ >
78
+ <Alert
79
+ message={
80
+ <Link to={aiConfigWikiLink}>WIKI: {aiConfigWikiLink}</Link>
81
+ }
82
+ type='info'
83
+ className='mg2y'
84
+ />
85
+ <Form
86
+ form={form}
87
+ onFinish={handleSubmit}
88
+ initialValues={initialValues}
89
+ >
90
+ <Form.Item
91
+ label='API URL'
92
+ name='baseURLAI'
93
+ rules={[
94
+ { required: true, message: 'Please input or select API provider URL!' },
95
+ { type: 'url', message: 'Please enter a valid URL!' }
96
+ ]}
97
+ >
98
+ <AutoComplete
99
+ options={getBaseURLOptions()}
100
+ placeholder='Enter or select API provider URL'
101
+ filterOption={filter}
102
+ onChange={handleChange}
103
+ allowClear
104
+ />
105
+ </Form.Item>
106
+
107
+ <Form.Item
108
+ label='Model'
109
+ name='modelAI'
110
+ rules={[{ required: true, message: 'Please input or select a model!' }]}
111
+ >
112
+ <AutoComplete
113
+ options={modelOptions}
114
+ placeholder='Enter or select AI model'
115
+ filterOption={filter}
116
+ />
117
+ </Form.Item>
118
+
119
+ <Form.Item
120
+ label='API Key'
121
+ name='apiKeyAI'
122
+ >
123
+ <Input.Password placeholder='Enter your API key' />
124
+ </Form.Item>
125
+
126
+ <Form.Item
127
+ label='System Role'
128
+ name='roleAI'
129
+ rules={[{ required: true, message: 'Please input the AI role!' }]}
130
+ >
131
+ <Input.TextArea
132
+ placeholder='Enter AI role/system prompt'
133
+ rows={4}
134
+ />
135
+ </Form.Item>
136
+
137
+ <Form.Item>
138
+ <Button type='primary' htmlType='submit'>
139
+ {e('save')}
140
+ </Button>
141
+ </Form.Item>
142
+ </Form>
143
+ </Modal>
144
+ )
145
+ }
@@ -0,0 +1,118 @@
1
+ import { useState, useMemo } from 'react'
2
+ import ReactMarkdown from 'react-markdown'
3
+ import { copy } from '../../common/clipboard'
4
+ import Link from '../common/external-link'
5
+ import { Tag } from 'antd'
6
+ import { CopyOutlined, PlayCircleOutlined, DownCircleOutlined } from '@ant-design/icons'
7
+ import getBrand from './get-brand'
8
+
9
+ const e = window.translate
10
+
11
+ export default function AIOutput ({ item }) {
12
+ const [showFull, setShowFull] = useState(false)
13
+ const {
14
+ response,
15
+ baseURLAI
16
+ } = item
17
+ if (!response) {
18
+ return null
19
+ }
20
+
21
+ const { brand, brandUrl } = getBrand(baseURLAI)
22
+
23
+ const truncatedResponse = useMemo(() => {
24
+ if (!response) return ''
25
+ const codeBlockRegex = /```[\s\S]*?```/
26
+ const match = response.match(codeBlockRegex)
27
+ if (match) {
28
+ const index = match.index + match[0].length
29
+ return response.slice(0, index) + '\n\n... ...'
30
+ }
31
+ // If no code block found, show first 5 lines
32
+ return response.split('\n').slice(0, 5).join('\n') + '\n\n... ...'
33
+ }, [response])
34
+
35
+ const renderCode = (props) => {
36
+ const { node, className = '', children, ...rest } = props
37
+ const code = String(children).replace(/\n$/, '')
38
+ const inline = !className.includes('language-')
39
+ if (inline) {
40
+ return (
41
+ <code className={className} {...props}>
42
+ {children}
43
+ </code>
44
+ )
45
+ }
46
+
47
+ const copyToClipboard = () => {
48
+ copy(code)
49
+ }
50
+
51
+ const runInTerminal = () => {
52
+ window.store.runCommandInTerminal(code)
53
+ }
54
+
55
+ return (
56
+ <div className='code-block'>
57
+ <pre>
58
+ <code className={className} {...rest}>
59
+ {children}
60
+ </code>
61
+ </pre>
62
+ <div className='code-block-actions'>
63
+ <CopyOutlined
64
+ className='code-action-icon pointer'
65
+ onClick={copyToClipboard}
66
+ title={e('copy')}
67
+ />
68
+ <PlayCircleOutlined
69
+ className='code-action-icon pointer mg1l'
70
+ onClick={runInTerminal}
71
+ />
72
+ </div>
73
+ </div>
74
+ )
75
+ }
76
+
77
+ function renderBrand () {
78
+ if (!brand) {
79
+ return null
80
+ }
81
+ return (
82
+ <div className='pd1y'>
83
+ <Link to={brandUrl}>
84
+ <Tag>{brand}</Tag>
85
+ </Link>
86
+ </div>
87
+ )
88
+ }
89
+
90
+ function renderShowMore () {
91
+ if (showFull) {
92
+ return null
93
+ }
94
+ return (
95
+ <span
96
+ onClick={() => setShowFull(true)}
97
+ className='mg1t pointer'
98
+ >
99
+ <DownCircleOutlined /> {e('fullContent')}
100
+ </span>
101
+ )
102
+ }
103
+
104
+ const mdProps = {
105
+ children: showFull ? response : truncatedResponse,
106
+ components: {
107
+ code: renderCode
108
+ }
109
+ }
110
+
111
+ return (
112
+ <div className='pd1'>
113
+ {renderBrand()}
114
+ <ReactMarkdown {...mdProps} />
115
+ {renderShowMore()}
116
+ </div>
117
+ )
118
+ }
@@ -0,0 +1,70 @@
1
+ @require '../../css/includes/theme-default'
2
+ .ai-chat-container
3
+ height 100%
4
+ display flex
5
+ flex-direction column
6
+ color text
7
+
8
+ .ai-chat-history
9
+ flex-grow 1
10
+ min-height 0
11
+ &::-webkit-scrollbar
12
+ width 0
13
+ .ai-history-wrap
14
+ width 100%
15
+ overflow-y auto
16
+ overflow-x hidden
17
+
18
+ .ai-config-form
19
+ height 200px
20
+ overflow-y auto
21
+
22
+ .chat-history-item
23
+ .code-block
24
+ border 1px dashed text
25
+ padding 5px
26
+ border-radius 3px
27
+ pre
28
+ margin-bottom 0
29
+ .code-block-actions
30
+ display none
31
+ &:hover
32
+ .code-block-actions
33
+ display block
34
+
35
+ .ai-chat-input
36
+ position relative
37
+ margin-top 10px
38
+
39
+ .ant-input
40
+ padding-bottom 40px
41
+
42
+ .ai-chat-terminals
43
+ position absolute
44
+ bottom 5px
45
+ left 5px
46
+ right 5px
47
+ background transparent
48
+ z-index 1
49
+ font-size 16px
50
+
51
+ .code-block
52
+ width 100%
53
+ pre
54
+ white-space pre-wrap
55
+ word-wrap break-word
56
+ overflow-x auto
57
+ max-width 100%
58
+ code
59
+ white-space pre-wrap
60
+ word-break break-all
61
+
62
+ .clear-ai-icon
63
+ position relative
64
+ &::after
65
+ content '×'
66
+ position absolute
67
+ font-size 12px
68
+ top 8px
69
+ right -4px
70
+ font-weight bold
@@ -0,0 +1,34 @@
1
+ import providers from './providers'
2
+
3
+ export default function getBrand (baseURLAI) {
4
+ // First, try to match with providers
5
+ const provider = providers.find(p => p.baseURL === baseURLAI)
6
+ if (provider) {
7
+ return {
8
+ brand: provider.label,
9
+ brandUrl: provider.homepage
10
+ }
11
+ }
12
+
13
+ // If no match, extract brand from URL
14
+ try {
15
+ const url = new URL(baseURLAI)
16
+ const hostname = url.hostname
17
+ const parts = hostname.split('.')
18
+ let brand = parts[parts.length - 2] // Usually the brand name is the second-to-last part
19
+
20
+ // Capitalize the first letter
21
+ brand = brand.charAt(0).toUpperCase() + brand.slice(1)
22
+
23
+ return {
24
+ brand,
25
+ brandUrl: `https://${parts[parts.length - 2]}.${parts[parts.length - 1]}`
26
+ }
27
+ } catch (error) {
28
+ // If URL parsing fails, return null
29
+ return {
30
+ brand: null,
31
+ brandUrl: null
32
+ }
33
+ }
34
+ }
@@ -0,0 +1,14 @@
1
+ export default [
2
+ {
3
+ label: 'OpenAI',
4
+ baseURL: 'https://api.openai.com/v1',
5
+ homepage: 'https://openai.com',
6
+ models: ['gpt-4', 'gpt-3.5-turbo', 'gpt-3.5-turbo-16k']
7
+ },
8
+ {
9
+ label: 'DeepSeek',
10
+ baseURL: 'https://api.deepseek.com/v1',
11
+ homepage: 'https://deepseek.com',
12
+ models: ['deepseek-chat', 'deepseek-coder']
13
+ }
14
+ ]
@@ -105,7 +105,7 @@ export default function RdpFormUi (props) {
105
105
  />
106
106
  <FormItem
107
107
  {...formItemLayout}
108
- label={e('userName')}
108
+ label={e('username')}
109
109
  hasFeedback
110
110
  name='userName'
111
111
  required