@electerm/electerm-react 5.5.25 → 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.
Files changed (41) hide show
  1. package/README.md +5 -5
  2. package/client/common/bookmark-schemas.js +3 -0
  3. package/client/common/constants.js +2 -0
  4. package/client/common/create-title.jsx +5 -1
  5. package/client/common/default-setting.js +2 -0
  6. package/client/common/parse-vv.js +119 -25
  7. package/client/common/vv-to-tab.js +120 -0
  8. package/client/components/ai/agent-tool-call-card.jsx +19 -4
  9. package/client/components/ai/agent.js +33 -16
  10. package/client/components/ai/ai-chat-history-item.jsx +12 -48
  11. package/client/components/ai/ai-chat.jsx +82 -25
  12. package/client/components/ai/ai-config-props.js +12 -1
  13. package/client/components/ai/ai-config.jsx +14 -0
  14. package/client/components/ai/ai-context-indicator.jsx +83 -0
  15. package/client/components/ai/ai-context.js +272 -0
  16. package/client/components/ai/ai-output.jsx +4 -0
  17. package/client/components/ai/ai-presets.js +1 -1
  18. package/client/components/ai/ai.styl +105 -6
  19. package/client/components/ai/copy-icon.jsx +31 -0
  20. package/client/components/bookmark-form/common/vv-file-field.jsx +3 -2
  21. package/client/components/bookmark-form/config/spice.js +21 -0
  22. package/client/components/footer/cmd-history.jsx +279 -65
  23. package/client/components/footer/cmd-history.styl +68 -1
  24. package/client/components/footer/footer-entry.jsx +7 -1
  25. package/client/components/layout/layout.jsx +12 -19
  26. package/client/components/main/main.jsx +14 -1
  27. package/client/components/quick-commands/qm.styl +58 -0
  28. package/client/components/quick-commands/quick-commands-box.jsx +180 -83
  29. package/client/components/quick-commands/quick-commands-select.jsx +11 -0
  30. package/client/components/setting-panel/setting-common.jsx +2 -0
  31. package/client/components/side-panel-r/side-panel-r.jsx +11 -4
  32. package/client/components/sidebar/bookmark-select.jsx +14 -2
  33. package/client/components/sidebar/history-item.jsx +20 -4
  34. package/client/components/sidebar/history.jsx +7 -0
  35. package/client/components/tree-list/tree-list.jsx +31 -3
  36. package/client/store/common.js +120 -32
  37. package/client/store/init-state.js +8 -0
  38. package/client/store/load-data.js +72 -0
  39. package/client/store/store.js +6 -0
  40. package/client/store/watch.js +3 -1
  41. package/package.json +1 -1
@@ -1,8 +1,11 @@
1
- import { useState, useCallback, useEffect, useRef } from 'react'
1
+ import { useState, useCallback, useEffect, useMemo, useRef } from 'react'
2
2
  import { Flex, Input, Segmented, Button, Tag, message } from 'antd'
3
3
  import TabSelect from '../footer/tab-select'
4
4
  import AiChatHistory from './ai-chat-history'
5
5
  import AiChatSessions from './ai-chat-sessions'
6
+ import AiContextIndicator from './ai-context-indicator'
7
+ import { buildSessionMessages, summarizeContext, getUsageLevel, formatPercent } from './ai-context'
8
+ import { appendMandatoryGuardrails } from './ai-guardrails'
6
9
  import uid from '../../common/uid'
7
10
  import { pick } from 'lodash-es'
8
11
  import {
@@ -53,6 +56,48 @@ export default function AIChat (props) {
53
56
  h => h.chatSessionId === currentChatSessionId
54
57
  )
55
58
 
59
+ const config = props.config || {}
60
+
61
+ // Size of the context the *next* message will carry: every entry of this
62
+ // session, from the last compression onwards, built by the same helper the
63
+ // sender uses. While an agent loop is running its own, much larger, figure
64
+ // is published to the store (tool schemas and tool results included) and
65
+ // takes precedence.
66
+ const sessionContextInfo = useMemo(() => {
67
+ if (!currentChatSessionId) {
68
+ return null
69
+ }
70
+ const lang = config.languageAI || window.store.getLangName()
71
+ const role = appendMandatoryGuardrails(
72
+ (config.roleAI || '') + `;用[${lang}]回复`
73
+ )
74
+ const messages = buildSessionMessages({
75
+ history: props.aiChatHistory,
76
+ chatSessionId: currentChatSessionId,
77
+ role
78
+ })
79
+ if (!messages || messages.length < 2) {
80
+ return null
81
+ }
82
+ return summarizeContext(messages, {
83
+ model: config.modelAI,
84
+ contextLength: config.contextLengthAI
85
+ })
86
+ }, [
87
+ props.aiChatHistory,
88
+ // length as well as identity: sending a message pushes onto the existing
89
+ // array, so the reference alone would not change
90
+ props.aiChatHistory.length,
91
+ currentChatSessionId,
92
+ config.roleAI,
93
+ config.languageAI,
94
+ config.modelAI,
95
+ config.contextLengthAI
96
+ ])
97
+
98
+ const contextInfo = props.aiContextInfo || sessionContextInfo
99
+ const contextLevel = getUsageLevel(contextInfo && contextInfo.percent)
100
+
56
101
  function handlePromptChange (e) {
57
102
  setPrompt(e.target.value)
58
103
  }
@@ -179,6 +224,13 @@ export default function AIChat (props) {
179
224
  }
180
225
  }
