@electerm/electerm-react 3.10.0 → 3.11.0

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.
@@ -0,0 +1,159 @@
1
+ import { agentTools, executeToolCall } from './agent-tools'
2
+
3
+ const MAX_ITERATIONS = 15
4
+
5
+ function buildAgentSystemPrompt (config) {
6
+ const lang = config.languageAI || window.store.getLangName()
7
+ const baseRole = config.roleAI || 'You are a helpful assistant.'
8
+ return `${baseRole}
9
+
10
+ You are operating inside electerm, a terminal/SSH client. You have access to tools that let you:
11
+ - Run commands in terminal tabs and read their output
12
+ - Open new terminal tabs (local or SSH)
13
+ - Manage bookmarks (create, list, open connections)
14
+ - Switch between tabs
15
+
16
+ When the user asks you to perform terminal operations, use the available tools.
17
+ Always explain what you are doing before executing commands.
18
+ If a command produces errors, analyze the output and try to fix the issue.
19
+ Prefer using the active terminal unless the user specifies otherwise.
20
+ For SSH connections, create a bookmark and open it rather than running ssh directly.
21
+
22
+ Reply in ${lang} language.`
23
+ }
24
+
25
+ function updateChatEntry (chatEntry, updates) {
26
+ const index = window.store.aiChatHistory.findIndex(i => i.id === chatEntry.id)
27
+ if (index !== -1) {
28
+ Object.assign(window.store.aiChatHistory[index], updates)
29
+ window.store.aiChatHistory = [...window.store.aiChatHistory]
30
+ }
31
+ }
32
+
33
+ async function callBackendAIchatWithTools (messages, config) {
34
+ return window.pre.runGlobalAsync(
35
+ 'AIchatWithTools',
36
+ messages,
37
+ config.modelAI,
38
+ config.baseURLAI,
39
+ config.apiPathAI,
40
+ config.apiKeyAI,
41
+ config.proxyAI,
42
+ agentTools
43
+ )
44
+ }
45
+
46
+ export async function runAgentLoop (chatEntry, config, abortRef) {
47
+ const messages = [
48
+ { role: 'system', content: buildAgentSystemPrompt(config) },
49
+ { role: 'user', content: chatEntry.prompt }
50
+ ]
51
+ const toolCallsLog = []
52
+ let accumulatedContent = ''
53
+
54
+ updateChatEntry(chatEntry, {
55
+ isStreaming: true,
56
+ toolCalls: [],
57
+ response: ''
58
+ })
59
+
60
+ for (let iteration = 0; iteration < MAX_ITERATIONS; iteration++) {
61
+ if (abortRef && abortRef.current) {
62
+ updateChatEntry(chatEntry, {
63
+ isStreaming: false,
64
+ response: accumulatedContent + '\n\n*(Agent stopped by user)*'
65
+ })
66
+ return
67
+ }
68
+
69
+ const result = await callBackendAIchatWithTools(messages, config)
70
+
71
+ if (result.error) {
72
+ updateChatEntry(chatEntry, {
73
+ isStreaming: false,
74
+ response: accumulatedContent + `\n\n**Error:** ${result.error}`
75
+ })
76
+ return
77
+ }
78
+
79
+ const assistantMessage = result.message
80
+ if (!assistantMessage) {
81
+ updateChatEntry(chatEntry, {
82
+ isStreaming: false,
83
+ response: accumulatedContent || 'No response from AI.'
84
+ })
85
+ return
86
+ }
87
+
88
+ messages.push(assistantMessage)
89
+
90
+ if (assistantMessage.content) {
91
+ accumulatedContent += (accumulatedContent ? '\n\n' : '') + assistantMessage.content
92
+ updateChatEntry(chatEntry, {
93
+ response: accumulatedContent
94
+ })
95
+ }
96
+
97
+ if (!assistantMessage.tool_calls || assistantMessage.tool_calls.length === 0) {
98
+ updateChatEntry(chatEntry, {
99
+ isStreaming: false,
100
+ response: accumulatedContent
101
+ })
102
+ return
103
+ }
104
+
105
+ for (const toolCall of assistantMessage.tool_calls) {
106
+ if (abortRef && abortRef.current) {
107
+ updateChatEntry(chatEntry, {
108
+ isStreaming: false,
109
+ response: accumulatedContent + '\n\n*(Agent stopped by user)*'
110
+ })
111
+ return
112
+ }
113
+
114
+ let args
115
+ try {
116
+ args = JSON.parse(toolCall.function.arguments)
117
+ } catch {
118
+ args = {}
119
+ }
120
+
121
+ const toolEntry = {
122
+ id: toolCall.id,
123
+ name: toolCall.function.name,
124
+ args,
125
+ status: 'running',
126
+ result: null
127
+ }
128
+ toolCallsLog.push(toolEntry)
129
+ updateChatEntry(chatEntry, {
130
+ toolCalls: [...toolCallsLog]
131
+ })
132
+
133
+ let toolResult
134
+ try {
135
+ toolResult = await executeToolCall(toolCall.function.name, args)
136
+ toolEntry.status = 'completed'
137
+ toolEntry.result = toolResult
138
+ } catch (err) {
139
+ toolEntry.status = 'error'
140
+ toolEntry.result = err.message
141
+ }
142
+
143
+ updateChatEntry(chatEntry, {
144
+ toolCalls: [...toolCallsLog]
145
+ })
146
+
147
+ messages.push({
148
+ role: 'tool',
149
+ tool_call_id: toolCall.id,
150
+ content: toolEntry.result
151
+ })
152
+ }
153
+ }
154
+
155
+ updateChatEntry(chatEntry, {
156
+ isStreaming: false,
157
+ response: accumulatedContent + '\n\n*(Agent reached maximum iterations)*'
158
+ })
159
+ }
@@ -0,0 +1,11 @@
1
+ import { lazy, Suspense } from 'react'
2
+
3
+ const AIChat = lazy(() => import('./ai-chat'))
4
+
5
+ export default function AIChatEntry (props) {
6
+ return (
7
+ <Suspense fallback={null}>
8
+ <AIChat {...props} />
9
+ </Suspense>
10
+ )
11
+ }
@@ -1,6 +1,8 @@
1
1
  import { useState, useEffect, useRef, useCallback } from 'react'
