create-openclaw-bot 5.15.2 → 5.16.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.
- package/README.md +2 -2
- package/README.vi.md +2 -2
- package/dist/server/local-server.js +728 -39
- package/dist/setup/shared/bot-config-gen.js +10 -2
- package/dist/setup/shared/common-gen.js +7 -1
- package/dist/setup/shared/docker-gen.js +16 -1
- package/dist/setup/shared/host-ui-ps1.js +179 -0
- package/dist/web/app.js +65 -7
- package/package.json +1 -1
|
@@ -277,11 +277,19 @@
|
|
|
277
277
|
}
|
|
278
278
|
|
|
279
279
|
// ── gateway ──────────────────────────────────────────────────────────────
|
|
280
|
+
// Docker MUST bind 0.0.0.0 inside the container to be reachable at all, but that is contained:
|
|
281
|
+
// compose publishes the port as `127.0.0.1:<port>:<port>` (docker-gen), so the host still only
|
|
282
|
+
// answers on loopback. A NATIVE gateway has no port mapping to hide behind — 0.0.0.0 there puts
|
|
283
|
+
// it straight on the VPS's public interface, with the auth token crossing the wire in plaintext
|
|
284
|
+
// (the gateway speaks plain HTTP/WS) and, on a fresh VPS, no firewall in front of it. The
|
|
285
|
+
// `osChoice === 'vps'` clause predates native mode; keep native on loopback and reach it through
|
|
286
|
+
// an SSH tunnel, which is what docker-on-a-VPS effectively does too.
|
|
287
|
+
const openGatewayBind = deployMode === 'docker' || (osChoice === 'vps' && deployMode !== 'native');
|
|
280
288
|
cfg.gateway = {
|
|
281
289
|
port: gatewayPort,
|
|
282
290
|
mode: 'local',
|
|
283
|
-
bind:
|
|
284
|
-
...(
|
|
291
|
+
bind: openGatewayBind ? 'custom' : 'loopback',
|
|
292
|
+
...(openGatewayBind ? { customBindHost: '0.0.0.0' } : {}),
|
|
285
293
|
controlUi: {
|
|
286
294
|
allowedOrigins: gatewayAllowedOrigins.length > 0
|
|
287
295
|
? gatewayAllowedOrigins
|
|
@@ -268,7 +268,13 @@ If setup reported a plugin install error, run this after the bot is running:
|
|
|
268
268
|
{
|
|
269
269
|
id: 'smart-route',
|
|
270
270
|
name: 'Smart Proxy (Auto Route)',
|
|
271
|
-
|
|
271
|
+
// smart-route fans out to whatever free upstreams the operator's combo holds, and the
|
|
272
|
+
// SMALLEST window in that pool is the real ceiling — many free models stop at 128k.
|
|
273
|
+
// Declaring 200k let sessions grow past what the route could actually accept: once
|
|
274
|
+
// full, even the compaction summarize call overflowed and every turn died with
|
|
275
|
+
// "auto-compaction could not recover" until /new. 131072 keeps compaction triggering
|
|
276
|
+
// (window - reserveTokens) while the summarize request still fits everywhere.
|
|
277
|
+
contextWindow: 131072,
|
|
272
278
|
maxTokens: 8192,
|
|
273
279
|
input: ['text', 'image'],
|
|
274
280
|
},
|
|
@@ -272,8 +272,12 @@ if(touched){console.log('[patch-9router] Applied Codex compatibility patch.');}e
|
|
|
272
272
|
// • imageMaxDimensionPx / imageQuality / contextLimits.toolResultMaxChars → keep one
|
|
273
273
|
// heavy turn (deep research, 4K chart read-back) from overflowing the context
|
|
274
274
|
// window mid tool-loop, which cannot be compacted and poisons the session.
|
|
275
|
+
// • smart-route contextWindow 200000 → 131072: the old declared window exceeded the
|
|
276
|
+
// smallest upstream in typical free-model combos, so full sessions deadlocked —
|
|
277
|
+
// the compaction summarize call itself overflowed and only /new recovered. Only the
|
|
278
|
+
// exact setup-written 200000 is rewritten; an operator's custom value is left alone.
|
|
275
279
|
// Each key is only filled in when absent, so an operator's own tuning is never clobbered.
|
|
276
|
-
const contextDefaultsScript = `const fs=require('fs'),path=require('path');const p=path.join(process.cwd(),'.openclaw','openclaw.json');if(fs.existsSync(p)){const c=JSON.parse(fs.readFileSync(p,'utf8'));let ch=false;c.skills=c.skills||{};c.skills.workshop=c.skills.workshop||{};if(!c.skills.workshop.approvalPolicy){c.skills.workshop.approvalPolicy='auto';ch=true;}if(c.browser&&c.browser.enabled!==false){c.tools=c.tools||{};const dn=Array.isArray(c.tools.deny)?c.tools.deny:[];if(!dn.includes('browser')){dn.push('browser');c.tools.deny=dn;ch=true;}}const d=(c.agents&&c.agents.defaults)?c.agents.defaults:null;if(d){if(d.imageMaxDimensionPx===undefined){d.imageMaxDimensionPx=1024;ch=true;}if(d.imageQuality===undefined){d.imageQuality='efficient';ch=true;}d.contextLimits=d.contextLimits||{};if(d.contextLimits.toolResultMaxChars===undefined){d.contextLimits.toolResultMaxChars=12000;ch=true;}}if(ch)fs.writeFileSync(p,JSON.stringify(c,null,2));}`;
|
|
280
|
+
const contextDefaultsScript = `const fs=require('fs'),path=require('path');const p=path.join(process.cwd(),'.openclaw','openclaw.json');if(fs.existsSync(p)){const c=JSON.parse(fs.readFileSync(p,'utf8'));let ch=false;c.skills=c.skills||{};c.skills.workshop=c.skills.workshop||{};if(!c.skills.workshop.approvalPolicy){c.skills.workshop.approvalPolicy='auto';ch=true;}if(c.browser&&c.browser.enabled!==false){c.tools=c.tools||{};const dn=Array.isArray(c.tools.deny)?c.tools.deny:[];if(!dn.includes('browser')){dn.push('browser');c.tools.deny=dn;ch=true;}}const d=(c.agents&&c.agents.defaults)?c.agents.defaults:null;if(d){if(d.imageMaxDimensionPx===undefined){d.imageMaxDimensionPx=1024;ch=true;}if(d.imageQuality===undefined){d.imageQuality='efficient';ch=true;}d.contextLimits=d.contextLimits||{};if(d.contextLimits.toolResultMaxChars===undefined){d.contextLimits.toolResultMaxChars=12000;ch=true;}}const pr=c.models&&c.models.providers&&c.models.providers['9router'];if(pr&&Array.isArray(pr.models)){for(const m of pr.models){if(m&&m.id==='smart-route'&&m.contextWindow===200000){m.contextWindow=131072;ch=true;}}}if(ch)fs.writeFileSync(p,JSON.stringify(c,null,2));}`;
|
|
277
281
|
// Companion backfill for the same older projects: their TOOLS.md was generated before the
|
|
278
282
|
// skill-authoring / long-turn guidance existed, and workspace files are only written when a
|
|
279
283
|
// bot is created — so a rebuild alone leaves the assistant stopping at "proposal awaiting
|
|
@@ -288,6 +292,17 @@ if(touched){console.log('[patch-9router] Applied Codex compatibility patch.');}e
|
|
|
288
292
|
'export OPENCLAW_HOME="${OPENCLAW_HOME:-$PWD/.openclaw}"',
|
|
289
293
|
'export OPENCLAW_STATE_DIR="${OPENCLAW_STATE_DIR:-$OPENCLAW_HOME}"',
|
|
290
294
|
'mkdir -p "$OPENCLAW_HOME" "$OPENCLAW_STATE_DIR"',
|
|
295
|
+
// `openclaw plugins install` unpacks into extensions/.openclaw-install-stage-XXXXXX and removes
|
|
296
|
+
// it when it finishes. An interrupted install leaves the staging copy behind — and it still
|
|
297
|
+
// carries a plugin manifest, so the gateway logs "duplicate plugin id detected" on every boot
|
|
298
|
+
// and a stale build competes with the real one for the same id. Found on a production host: a
|
|
299
|
+
// zalo-connect 3.0.7 stage dir shadowing 3.0.17 for a week. Nothing is installing at entrypoint
|
|
300
|
+
// time, so any stage dir here is by definition abandoned.
|
|
301
|
+
'for stage in "$OPENCLAW_HOME"/extensions/.openclaw-install-stage-*; do',
|
|
302
|
+
' [ -d "$stage" ] || continue',
|
|
303
|
+
' echo "[entrypoint] removing abandoned plugin staging dir $(basename "$stage")"',
|
|
304
|
+
' rm -rf "$stage"',
|
|
305
|
+
'done',
|
|
291
306
|
'if [ "$OPENCLAW_STATE_DIR" != "$OPENCLAW_HOME" ]; then',
|
|
292
307
|
' for path in "$OPENCLAW_HOME"/*; do',
|
|
293
308
|
' [ -e "$path" ] || continue',
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
// PowerShell backend for host UI automation on Windows.
|
|
3
|
+
//
|
|
4
|
+
// Windows has no xdotool and no bundled CLI for input, so the actions are done the way the
|
|
5
|
+
// no-dependency tools do it: P/Invoke into user32.dll for the pointer and keys (SetCursorPos /
|
|
6
|
+
// mouse_event / keybd_event), System.Windows.Forms.SendKeys for text, System.Drawing's
|
|
7
|
+
// CopyFromScreen for captures, and Get/Set-Clipboard for the clipboard. Shipping this as one
|
|
8
|
+
// version-stamped .ps1 keeps the quoting sane: the server passes typed parameters, never a
|
|
9
|
+
// composed command line.
|
|
10
|
+
(function (root) {
|
|
11
|
+
const HOST_UI_PS1_VERSION = '1';
|
|
12
|
+
|
|
13
|
+
const HOST_UI_PS1 = String.raw`# OpenClaw host UI helper — version __VERSION__
|
|
14
|
+
# Generated by openclaw-setup. Edits are overwritten when the version changes.
|
|
15
|
+
param(
|
|
16
|
+
[Parameter(Mandatory = $true)][string]$Action,
|
|
17
|
+
[int]$X = -1,
|
|
18
|
+
[int]$Y = -1,
|
|
19
|
+
[int]$ToX = -1,
|
|
20
|
+
[int]$ToY = -1,
|
|
21
|
+
[int]$Amount = 3,
|
|
22
|
+
[string]$Text = '',
|
|
23
|
+
[string]$Button = 'left',
|
|
24
|
+
[int]$Clicks = 1,
|
|
25
|
+
[string]$Path = '',
|
|
26
|
+
[string]$Title = ''
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
$ErrorActionPreference = 'Stop'
|
|
30
|
+
Add-Type -AssemblyName System.Windows.Forms, System.Drawing
|
|
31
|
+
|
|
32
|
+
if (-not ('OpenClawUi' -as [type])) {
|
|
33
|
+
Add-Type -Namespace '' -Name OpenClawUi -MemberDefinition @'
|
|
34
|
+
[DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
|
|
35
|
+
[DllImport("user32.dll")] public static extern void mouse_event(uint flags, uint dx, uint dy, uint data, int extra);
|
|
36
|
+
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(System.IntPtr hWnd);
|
|
37
|
+
[DllImport("user32.dll")] public static extern bool ShowWindow(System.IntPtr hWnd, int cmd);
|
|
38
|
+
'@
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
$MOUSEEVENTF = @{
|
|
42
|
+
leftdown = 0x0002; leftup = 0x0004;
|
|
43
|
+
rightdown = 0x0008; rightup = 0x0010;
|
|
44
|
+
middledown = 0x0020; middleup = 0x0040;
|
|
45
|
+
wheel = 0x0800; hwheel = 0x01000;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function Out-Json($obj) { $obj | ConvertTo-Json -Compress -Depth 6 }
|
|
49
|
+
|
|
50
|
+
function Move-Pointer([int]$px, [int]$py) {
|
|
51
|
+
if ($px -ge 0 -and $py -ge 0) { [OpenClawUi]::SetCursorPos($px, $py) | Out-Null; Start-Sleep -Milliseconds 40 }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function Invoke-Click([string]$btn, [int]$times) {
|
|
55
|
+
$down = $MOUSEEVENTF[($btn + 'down')]
|
|
56
|
+
$up = $MOUSEEVENTF[($btn + 'up')]
|
|
57
|
+
if (-not $down) { throw "unknown button: $btn" }
|
|
58
|
+
for ($i = 0; $i -lt [Math]::Max(1, $times); $i++) {
|
|
59
|
+
[OpenClawUi]::mouse_event($down, 0, 0, 0, 0)
|
|
60
|
+
[OpenClawUi]::mouse_event($up, 0, 0, 0, 0)
|
|
61
|
+
Start-Sleep -Milliseconds 60
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
# SendKeys needs its own escaping: + ^ % ~ ( ) { } [ ] are modifiers there, so literal text has
|
|
66
|
+
# to be wrapped in braces or the bot's message silently turns into shortcuts.
|
|
67
|
+
function ConvertTo-SendKeysLiteral([string]$s) {
|
|
68
|
+
$sb = New-Object System.Text.StringBuilder
|
|
69
|
+
foreach ($ch in $s.ToCharArray()) {
|
|
70
|
+
if ('+^%~(){}[]'.Contains($ch)) { [void]$sb.Append('{' + $ch + '}') }
|
|
71
|
+
elseif ($ch -eq [char]10) { [void]$sb.Append('{ENTER}') }
|
|
72
|
+
elseif ($ch -eq [char]9) { [void]$sb.Append('{TAB}') }
|
|
73
|
+
elseif ($ch -eq [char]13) { }
|
|
74
|
+
else { [void]$sb.Append($ch) }
|
|
75
|
+
}
|
|
76
|
+
$sb.ToString()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
# "ctrl+shift+t" / "enter" / "cmd+a" (cmd is mapped to ctrl on Windows) → SendKeys syntax.
|
|
80
|
+
function ConvertTo-SendKeysCombo([string]$combo) {
|
|
81
|
+
$named = @{
|
|
82
|
+
enter = '{ENTER}'; return = '{ENTER}'; tab = '{TAB}'; esc = '{ESC}'; escape = '{ESC}';
|
|
83
|
+
backspace = '{BACKSPACE}'; delete = '{DELETE}'; del = '{DELETE}'; home = '{HOME}'; end = '{END}';
|
|
84
|
+
pageup = '{PGUP}'; pagedown = '{PGDN}'; up = '{UP}'; down = '{DOWN}'; left = '{LEFT}'; right = '{RIGHT}';
|
|
85
|
+
space = ' '; f1 = '{F1}'; f2 = '{F2}'; f3 = '{F3}'; f4 = '{F4}'; f5 = '{F5}'; f6 = '{F6}';
|
|
86
|
+
f7 = '{F7}'; f8 = '{F8}'; f9 = '{F9}'; f10 = '{F10}'; f11 = '{F11}'; f12 = '{F12}';
|
|
87
|
+
printscreen = '{PRTSC}'; insert = '{INSERT}';
|
|
88
|
+
}
|
|
89
|
+
$prefix = ''
|
|
90
|
+
$parts = $combo.Split('+') | ForEach-Object { $_.Trim().ToLower() } | Where-Object { $_ }
|
|
91
|
+
$keyPart = ''
|
|
92
|
+
foreach ($p in $parts) {
|
|
93
|
+
switch ($p) {
|
|
94
|
+
'ctrl' { $prefix += '^' }
|
|
95
|
+
'control' { $prefix += '^' }
|
|
96
|
+
'cmd' { $prefix += '^' } # macOS habit; Windows equivalent is Ctrl
|
|
97
|
+
'meta' { $prefix += '^' }
|
|
98
|
+
'alt' { $prefix += '%' }
|
|
99
|
+
'shift' { $prefix += '+' }
|
|
100
|
+
'win' { $prefix += '' } # SendKeys cannot press Win; use the Windows key combos below
|
|
101
|
+
default { $keyPart = $p }
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (-not $keyPart) { throw "no key in combo: $combo" }
|
|
105
|
+
$mapped = if ($named.ContainsKey($keyPart)) { $named[$keyPart] } else { $keyPart }
|
|
106
|
+
$prefix + $mapped
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
switch ($Action) {
|
|
110
|
+
'screen_size' {
|
|
111
|
+
$b = [System.Windows.Forms.SystemInformation]::VirtualScreen
|
|
112
|
+
Out-Json @{ ok = $true; width = $b.Width; height = $b.Height; left = $b.Left; top = $b.Top }
|
|
113
|
+
}
|
|
114
|
+
'screenshot' {
|
|
115
|
+
if (-not $Path) { throw 'screenshot needs -Path' }
|
|
116
|
+
$b = [System.Windows.Forms.SystemInformation]::VirtualScreen
|
|
117
|
+
$bmp = New-Object System.Drawing.Bitmap $b.Width, $b.Height
|
|
118
|
+
$g = [System.Drawing.Graphics]::FromImage($bmp)
|
|
119
|
+
$g.CopyFromScreen($b.Left, $b.Top, 0, 0, $bmp.Size)
|
|
120
|
+
$dir = Split-Path -Parent $Path
|
|
121
|
+
if ($dir -and -not (Test-Path $dir)) { New-Item -ItemType Directory -Force -Path $dir | Out-Null }
|
|
122
|
+
$bmp.Save($Path, [System.Drawing.Imaging.ImageFormat]::Png)
|
|
123
|
+
$g.Dispose(); $bmp.Dispose()
|
|
124
|
+
Out-Json @{ ok = $true; path = $Path; width = $b.Width; height = $b.Height }
|
|
125
|
+
}
|
|
126
|
+
'mouse_move' { Move-Pointer $X $Y; Out-Json @{ ok = $true; x = $X; y = $Y } }
|
|
127
|
+
'click' { Move-Pointer $X $Y; Invoke-Click $Button $Clicks; Out-Json @{ ok = $true; button = $Button; clicks = $Clicks } }
|
|
128
|
+
'drag' {
|
|
129
|
+
Move-Pointer $X $Y
|
|
130
|
+
[OpenClawUi]::mouse_event($MOUSEEVENTF['leftdown'], 0, 0, 0, 0)
|
|
131
|
+
Start-Sleep -Milliseconds 80
|
|
132
|
+
Move-Pointer $ToX $ToY
|
|
133
|
+
[OpenClawUi]::mouse_event($MOUSEEVENTF['leftup'], 0, 0, 0, 0)
|
|
134
|
+
Out-Json @{ ok = $true; from = @($X, $Y); to = @($ToX, $ToY) }
|
|
135
|
+
}
|
|
136
|
+
'scroll' {
|
|
137
|
+
Move-Pointer $X $Y
|
|
138
|
+
$ticks = [Math]::Max(1, [Math]::Abs($Amount))
|
|
139
|
+
$delta = if ($Amount -lt 0) { -120 } else { 120 }
|
|
140
|
+
for ($i = 0; $i -lt $ticks; $i++) {
|
|
141
|
+
[OpenClawUi]::mouse_event($MOUSEEVENTF['wheel'], 0, 0, [uint32]([int]$delta -band 0xFFFFFFFF), 0)
|
|
142
|
+
Start-Sleep -Milliseconds 40
|
|
143
|
+
}
|
|
144
|
+
Out-Json @{ ok = $true; amount = $Amount }
|
|
145
|
+
}
|
|
146
|
+
'type' {
|
|
147
|
+
[System.Windows.Forms.SendKeys]::SendWait((ConvertTo-SendKeysLiteral $Text))
|
|
148
|
+
Out-Json @{ ok = $true; typed = $Text.Length }
|
|
149
|
+
}
|
|
150
|
+
'key' {
|
|
151
|
+
foreach ($combo in ($Text -split '\s+' | Where-Object { $_ })) {
|
|
152
|
+
[System.Windows.Forms.SendKeys]::SendWait((ConvertTo-SendKeysCombo $combo))
|
|
153
|
+
Start-Sleep -Milliseconds 60
|
|
154
|
+
}
|
|
155
|
+
Out-Json @{ ok = $true; keys = $Text }
|
|
156
|
+
}
|
|
157
|
+
'clipboard_get' { Out-Json @{ ok = $true; text = (Get-Clipboard -Raw) } }
|
|
158
|
+
'clipboard_set' { Set-Clipboard -Value $Text; Out-Json @{ ok = $true; length = $Text.Length } }
|
|
159
|
+
'windows' {
|
|
160
|
+
$list = Get-Process | Where-Object { $_.MainWindowTitle } |
|
|
161
|
+
Select-Object -First 40 @{n = 'title'; e = { $_.MainWindowTitle } }, @{n = 'process'; e = { $_.ProcessName } }, Id
|
|
162
|
+
Out-Json @{ ok = $true; windows = @($list) }
|
|
163
|
+
}
|
|
164
|
+
'focus' {
|
|
165
|
+
if (-not $Title) { throw 'focus needs -Title' }
|
|
166
|
+
$proc = Get-Process | Where-Object { $_.MainWindowTitle -like "*$Title*" } | Select-Object -First 1
|
|
167
|
+
if (-not $proc) { Out-Json @{ ok = $false; error = "no window matching: $Title" }; break }
|
|
168
|
+
[OpenClawUi]::ShowWindow($proc.MainWindowHandle, 9) | Out-Null # SW_RESTORE
|
|
169
|
+
[OpenClawUi]::SetForegroundWindow($proc.MainWindowHandle) | Out-Null
|
|
170
|
+
Out-Json @{ ok = $true; focused = $proc.MainWindowTitle }
|
|
171
|
+
}
|
|
172
|
+
default { Out-Json @{ ok = $false; error = "unknown action: $Action" } }
|
|
173
|
+
}
|
|
174
|
+
`.replace('__VERSION__', HOST_UI_PS1_VERSION);
|
|
175
|
+
|
|
176
|
+
const api = { HOST_UI_PS1, HOST_UI_PS1_VERSION };
|
|
177
|
+
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
178
|
+
root.__openclawHostUiPs1 = api;
|
|
179
|
+
})(typeof globalThis !== 'undefined' ? globalThis : this);
|
package/dist/web/app.js
CHANGED
|
@@ -413,7 +413,11 @@ function remoteAccessPanel(s = {}) {
|
|
|
413
413
|
const host = r.host || '<your-server-ip>';
|
|
414
414
|
const user = r.user || 'root';
|
|
415
415
|
const portOf = (raw, def) => { try { return new URL(raw).port || def; } catch { return def; } };
|
|
416
|
-
|
|
416
|
+
// zalo-mod's dashboard is gateway port + 1, not a fixed 18790 — the same rule the plugin card's
|
|
417
|
+
// "Open" button uses. Hardcoding it meant any project whose gateway is not on 18789 got a tunnel
|
|
418
|
+
// command missing the dashboard port, and the dashboard then simply failed to load with no clue why.
|
|
419
|
+
const gwPort = Number(portOf(s.gatewayUrl, 18789));
|
|
420
|
+
const ports = Array.from(new Set([r.uiPort || 51789, gwPort, Number(portOf(s.routerUrl, 20128)), gwPort + 1]));
|
|
417
421
|
const cmd = `ssh ${ports.map((p) => `-L ${p}:127.0.0.1:${p}`).join(' ')} ${user}@${host}`;
|
|
418
422
|
return `<details class="card" style="margin-top:12px;" ${r.headless ? 'open' : ''}>
|
|
419
423
|
<summary style="cursor:pointer; font-weight:600;">🌐 ${t('Mở từ máy khác (VPS/server)', 'Open from another machine (VPS/server)')}</summary>
|
|
@@ -1011,11 +1015,7 @@ function botListPanel(bots) {
|
|
|
1011
1015
|
const listItems = bots.map(b => {
|
|
1012
1016
|
const role = (b.role || b.desc || b.description || '').trim() || t('Tr\u1ee3 l\u00fd OpenClaw','OpenClaw assistant');
|
|
1013
1017
|
const isZalo = b.channel === 'zalo-personal';
|
|
1014
|
-
const
|
|
1015
|
-
const connection = !health ? t('Chưa rõ','Unknown') : health.running ? t('Đã kết nối','Connected') : health.lastError ? t('Mất kết nối','Disconnected') : t('Đang kết nối','Connecting');
|
|
1016
|
-
const login = !health ? t('Chưa rõ','Unknown') : health.sessionSaved ? t('Đã đăng nhập','Logged in') : t('Chưa đăng nhập','Not logged in');
|
|
1017
|
-
const connectionTone = health?.running ? 'ok' : health?.lastError ? 'bad' : 'warn';
|
|
1018
|
-
const loginTone = health?.sessionSaved ? 'ok' : health ? 'bad' : 'warn';
|
|
1018
|
+
const { connection, login, connectionTone, loginTone } = zaloHealthBadges(b);
|
|
1019
1019
|
return `<article class="bot-item ${state.activeBotId===b.id?'active':''}" data-bot-id="${escapeHtml(b.id)}"><div class="bot-item-actions"><button class="bot-edit" data-edit-bot="${escapeHtml(b.id)}" title="${t('Sửa bot','Edit bot')}" aria-label="${t('Sửa bot','Edit bot')}">${actionIcon('edit')}</button><button class="bot-delete" data-delete-bot="${escapeHtml(b.id)}" title="${t('X\u00f3a bot','Delete bot')}" aria-label="${t('X\u00f3a bot','Delete bot')}">×</button></div><div class="bot-item-title"><b>${escapeHtml(b.name)}</b></div><small title="${escapeHtml(role)}">${escapeHtml(role)}</small>${isZalo ? `<div class="zalo-bot-health"><div><span>${t('Kết nối','Connection')}</span><em class="${connectionTone}">${connection}</em></div><div><span>${t('Đăng nhập','Login')}</span><em class="${loginTone}">${login}</em></div></div>` : ''}</article>`;
|
|
1020
1020
|
});
|
|
1021
1021
|
listItems.push(`
|
|
@@ -1623,7 +1623,10 @@ document.querySelectorAll('[data-project-pick-folder]').forEach(btn => btn.oncli
|
|
|
1623
1623
|
body.projectDir = activeProjectDir();
|
|
1624
1624
|
body.channel = state.botChannel || 'telegram';
|
|
1625
1625
|
body.userTimezone = state.tz;
|
|
1626
|
-
|
|
1626
|
+
// Only the CREATE flow starts a Zalo QR login (the server kicks it off and sets
|
|
1627
|
+
// loginStarted). Editing a bot (rename, persona...) must never pop the QR modal —
|
|
1628
|
+
// the session is already saved and the PUT endpoint starts no login.
|
|
1629
|
+
if (body.channel === 'zalo-personal' && !state.botEditId) {
|
|
1627
1630
|
state.botModalOpen = false;
|
|
1628
1631
|
state.zaloLoginOpen = true;
|
|
1629
1632
|
state.zaloQrDataUrl = '';
|
|
@@ -1718,6 +1721,24 @@ function zaloAccountHealth(bot) {
|
|
|
1718
1721
|
|| accounts.find((account) => account.accountId === (bot?.accountId || 'default'))
|
|
1719
1722
|
|| null;
|
|
1720
1723
|
}
|
|
1724
|
+
// Connection/login labels for one bot. lastError wins over running: the gateway keeps
|
|
1725
|
+
// running=true while the Zalo listener is stuck in a retry loop (e.g. "Đăng nhập thất
|
|
1726
|
+
// bại"), so a running-first check painted dead bots green.
|
|
1727
|
+
function zaloHealthBadges(bot) {
|
|
1728
|
+
const health = bot?.channel === 'zalo-personal' ? zaloAccountHealth(bot) : null;
|
|
1729
|
+
const loginFailed = !!health?.lastError && /đăng nhập|log ?in|auth|credential|session/i.test(String(health.lastError));
|
|
1730
|
+
const connection = !health ? t('Chưa rõ','Unknown')
|
|
1731
|
+
: health.lastError ? t('Mất kết nối','Disconnected')
|
|
1732
|
+
: health.running ? t('Đã kết nối','Connected')
|
|
1733
|
+
: t('Đang kết nối','Connecting');
|
|
1734
|
+
const login = !health ? t('Chưa rõ','Unknown')
|
|
1735
|
+
: loginFailed ? t('Phiên hết hạn','Session expired')
|
|
1736
|
+
: health.sessionSaved ? t('Đã đăng nhập','Logged in')
|
|
1737
|
+
: t('Chưa đăng nhập','Not logged in');
|
|
1738
|
+
const connectionTone = !health ? 'warn' : health.lastError ? 'bad' : health.running ? 'ok' : 'warn';
|
|
1739
|
+
const loginTone = !health ? 'warn' : loginFailed ? 'bad' : health.sessionSaved ? 'ok' : 'bad';
|
|
1740
|
+
return { health, connection, login, connectionTone, loginTone };
|
|
1741
|
+
}
|
|
1721
1742
|
function zaloToolbar(channelBots = []) {
|
|
1722
1743
|
const active = channelBots.find((bot) => bot.id === state.activeBotId) || channelBots[0];
|
|
1723
1744
|
const health = zaloAccountHealth(active);
|
|
@@ -1729,6 +1750,42 @@ async function loadZaloHealth(silent=false){
|
|
|
1729
1750
|
try { state.zaloHealth = await api('/api/zalo/health' + projectQuery()); } catch (_) { state.zaloHealth = null; }
|
|
1730
1751
|
if (!silent) render();
|
|
1731
1752
|
}
|
|
1753
|
+
// Patch the connection/login badges of the rendered bot cards in place. render() rebuilds
|
|
1754
|
+
// the whole panel (killing focus/scroll), so the auto-refresh below must never call it.
|
|
1755
|
+
function updateZaloHealthDom() {
|
|
1756
|
+
const bots = state.install?.bots || [];
|
|
1757
|
+
document.querySelectorAll('.bot-item[data-bot-id] .zalo-bot-health').forEach((wrap) => {
|
|
1758
|
+
const id = wrap.closest('[data-bot-id]')?.dataset.botId;
|
|
1759
|
+
const bot = bots.find((b) => b.id === id);
|
|
1760
|
+
if (!bot) return;
|
|
1761
|
+
const badges = zaloHealthBadges(bot);
|
|
1762
|
+
const ems = wrap.querySelectorAll('em');
|
|
1763
|
+
if (ems[0]) { ems[0].className = badges.connectionTone; ems[0].textContent = badges.connection; }
|
|
1764
|
+
if (ems[1]) { ems[1].className = badges.loginTone; ems[1].textContent = badges.login; }
|
|
1765
|
+
});
|
|
1766
|
+
}
|
|
1767
|
+
// Live status: poll the health endpoint while Zalo bots are on screen (the server caches
|
|
1768
|
+
// the ~3s CLI probe with a short TTL, so this stays cheap) and patch the badges in place —
|
|
1769
|
+
// no more pressing "Làm mới" to see a bot drop or come back.
|
|
1770
|
+
const ZALO_HEALTH_POLL_MS = 10000;
|
|
1771
|
+
let zaloHealthPollBusy = false;
|
|
1772
|
+
// Debounced instant refresh for log-visible state changes (login saved, container
|
|
1773
|
+
// restarted...) so the badges flip within ~2s instead of waiting out a poll tick.
|
|
1774
|
+
let zaloHealthRefreshTimer = null;
|
|
1775
|
+
function scheduleZaloHealthRefresh() {
|
|
1776
|
+
if (zaloHealthRefreshTimer) return;
|
|
1777
|
+
zaloHealthRefreshTimer = setTimeout(async () => {
|
|
1778
|
+
zaloHealthRefreshTimer = null;
|
|
1779
|
+
try { await loadZaloHealth(true); updateZaloHealthDom(); } catch (_) {}
|
|
1780
|
+
}, 1500);
|
|
1781
|
+
}
|
|
1782
|
+
setInterval(async () => {
|
|
1783
|
+
if (document.hidden || zaloHealthPollBusy) return;
|
|
1784
|
+
if (!activeProjectDir()) return;
|
|
1785
|
+
if (!document.querySelector('.bot-item[data-bot-id] .zalo-bot-health')) return;
|
|
1786
|
+
zaloHealthPollBusy = true;
|
|
1787
|
+
try { await loadZaloHealth(true); updateZaloHealthDom(); } finally { zaloHealthPollBusy = false; }
|
|
1788
|
+
}, ZALO_HEALTH_POLL_MS);
|
|
1732
1789
|
async function loadFeatureFlags(silent=false){
|
|
1733
1790
|
const botId=currentBotId();
|
|
1734
1791
|
if (!activeProjectDir()) { state.featureFlags = {}; state.featureInstalled = {}; state.featureVersions = {}; if (!silent) render(); return; }
|
|
@@ -1784,6 +1841,7 @@ function appendLogLine(line) {
|
|
|
1784
1841
|
});
|
|
1785
1842
|
return;
|
|
1786
1843
|
}
|
|
1844
|
+
if (/\[zalo-connect\].*(login saved|listener (connected|start failed)|restarted|login failed|logged out)/i.test(line)) scheduleZaloHealthRefresh();
|
|
1787
1845
|
const html = `<p>${escapeHtml(line)}</p>`;
|
|
1788
1846
|
if (state.zaloLoginOpen || /\[zalo-connect\]|zalo|qr|login|scan/i.test(line)) {
|
|
1789
1847
|
state.zaloLoginLines.push(cleanTerminalLine(line));
|