181
226
 
227
+ function renderCompressTitle () {
228
+ if ((contextLevel === 'warn' || contextLevel === 'danger') && contextInfo) {
229
+ return `Context is ${formatPercent(contextInfo.percent)} full — compress to summarize the session`
230
+ }
231
+ return 'Summarize this session into a single message, dropping the older history'
232
+ }
233
+
182
234
  function handleShowHistory () {
183
235
  window.store.toggleChatSessions()
184
236
  }
@@ -259,34 +311,39 @@ export default function AIChat (props) {
259
311
  </Flex>
260
312
 
261
313
  <Flex vertical className='ai-chat-input'>
262
- <Flex className='ai-chat-toolbar mg1b' align='left' gap={4}>
263
- <Button
264
- size='small'
265
- icon={<PlusOutlined />}
266
- onClick={handleNewChat}
267
- className='mg1r new-chat-btn'
268
- >
269
- {e('new')}
270
- </Button>
271
- {sessionHistory.length >= 2 && (
314
+ <Flex className='ai-chat-toolbar mg1b' align='center' justify='space-between' gap={4}>
315
+ <Flex align='center' gap={4}>
316
+ <Button
317
+ size='small'
318
+ icon={<PlusOutlined />}
319
+ onClick={handleNewChat}
320
+ className='new-chat-btn'
321
+ >
322
+ {e('new')}
323
+ </Button>
324
+ {sessionHistory.length >= 2 && (
325
+ <Button
326
+ size='small'
327
+ icon={<CompressOutlined />}
328
+ onClick={handleCompressSession}
329
+ loading={compressing}
330
+ type={contextLevel === 'warn' ? 'primary' : 'default'}
331
+ danger={contextLevel === 'danger'}
332
+ title={renderCompressTitle()}
333
+ >
334
+ {e('compress')}
335
+ </Button>
336
+ )}
272
337
  <Button
273
338
  size='small'
274
- icon={<CompressOutlined />}
275
- onClick={handleCompressSession}
276
- loading={compressing}
277
- className='mg1r'
339
+ icon={<HistoryOutlined />}
340
+ onClick={handleShowHistory}
341
+ type={props.showChatSessions ? 'primary' : 'default'}
278
342
  >
279
- {e('compress')}
343
+ {e('history')}
280
344
  </Button>
281
- )}
282
- <Button
283
- size='small'
284
- icon={<HistoryOutlined />}
285
- onClick={handleShowHistory}
286
- type={props.showChatSessions ? 'primary' : 'default'}
287
- >
288
- {e('history')}
289
- </Button>
345
+ </Flex>
346
+ <AiContextIndicator info={contextInfo} />
290
347
  </Flex>
291
348
  {attachments.length > 0 && (
292
349
  <Flex wrap='wrap' gap={4} className='ai-chat-attachments mg1b'>
@@ -7,7 +7,18 @@ export const aiConfigsArr = [
7
7
  'authHeaderNameAI',
8
8
  'apiPathAI',
9
9
  'languageAI',
10
- 'proxyAI'
10
+ 'proxyAI',
11
+ 'contextLengthAI'
12
+ ]
13
+
14
+ // Keys that may legitimately be empty. `aiConfigMissing` only forces the
15
+ // config modal open when one of the *other* keys is unset, so anything
16
+ // optional has to be listed here or the modal would pop up forever.
17
+ export const optionalAIConfigsArr = [
18
+ 'apiKeyAI',
19
+ 'proxyAI',
20
+ 'nameAI',
21
+ 'contextLengthAI'
11
22
  ]
12
23
 
13
24
  export {
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  Form,
3
3
  Input,
4
+ InputNumber,
4
5
  Button,
5
6
  AutoComplete,
6
7
  Alert,
@@ -356,6 +357,19 @@ export default function AIConfigForm ({ initialValues, onSubmit, showAIConfig })
356
357
  {renderModelInput()}
357
358
  </Form.Item>
358
359
 
360
+ <Form.Item
361
+ label='Context length'
362
+ name='contextLengthAI'
363
+ tooltip='How many tokens this model can hold in one request. Leave empty to guess from the model name; the chat panel uses it to show how full the context is.'
364
+ >
365
+ <InputNumber
366
+ placeholder='auto (detect from model name)'
367
+ min={1000}
368
+ step={1000}
369
+ style={{ width: '100%' }}
370
+ />
371
+ </Form.Item>
372
+
359
373
  <Form.Item
360
374
  label={renderApiKeyLabel()}
361
375
  name='apiKeyAI'
@@ -0,0 +1,83 @@
1
+ import { Tooltip } from 'antd'
2
+ import {
3
+ formatTokens,
4
+ formatPercent,
5
+ getUsageLevel,
6
+ CONTEXT_WARN_PERCENT
7
+ } from './ai-context'
8
+
9
+ // How full the context window is, as a small bar + count, with the breakdown
10
+ // in the tooltip. The number is what the next request will carry, so it is
11
+ // the one to watch before pressing Compress.
12
+ export default function AiContextIndicator ({ info }) {
13
+ if (!info || !info.tokens) {
14
+ return null
15
+ }
16
+ const {
17
+ tokens,
18
+ messagesTokens,
19
+ toolsTokens,
20
+ windowSize,
21
+ windowSource,
22
+ percent,
23
+ measuredTokens
24
+ } = info
25
+ const level = getUsageLevel(percent)
26
+ const width = Math.min(100, Math.max(2, percent || 0))
27
+
28
+ function renderWindowSource () {
29
+ if (windowSource === 'config') {
30
+ return `window ${formatTokens(windowSize)}, set in AI config`
31
+ }
32
+ if (windowSource === 'model') {
33
+ return `window ${formatTokens(windowSize)}, from the model name`
34
+ }
35
+ return `window assumed to be ${formatTokens(windowSize)} — set "Context length" in AI config if your model differs`
36
+ }
37
+
38
+ const tip = (
39
+ <div className='ai-context-tip'>
40
+ <div className='ai-context-tip-title'>Context of the next message</div>
41
+ <div className='ai-context-tip-row'>
42
+ <span>system + history</span>
43
+ <span>~{formatTokens(messagesTokens)}</span>
44
+ </div>
45
+ {toolsTokens > 0 && (
46
+ <div className='ai-context-tip-row'>
47
+ <span>agent tools</span>
48
+ <span>~{formatTokens(toolsTokens)}</span>
49
+ </div>
50
+ )}
51
+ <div className='ai-context-tip-row ai-context-tip-total'>
52
+ <span>total</span>
53
+ <span>~{formatTokens(tokens)} ({formatPercent(percent)})</span>
54
+ </div>
55
+ {measuredTokens > 0 && (
56
+ <div className='ai-context-tip-row'>
57
+ <span>last request, measured</span>
58
+ <span>{measuredTokens.toLocaleString()}</span>
59
+ </div>
60
+ )}
61
+ <div className='ai-context-tip-note'>{renderWindowSource()}</div>
62
+ {(level === 'warn' || level === 'danger') && (
63
+ <div className='ai-context-tip-warn'>
64
+ Over {CONTEXT_WARN_PERCENT}% — use Compress to summarize the session.
65
+ </div>
66
+ )}
67
+ </div>
68
+ )
69
+
70
+ return (
71
+ <Tooltip title={tip}>
72
+ <span className={`ai-context-indicator ai-context-${level}`}>
73
+ <span className='ai-context-bar'>
74
+ <span className='ai-context-bar-fill' style={{ width: width + '%' }} />
75
+ </span>
76
+ <span className='ai-context-count'>
77
+ {formatTokens(tokens)}/{formatTokens(windowSize)}
78
+ </span>
79
+ <span className='ai-context-percent'>{formatPercent(percent)}</span>
80
+ </span>
81
+ </Tooltip>
82
+ )
83
+ }
@@ -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>
@@ -6,7 +6,7 @@ export const defaultAIPresets = [
6
6
  apiPathAI: '/chat/completions',
7
7
  modelAI: 'deepseek-v4-flash-0731',
8
8
  authHeaderNameAI: 'Authorization: Bearer',
9
- siteUrl: 'https://fluxionai.world/register?aff=7H7DERHU3GFF&utm_source=github&utm_medium=link&utm_campaign=electerm'
9
+ siteUrl: 'https://fluxionai.world/register?source=github&campaign=electerm-20260923&promo=ELECTERM&aff=7H7DERHU3GFF'
10
10
  },
11
11
  {
12
12
  id: 'atlascloud',