@electerm/electerm-react 5.5.15 → 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.
- package/README.md +14 -4
- package/client/common/bookmark-schemas.js +3 -0
- package/client/common/constants.js +16 -0
- package/client/common/create-title.jsx +5 -1
- package/client/common/default-setting.js +4 -2
- package/client/common/parse-vv.js +407 -0
- package/client/common/theme-defaults.js +5 -2
- package/client/common/update-check.js +5 -4
- package/client/common/vv-to-tab.js +120 -0
- package/client/components/ai/agent-tool-call-card.jsx +19 -4
- package/client/components/ai/agent.js +33 -16
- package/client/components/ai/ai-chat-history-item.jsx +12 -48
- package/client/components/ai/ai-chat.jsx +82 -25
- package/client/components/ai/ai-config-props.js +12 -1
- package/client/components/ai/ai-config.jsx +14 -0
- package/client/components/ai/ai-context-indicator.jsx +83 -0
- package/client/components/ai/ai-context.js +272 -0
- package/client/components/ai/ai-output.jsx +4 -0
- package/client/components/ai/ai-presets.js +13 -2
- package/client/components/ai/ai.styl +105 -6
- package/client/components/ai/copy-icon.jsx +31 -0
- package/client/components/bg/css-overwrite.jsx +16 -1
- package/client/components/bookmark-form/common/fields.jsx +3 -0
- package/client/components/bookmark-form/common/vv-file-field.jsx +138 -0
- package/client/components/bookmark-form/config/spice.js +22 -0
- package/client/components/footer/cmd-history.jsx +279 -65
- package/client/components/footer/cmd-history.styl +68 -1
- package/client/components/footer/footer-entry.jsx +46 -15
- package/client/components/icons/ai-icon.styl +4 -1
- package/client/components/layout/layout.jsx +12 -13
- package/client/components/main/main.jsx +23 -3
- package/client/components/main/term-fullscreen.styl +7 -0
- package/client/components/quick-commands/qm.styl +58 -0
- package/client/components/quick-commands/quick-command-ai-editor.jsx +255 -0
- package/client/components/quick-commands/quick-command-ai-editor.styl +13 -0
- package/client/components/quick-commands/quick-commands-box.jsx +197 -82
- package/client/components/quick-commands/quick-commands-form-elem.jsx +129 -65
- package/client/components/quick-commands/quick-commands-select.jsx +11 -0
- package/client/components/setting-panel/list.jsx +2 -2
- package/client/components/setting-panel/setting-common.jsx +2 -0
- package/client/components/side-panel-r/right-side-panel.styl +11 -1
- package/client/components/side-panel-r/side-panel-r.jsx +56 -13
- package/client/components/sidebar/bookmark-select.jsx +17 -3
- package/client/components/sidebar/history-item.jsx +20 -4
- package/client/components/sidebar/history.jsx +7 -0
- package/client/components/sidebar/index.jsx +13 -4
- package/client/components/sidebar/side-panel.jsx +8 -3
- package/client/components/sidebar/sidebar-panel.jsx +20 -7
- package/client/components/terminal/shortcut-bar.styl +14 -0
- package/client/components/terminal/unix-timestamp-tooltip.jsx +27 -1
- package/client/components/terminal-info/terminal-info.styl +3 -0
- package/client/components/theme/terminal-theme-list.styl +3 -0
- package/client/components/theme/theme-ai-editor.jsx +2 -2
- package/client/components/theme/theme-form.jsx +2 -2
- package/client/components/theme/theme-list-item.jsx +59 -25
- package/client/components/tree-list/tree-list.jsx +31 -3
- package/client/css/mobile.styl +28 -0
- package/client/store/common.js +164 -32
- package/client/store/init-state.js +10 -1
- package/client/store/load-data.js +72 -0
- package/client/store/sidebar.js +5 -1
- package/client/store/store.js +15 -2
- package/client/store/watch.js +3 -1
- package/package.json +1 -1
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* .vv connection file -> electerm Spice tab options
|
|
3
|
+
*
|
|
4
|
+
* Kept apart from parse-vv.js on purpose: parse-vv.js knows the file format
|
|
5
|
+
* and nothing about electerm, this module knows electerm's Spice tab shape and
|
|
6
|
+
* nothing about the format. Staying free of window/api access is what makes it
|
|
7
|
+
* unit-testable, and it is shared by both entry points for a .vv file --
|
|
8
|
+
* the bookmark form's "Load .vv file" button and a file handed to electerm
|
|
9
|
+
* from outside (`electerm /path/to/console.vv`).
|
|
10
|
+
*
|
|
11
|
+
* Deliberately imports nothing: constants.js pulls in the logo .png files and
|
|
12
|
+
* `window`, which would make this unimportable under `node --test`.
|
|
13
|
+
* parse-quick-connect.js spells the type out for the same reason.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The `spice` value of terminalSpiceType (src/client/common/constants.js).
|
|
18
|
+
* A .vv file is a virt-viewer file, and the only type this client implements
|
|
19
|
+
* is spice -- parse-vv.js rejects every other `type=` already.
|
|
20
|
+
*/
|
|
21
|
+
const SPICE_TYPE = 'spice'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Mirrors the Spice defaults in components/bookmark-form/config/spice.js
|
|
25
|
+
* initValues(), so a .vv that omits them behaves exactly like a Spice bookmark
|
|
26
|
+
* added by hand.
|
|
27
|
+
*/
|
|
28
|
+
const SPICE_DEFAULTS = {
|
|
29
|
+
viewOnly: false,
|
|
30
|
+
scaleViewport: true
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Port used when the file names a host but no port at all. Matches
|
|
35
|
+
* DEFAULT_PORTS.spice in parse-quick-connect.js.
|
|
36
|
+
*/
|
|
37
|
+
const DEFAULT_SPICE_PORT = 5900
|
|
38
|
+
|
|
39
|
+
const VV_EXT = /\.vv$/i
|
|
40
|
+
const URL_LIKE = /:\/\//
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Does this look like a .vv file path?
|
|
44
|
+
*
|
|
45
|
+
* A suffix test rather than an exists()/stat() call on purpose: this is asked
|
|
46
|
+
* synchronously while scanning a command line, and the read that follows
|
|
47
|
+
* reports a missing file far better than a bare boolean could.
|
|
48
|
+
*
|
|
49
|
+
* `://` is excluded so a deep link that happens to end in .vv
|
|
50
|
+
* (spice://host/console.vv) is not mistaken for a path -- deep links have their
|
|
51
|
+
* own parser and would otherwise be read off the disk.
|
|
52
|
+
*
|
|
53
|
+
* @param {string} value
|
|
54
|
+
* @returns {boolean}
|
|
55
|
+
*/
|
|
56
|
+
function isVvFile (value) {
|
|
57
|
+
if (typeof value !== 'string') {
|
|
58
|
+
return false
|
|
59
|
+
}
|
|
60
|
+
const text = value.trim()
|
|
61
|
+
return VV_EXT.test(text) && !URL_LIKE.test(text)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Last path segment, for a tab title when the file carries no `title` key.
|
|
66
|
+
* Done by hand because the renderer has no path module.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} filePath
|
|
69
|
+
* @returns {string}
|
|
70
|
+
*/
|
|
71
|
+
function basename (filePath) {
|
|
72
|
+
return String(filePath).split(/[/\\]/).pop() || String(filePath)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Build tab options from a parse result.
|
|
77
|
+
*
|
|
78
|
+
* Only keys the file actually provided are set. An absent `ca` or `hostSubject`
|
|
79
|
+
* must stay absent rather than become an empty string, because the session code
|
|
80
|
+
* reads "no ca" as "do not verify against a pinned CA" -- an empty string there
|
|
81
|
+
* would mean "pin nothing", which fails every handshake.
|
|
82
|
+
*
|
|
83
|
+
* @param {Object} parsed - result of parseVv()
|
|
84
|
+
* @param {Object} [options] - { filePath } plus any tab overrides
|
|
85
|
+
* @returns {Object} options for store.addTab()
|
|
86
|
+
*/
|
|
87
|
+
function vvToTab (parsed, options = {}) {
|
|
88
|
+
const { filePath, ...extra } = options
|
|
89
|
+
const f = parsed.fields
|
|
90
|
+
const tab = {
|
|
91
|
+
type: SPICE_TYPE,
|
|
92
|
+
host: f.host,
|
|
93
|
+
port: f.port || DEFAULT_SPICE_PORT,
|
|
94
|
+
tls: !!f.tls,
|
|
95
|
+
...SPICE_DEFAULTS
|
|
96
|
+
}
|
|
97
|
+
const optional = {
|
|
98
|
+
ca: f.ca,
|
|
99
|
+
hostSubject: f.hostSubject,
|
|
100
|
+
password: f.password,
|
|
101
|
+
proxy: f.proxy,
|
|
102
|
+
title: f.title || (filePath ? basename(filePath) : '')
|
|
103
|
+
}
|
|
104
|
+
Object.keys(optional).forEach(key => {
|
|
105
|
+
if (optional[key]) {
|
|
106
|
+
tab[key] = optional[key]
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
return { ...tab, ...extra }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export {
|
|
113
|
+
isVvFile,
|
|
114
|
+
SPICE_TYPE,
|
|
115
|
+
vvToTab,
|
|
116
|
+
basename,
|
|
117
|
+
VV_EXT,
|
|
118
|
+
DEFAULT_SPICE_PORT,
|
|
119
|
+
SPICE_DEFAULTS
|
|
120
|
+
}
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
CodeOutlined,
|
|
10
10
|
DatabaseOutlined
|
|
11
11
|
} from '@ant-design/icons'
|
|
12
|
+
import CopyIcon from './copy-icon'
|
|
12
13
|
|
|
13
14
|
const toolIcons = {
|
|
14
15
|
send_terminal_command: CodeOutlined,
|
|
@@ -65,6 +66,20 @@ export default function AgentToolCallCard ({ toolCall, autoCollapse }) {
|
|
|
65
66
|
)
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
function renderPre (text) {
|
|
70
|
+
return (
|
|
71
|
+
<div className='agent-tool-pre-wrap'>
|
|
72
|
+
<CopyIcon text={text} className='agent-tool-copy' />
|
|
73
|
+
<pre className='agent-tool-pre'>{text}</pre>
|
|
74
|
+
</div>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const argsText = args && Object.keys(args).length > 0
|
|
79
|
+
? JSON.stringify(args, null, 2)
|
|
80
|
+
: ''
|
|
81
|
+
const resultText = formatResult(result)
|
|
82
|
+
|
|
68
83
|
return (
|
|
69
84
|
<div className={`agent-tool-call-card agent-tool-${status}`}>
|
|
70
85
|
<div
|
|
@@ -79,16 +94,16 @@ export default function AgentToolCallCard ({ toolCall, autoCollapse }) {
|
|
|
79
94
|
</div>
|
|
80
95
|
{expanded && (
|
|
81
96
|
<div className='agent-tool-detail'>
|
|
82
|
-
{
|
|
97
|
+
{argsText && (
|
|
83
98
|
<div className='agent-tool-args'>
|
|
84
99
|
<div className='agent-tool-label'>Arguments:</div>
|
|
85
|
-
|
|
100
|
+
{renderPre(argsText)}
|
|
86
101
|
</div>
|
|
87
102
|
)}
|
|
88
|
-
{
|
|
103
|
+
{resultText && (
|
|
89
104
|
<div className='agent-tool-result'>
|
|
90
105
|
<div className='agent-tool-label'>Result:</div>
|
|
91
|
-
|
|
106
|
+
{renderPre(resultText)}
|
|
92
107
|
</div>
|
|
93
108
|
)}
|
|
94
109
|
</div>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { agentTools, executeToolCall } from './agent-tools'
|
|
2
2
|
import { appendMandatoryGuardrails } from './ai-guardrails'
|
|
3
|
+
import { buildAgentMessages, summarizeContext } from './ai-context'
|
|
3
4
|
|
|
4
5
|
const MAX_ITERATIONS = 150
|
|
5
6
|
|
|
@@ -47,27 +48,35 @@ async function callBackendAIchatWithTools (messages, config) {
|
|
|
47
48
|
)
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
// Publish the size of the context the agent is carrying so the panel can show
|
|
52
|
+
// it. An agent request is not just the conversation: it also carries every
|
|
53
|
+
// tool schema, and each iteration appends the tool results, which is what
|
|
54
|
+
// actually fills the window. `usage` is the provider's own count for the
|
|
55
|
+
// previous request and is kept for comparison -- the estimate is what the
|
|
56
|
+
// *next* request will cost, so that is the number on display.
|
|
57
|
+
function publishContext (messages, config, usage) {
|
|
58
|
+
const info = summarizeContext(messages, {
|
|
59
|
+
model: config.modelAI,
|
|
60
|
+
contextLength: window.store.config.contextLengthAI,
|
|
61
|
+
tools: agentTools
|
|
62
|
+
})
|
|
63
|
+
if (usage && usage.promptTokens) {
|
|
64
|
+
info.measuredTokens = usage.promptTokens
|
|
65
|
+
}
|
|
66
|
+
window.store.aiContextInfo = info
|
|
67
|
+
}
|
|
68
|
+
|
|
50
69
|
export async function runAgentLoop (chatEntry, config, abortRef, setIsStreaming, conversationMessages = null) {
|
|
51
70
|
window.store.agentRunning = true
|
|
52
71
|
try {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
...conversationMessages.filter(m => m.role !== 'system')
|
|
59
|
-
]
|
|
60
|
-
} else {
|
|
61
|
-
messages = [
|
|
62
|
-
{ role: 'system', content: buildAgentSystemPrompt(config) },
|
|
63
|
-
{
|
|
64
|
-
role: 'user',
|
|
65
|
-
content: chatEntry.promptWithAttachments || chatEntry.prompt
|
|
66
|
-
}
|
|
67
|
-
]
|
|
68
|
-
}
|
|
72
|
+
const messages = buildAgentMessages({
|
|
73
|
+
systemPrompt: buildAgentSystemPrompt(config),
|
|
74
|
+
conversationMessages,
|
|
75
|
+
prompt: chatEntry.promptWithAttachments || chatEntry.prompt
|
|
76
|
+
})
|
|
69
77
|
const toolCallsLog = []
|
|
70
78
|
let accumulatedContent = ''
|
|
79
|
+
let lastUsage = null
|
|
71
80
|
|
|
72
81
|
setIsStreaming(true)
|
|
73
82
|
updateChatEntry(chatEntry, {
|
|
@@ -84,7 +93,11 @@ export async function runAgentLoop (chatEntry, config, abortRef, setIsStreaming,
|
|
|
84
93
|
return
|
|
85
94
|
}
|
|
86
95
|
|
|
96
|
+
publishContext(messages, config, lastUsage)
|
|
87
97
|
const result = await callBackendAIchatWithTools(messages, config)
|
|
98
|
+
if (result.usage) {
|
|
99
|
+
lastUsage = result.usage
|
|
100
|
+
}
|
|
88
101
|
|
|
89
102
|
if (result.error) {
|
|
90
103
|
setIsStreaming(false)
|
|
@@ -104,6 +117,7 @@ export async function runAgentLoop (chatEntry, config, abortRef, setIsStreaming,
|
|
|
104
117
|
}
|
|
105
118
|
|
|
106
119
|
messages.push(assistantMessage)
|
|
120
|
+
publishContext(messages, config, lastUsage)
|
|
107
121
|
|
|
108
122
|
if (assistantMessage.content) {
|
|
109
123
|
accumulatedContent += (accumulatedContent ? '\n\n' : '') + assistantMessage.content
|
|
@@ -176,5 +190,8 @@ export async function runAgentLoop (chatEntry, config, abortRef, setIsStreaming,
|
|
|
176
190
|
})
|
|
177
191
|
} finally {
|
|
178
192
|
window.store.agentRunning = false
|
|
193
|
+
// hand the panel back to the session based estimate: tool results are not
|
|
194
|
+
// carried into the next turn, so the agent figure stops being meaningful
|
|
195
|
+
window.store.aiContextInfo = null
|
|
179
196
|
}
|
|
180
197
|
}
|
|
@@ -4,6 +4,7 @@ import AIStopIcon from './ai-stop-icon'
|
|
|
4
4
|
import AgentToolCallCard from './agent-tool-call-card'
|
|
5
5
|
import { runAgentLoop } from './agent'
|
|
6
6
|
import { appendMandatoryGuardrails } from './ai-guardrails'
|
|
7
|
+
import { buildSessionMessages } from './ai-context'
|
|
7
8
|
import {
|
|
8
9
|
Alert,
|
|
9
10
|
Tooltip,
|
|
@@ -49,54 +50,17 @@ export default function AIChatHistoryItem ({ item }) {
|
|
|
49
50
|
return appendMandatoryGuardrails(roleAI + `;用[${lang}]回复`)
|
|
50
51
|
}
|
|
51
52
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
if (sessionEntries[i].compressed) {
|
|
64
|
-
lastCompressIndex = i
|
|
65
|
-
break
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const messages = [
|
|
70
|
-
{ role: 'system', content: buildRole() }
|
|
71
|
-
]
|
|
72
|
-
|
|
73
|
-
// Start from the last compress entry (if any), skip older history
|
|
74
|
-
const startIndex = lastCompressIndex >= 0 ? lastCompressIndex : 0
|
|
75
|
-
for (let i = startIndex; i < sessionEntries.length; i++) {
|
|
76
|
-
const entry = sessionEntries[i]
|
|
77
|
-
if (entry.compressed) {
|
|
78
|
-
messages.push({
|
|
79
|
-
role: 'user',
|
|
80
|
-
content: `Here is a summary of our previous conversation for context:\n\n${entry.response}`
|
|
81
|
-
})
|
|
82
|
-
messages.push({
|
|
83
|
-
role: 'assistant',
|
|
84
|
-
content: 'Understood. I will use this context as we continue.'
|
|
85
|
-
})
|
|
86
|
-
} else {
|
|
87
|
-
messages.push({
|
|
88
|
-
role: 'user',
|
|
89
|
-
// promptWithAttachments carries the inlined file blocks;
|
|
90
|
-
// falls back to the plain prompt for legacy entries
|
|
91
|
-
content: entry.promptWithAttachments || entry.prompt
|
|
92
|
-
})
|
|
93
|
-
if (entry.response && entry.id !== item.id) {
|
|
94
|
-
messages.push({ role: 'assistant', content: entry.response })
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
return messages
|
|
99
|
-
}, [chatSessionId, item.id, item.timestamp])
|
|
53
|
+
// The context this turn sends: the session history up to and including the
|
|
54
|
+
// previous turns, but not this turn's own (still streaming) response.
|
|
55
|
+
// Shared with the context size indicator in ai-chat.jsx so the number shown
|
|
56
|
+
// is built from the same message list that goes out.
|
|
57
|
+
const conversationMessages = useMemo(() => buildSessionMessages({
|
|
58
|
+
history: window.store.aiChatHistory,
|
|
59
|
+
chatSessionId,
|
|
60
|
+
upto: item.timestamp,
|
|
61
|
+
role: buildRole(),
|
|
62
|
+
excludeId: item.id
|
|
63
|
+
}), [chatSessionId, item.id, item.timestamp])
|
|
100
64
|
|
|
101
65
|
const pollStreamContent = useCallback(async (sid) => {
|
|
102
66
|
try {
|
|
@@ -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='
|
|
263
|
-
<
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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={<
|
|
275
|
-
onClick={
|
|
276
|
-
|
|
277
|
-
className='mg1r'
|
|
339
|
+
icon={<HistoryOutlined />}
|
|
340
|
+
onClick={handleShowHistory}
|
|
341
|
+
type={props.showChatSessions ? 'primary' : 'default'}
|
|
278
342
|
>
|
|
279
|
-
{e('
|
|
343
|
+
{e('history')}
|
|
280
344
|
</Button>
|
|
281
|
-
|
|
282
|
-
<
|
|
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
|
+
}
|