2
2
  import AIOutput from './ai-output'
3
3
  import AIStopIcon from './ai-stop-icon'
4
+ import AgentToolCallCard from './agent-tool-call-card'
5
+ import { runAgentLoop } from './agent'
4
6
  import {
5
7
  Alert,
6
8
  Tooltip
@@ -17,6 +19,7 @@ import { copy } from '../../common/clipboard'
17
19
  export default function AIChatHistoryItem ({ item }) {
18
20
  const [showOutput, setShowOutput] = useState(true)
19
21
  const startedRef = useRef(false)
22
+ const abortRef = useRef(false)
20
23
  const {
21
24
  prompt,
22
25
  isStreaming,
@@ -28,7 +31,9 @@ export default function AIChatHistoryItem ({ item }) {
28
31
  apiPathAI,
29
32
  apiKeyAI,
30
33
  proxyAI,
31
- languageAI
34
+ languageAI,
35
+ mode,
36
+ toolCalls
32
37
  } = item
33
38
 
34
39
  function toggleOutput () {
@@ -108,15 +113,42 @@ export default function AIChatHistoryItem ({ item }) {
108
113
  }
109
114
  }, [prompt, modelAI, baseURLAI, apiPathAI, apiKeyAI, proxyAI, item.id, pollStreamContent])
110
115
 
116
+ const startAgentRequest = useCallback(async () => {
117
+ abortRef.current = false
118
+ const config = {
119
+ modelAI,
120
+ roleAI,
121
+ baseURLAI,
122
+ apiPathAI,
123
+ apiKeyAI,
124
+ proxyAI,
125
+ languageAI
126
+ }
127
+ await runAgentLoop(item, config, abortRef)
128
+ }, [modelAI, roleAI, baseURLAI, apiPathAI, apiKeyAI, proxyAI, languageAI, item.id])
129
+
111
130
  useEffect(() => {
112
131
  if (!response && !startedRef.current) {
113
132
  startedRef.current = true
114
- startRequest()
133
+ if (mode === 'agent') {
134
+ startAgentRequest()
135
+ } else {
136
+ startRequest()
137
+ }
115
138
  }
116
139
  }, [])
117
140
 
118
141
  async function handleStop (e) {
119
142
  e.stopPropagation()
143
+ if (mode === 'agent') {
144
+ abortRef.current = true
145
+ const index = window.store.aiChatHistory.findIndex(i => i.id === item.id)
146
+ if (index !== -1) {
147
+ window.store.aiChatHistory[index].isStreaming = false
148
+ window.store.aiChatHistory = [...window.store.aiChatHistory]
149
+ }
150
+ return
151
+ }
120
152
  if (!sessionId) return
121
153
 
122
154
  try {
@@ -194,6 +226,19 @@ export default function AIChatHistoryItem ({ item }) {
194
226
  )
195
227
  }
196
228
 
229
+ function renderToolCalls () {
230
+ if (mode !== 'agent' || !toolCalls || !toolCalls.length) {
231
+ return null
232
+ }
233
+ return (
234
+ <div className='agent-tool-calls'>
235
+ {toolCalls.map((tc) => (
236
+ <AgentToolCallCard key={tc.id} toolCall={tc} />
237
+ ))}
238
+ </div>
239
+ )
240
+ }
241
+
197
242
  return (
198
243
  <div className='chat-history-item'>
199
244
  <div className='mg1y'>
@@ -201,6 +246,7 @@ export default function AIChatHistoryItem ({ item }) {
201
246
  <Alert {...alertProps} />
202
247
  </Tooltip>
203
248
  </div>
249
+ {renderToolCalls()}
204
250
  {showOutput && <AIOutput item={item} />}
205
251
  </div>
206
252
  )
@@ -1,5 +1,5 @@
1
1
  import { useState, useCallback, useEffect } from 'react'
2
- import { Flex, Input } from 'antd'
2
+ import { Flex, Input, Segmented } from 'antd'
3
3
  import TabSelect from '../footer/tab-select'
4
4
  import AiChatHistory from './ai-chat-history'
5
5
  import uid from '../../common/uid'
@@ -21,6 +21,8 @@ const MAX_HISTORY = 100
21
21
 
22
22
  export default function AIChat (props) {
23
23
  const [prompt, setPrompt] = useState('')
24
+ const [mode, setMode] = useState('ask')
25
+ const isAgent = mode === 'agent'
24
26
 
25
27
  function handlePromptChange (e) {
26
28
  setPrompt(e.target.value)
@@ -38,6 +40,8 @@ export default function AIChat (props) {
38
40
  response: '',
39
41
  isStreaming: false,
40
42
  sessionId: null,
43
+ mode,
44
+ toolCalls: [],
41
45
  ...pick(props.config, [
42
46
  'modelAI',
43
47
  'roleAI',
@@ -57,7 +61,7 @@ export default function AIChat (props) {
57
61
  if (window.store.aiChatHistory.length > MAX_HISTORY) {
58
62
  window.store.aiChatHistory.splice(MAX_HISTORY)
59
63
  }
60
- }, [prompt])
64
+ }, [prompt, mode])
61
65
 
62
66
  function renderHistory () {
63
67
  return (
@@ -75,6 +79,19 @@ export default function AIChat (props) {
75
79
  window.store.aiChatHistory = []
76
80
  }
77
81
 
82
+ function renderTabSelect () {
83
+ if (isAgent) {
84
+ return null
85
+ }
86
+ return (
87
+ <TabSelect
88
+ selectedTabIds={props.selectedTabIds}
89
+ tabs={props.tabs}
90
+ activeTabId={props.activeTabId}
91
+ />
92
+ )
93
+ }
94
+
78
95
  function renderSendIcon () {
79
96
  return (
80
97
  <SendOutlined
@@ -126,11 +143,13 @@ export default function AIChat (props) {
126
143
  />
127
144
  <Flex className='ai-chat-terminals' justify='space-between' align='center'>
128
145
  <Flex align='center'>
129
- <TabSelect
130
- selectedTabIds={props.selectedTabIds}
131
- tabs={props.tabs}
132
- activeTabId={props.activeTabId}
146
+ <Segmented
147
+ options={['Ask', 'Agent']}
148
+ value={mode === 'ask' ? 'Ask' : 'Agent'}
149
+ onChange={(val) => setMode(val === 'Ask' ? 'ask' : 'agent')}
150
+ size='small'
133
151
  />
152
+ {renderTabSelect()}
134
153
  <SettingOutlined
135
154
  onClick={toggleConfig}
136
155
  className='mg1l pointer icon-hover toggle-ai-setting-icon'
@@ -6,7 +6,7 @@ import {
6
6
  Alert,
7
7
  Space
8
8
  } from 'antd'
9
- import { useEffect } from 'react'
9
+ import { useEffect, useState } from 'react'
10
10
  import Link from '../common/external-link'
11
11
  import AiCache from './ai-cache'
12
12
  import {
@@ -14,6 +14,7 @@ import {
14
14
  } from '../../common/constants'
15
15
  import Password from '../common/password'
16
16
  import AiHistory, { addHistoryItem } from './ai-history'
17
+ import message from '../common/message'
17
18
 
18
19
  const STORAGE_KEY_CONFIG = 'ai_config_history'
19
20
  const EVENT_NAME_CONFIG = 'ai-config-history-update'
@@ -36,6 +37,7 @@ const proxyOptions = [
36
37
 
37
38
  export default function AIConfigForm ({ initialValues, onSubmit, showAIConfig }) {
38
39
  const [form] = Form.useForm()
40
+ const [testing, setTesting] = useState(false)
39
41
  const baseURLAI = Form.useWatch('baseURLAI', form)
40
42
 
41
43
  useEffect(() => {
@@ -53,6 +55,37 @@ export default function AIConfigForm ({ initialValues, onSubmit, showAIConfig })
53
55
  addHistoryItem(STORAGE_KEY_CONFIG, values, EVENT_NAME_CONFIG)
54
56
  }
55
57
 
58
+ const handleTest = async () => {
59
+ try {
60
+ const values = await form.validateFields()
61
+ setTesting(true)
62
+ const res = await window.pre.runGlobalAsync(
63
+ 'AIchat',
64
+ 'Hi',
65
+ values.modelAI,
66
+ values.roleAI,
67
+ values.baseURLAI,
68
+ values.apiPathAI,
69
+ values.apiKeyAI,
70
+ values.proxyAI,
71
+ false
72
+ )
73
+ if (res && res.error) {
74
+ message.error(res.error)
75
+ } else if (res && res.response) {
76
+ message.success('AI config works!')
77
+ } else {
78
+ message.error('Unexpected response from AI API')
79
+ }
80
+ } catch (e) {
81
+ if (e.message) {
82
+ message.error(e.message)
83
+ }
84
+ } finally {
85
+ setTesting(false)
86
+ }
87
+ }
88
+
56
89
  function handleSelectHistory (item) {
57
90
  if (item && typeof item === 'object') {
58
91
  form.setFieldsValue(item)
@@ -186,9 +219,17 @@ export default function AIConfigForm ({ initialValues, onSubmit, showAIConfig })
186
219
  </Form.Item>
187
220
 
188
221
  <Form.Item>
189
- <Button type='primary' htmlType='submit'>
190
- {e('save')}
191
- </Button>
222
+ <Space>
223
+ <Button type='primary' htmlType='submit'>
224
+ {e('save')}
225
+ </Button>
226
+ <Button
227
+ loading={testing}
228
+ onClick={handleTest}
229
+ >
230
+ {e('testConnection')}
231
+ </Button>
232
+ </Space>
192
233
  </Form.Item>
193
234
  </Form>
194
235
  <AiHistory
@@ -77,3 +77,76 @@
77
77
  align-items center
78
78
  .ai-stop-icon-square
79
79
  margin-left auto
80
+
81
+ .agent-tool-calls
82
+ margin 8px 0
83
+
84
+ .agent-tool-call-card
85
+ border 1px solid var(--main-darker)
86
+ border-radius 4px
87
+ margin-bottom 6px
88
+ font-size 13px
89
+
90
+ .agent-tool-header
91
+ display flex
92
+ align-items center
93
+ padding 6px 8px
94
+ gap 6px
95
+
96
+ .agent-tool-name
97
+ font-weight 500
98
+
99
+ .agent-tool-tag
100
+ margin-left auto
101
+
102
+ .agent-tool-status-running
103
+ color #1890ff
104
+ animation spin 1s linear infinite
105
+
106
+ .agent-tool-status-completed
107
+ color #52c41a
108
+
109
+ .agent-tool-status-error
110
+ color #ff4d4f
111
+
112
+ .agent-tool-detail
113
+ padding 0 8px 8px
114
+ border-top 1px solid var(--main-darker)
115
+
116
+ .agent-tool-args
117
+ margin-top 6px
118
+
119
+ .agent-tool-result
120
+ margin-top 6px
121
+
122
+ .agent-tool-label
123
+ font-weight 500
124
+ margin-bottom 4px
125
+ font-size 12px
126
+ color var(--text-color-2)
127
+
128
+ .agent-tool-pre
129
+ background var(--main-dark)
130
+ padding 6px 8px
131
+ border-radius 3px
132
+ font-size 12px
133
+ max-height 200px
134
+ overflow-y auto
135
+ white-space pre-wrap
136
+ word-break break-all
137
+ margin-bottom 0
138
+
139
+ .agent-tool-running
140
+ border-color #1890ff
141
+
142
+ .agent-tool-completed
143
+ border-color #52c41a
144
+
145
+ .agent-tool-error
146
+ border-color #ff4d4f
147
+
148
+ @keyframes spin
149
+ from
150
+ transform rotate(0deg)
151
+ to
152
+ transform rotate(360deg)
@@ -4,7 +4,7 @@ import Layout from '../layout/layout'
4
4
  import FileInfoModal from '../sftp/file-info-modal'
5
5
  import UpdateCheck from './upgrade'
6
6
  import SettingModal from '../setting-panel/setting-modal'
7
- import TextEditor from '../text-editor/text-editor'
7
+ import TextEditor from '../text-editor/text-editor-entry'
8
8
  import Sidebar from '../sidebar'
9
9
  import CssOverwrite from '../bg/css-overwrite'
10
10
  import UiTheme from './ui-theme'
@@ -19,7 +19,6 @@ import TransportsActionStore from '../file-transfer/transports-action-store.jsx'
19
19
  import classnames from 'classnames'
20
20
  import ShortcutControl from '../shortcuts/shortcut-control.jsx'
21
21
  import { isMac, isWin, textTerminalBgValue } from '../../common/constants'
22
- import TerminalInfo from '../terminal-info/terminal-info'
23
22
  import { ConfigProvider } from 'antd'
24
23
  import { NotificationContainer } from '../common/notification'
25
24
  import InfoModal from '../sidebar/info-modal.jsx'
@@ -27,7 +26,7 @@ import RightSidePanel from '../side-panel-r/side-panel-r'
27
26
  import ConnectionHoppingWarning from './connection-hopping-warnning'
28
27
  import SshConfigLoadNotify from '../ssh-config/ssh-config-load-notify'
29
28
  import LoadSshConfigs from '../ssh-config/load-ssh-configs'
30
- import AIChat from '../ai/ai-chat'
29
+ import AIChat from '../ai/ai-chat-entry'
31
30
  import AIConfigModal from '../ai/ai-config-modal'
32
31
  import Opacity from '../common/opacity'
33
32
  import MoveItemModal from '../tree-list/move-item-modal'
@@ -40,6 +39,7 @@ import UnixTimestampTooltip from '../terminal/unix-timestamp-tooltip'
40
39
  import { pick } from 'lodash-es'
41
40
  import deepCopy from 'json-deep-copy'
42
41
  import './wrapper.styl'
42
+ import TerminalInfo from '../terminal-info/terminal-info-entry'
43
43
  import './term-fullscreen.styl'
44
44
 
45
45
  export default auto(function Index (props) {
@@ -273,6 +273,9 @@ export class FileTransferManager {
273
273
  })
274
274
 
275
275
  this.log(`Downloaded ${fileInfo.name} (${filesize(fileInfo._totalSize)}) to ${fullPath}`, 'success')
276
+ this.hasRemoteFiles = false
277
+ this.pendingDownloads.clear()
278
+ this.notifyStateChange()
276
279
  if (this.onDownloadComplete) {
277
280
  this.onDownloadComplete(fullPath, fileInfo.name, fileInfo._totalSize)
278
281
  }
@@ -33,7 +33,7 @@ export default memo(function TerminalErrorHandle ({
33
33
  return (
34
34
  <Alert
35
35
  className='terminal-error-handle'
36
- message={errorMessage}
36
+ title={errorMessage}
37
37
  type='error'
38
38
  showIcon
39
39
  banner