create-openclaw-bot 5.9.0 → 5.10.1
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 -2
- package/README.vi.md +14 -2
- package/dist/server/local-server.js +255 -10
- package/dist/setup/shared/bot-config-gen.js +15 -9
- package/dist/setup/shared/docker-gen.js +80 -83
- package/dist/web/app.js +24 -16
- package/dist/web/styles.css +101 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*Run one command → open the dashboard → your bot is live. Windows · macOS · Linux · VPS, on Docker or native.*
|
|
8
8
|
|
|
9
9
|
<p align="center">
|
|
10
|
-
<a href="https://github.com/tuanminhhole/openclaw-setup/releases"><img src="https://img.shields.io/badge/RELEASE-v5.
|
|
10
|
+
<a href="https://github.com/tuanminhhole/openclaw-setup/releases"><img src="https://img.shields.io/badge/RELEASE-v5.10.1-0EA5E9?style=for-the-badge" alt="Version 5.10.1" /></a>
|
|
11
11
|
<a href="https://github.com/tuanminhhole/openclaw-setup?tab=MIT-1-ov-file"><img src="https://img.shields.io/badge/LICENSE-MIT-success?style=for-the-badge" alt="MIT License" /></a>
|
|
12
12
|
<a href="https://www.npmjs.com/package/create-openclaw-bot"><img src="https://img.shields.io/npm/v/create-openclaw-bot?style=for-the-badge&label=CLI&color=2563EB&logo=npm&logoColor=white" alt="NPM Version" /></a>
|
|
13
13
|
<a href="https://github.com/tuanminhhole/openclaw-setup/stargazers"><img src="https://img.shields.io/github/stars/tuanminhhole/openclaw-setup?style=for-the-badge&color=eab308&logo=github&logoColor=white" alt="GitHub Stars" /></a>
|
|
@@ -27,7 +27,17 @@
|
|
|
27
27
|
|
|
28
28
|
---
|
|
29
29
|
|
|
30
|
-
## 🆕 What's New in v5.
|
|
30
|
+
## 🆕 What's New in v5.10.1
|
|
31
|
+
|
|
32
|
+
- 🔧 **Docker fixes**: plugin updates keep the zalo-mod dashboard port, and granted disk/folder mounts (including whole Windows drives like `D:\`) now survive a rebuild instead of being dropped.
|
|
33
|
+
- 🔄 **Native auto-restart (process supervision)**: native installs now register the gateway + 9router as OS services (macOS launchd, Linux systemd, Windows detached) that restart on crash/reboot — mirroring Docker's `restart: always`. Best-effort, falls back to a plain detached process.
|
|
34
|
+
- 🔐 **9router first-install sync — fixed**: no longer permanently disables "Require login". The generated `sync.js` logs in with 9router's default password `123456`, builds the `smart-route` combo from active providers **once**, then stops. Require login stays ON (change the password later); `/v1` model calls are unaffected.
|
|
35
|
+
- 📁 **Native workspace path — fixed**: agent `workspace` is now relative, so persona/memory/skills resolve correctly on native installs (previously a container-absolute path pointing nowhere on the host).
|
|
36
|
+
- 🧩 **Config `meta` no longer clobbered**: the generator stops seeding `meta`, so OpenClaw owns it — prevents config parse failures from a version-range `lastTouchedVersion`.
|
|
37
|
+
- 💅 **Dashboard UI polish**: Bot/Setup hero shortcuts, plugin version badges, cleaner feature-toggle layout, responsive/mobile fixes, and no more duplicate page title on the dashboard tab.
|
|
38
|
+
|
|
39
|
+
<details>
|
|
40
|
+
<summary><b>Previous: What's new in v5.9.0</b></summary>
|
|
31
41
|
|
|
32
42
|
- 🧠 **TencentDB Agent Memory — 1-click install**: New memory plugin in the UI. A 4-tier (L0–L3) layered-memory + context-compression engine that keeps long sessions coherent and **cuts token usage by up to ~61%**. Fully local (SQLite), no API key, Docker-ready.
|
|
33
43
|
- ⚡ **Token-lean defaults for every new bot**: Ships with context pruning (cache-TTL) + `safeguard` compaction — **cheaper & sharper long conversations** with zero tuning.
|
|
@@ -36,6 +46,8 @@
|
|
|
36
46
|
- 🐳 **One-click Docker controls**: Restart / Rebuild the bot container and grant it disk access (mount any host folder at `/mnt/<name>`, cross-OS) right from the dashboard — restart your bot without touching the command line.
|
|
37
47
|
- ⚡ **Faster Dashboard & Bot pages**: Runtime/version detection now probes **once** and is cached instead of re-running on every page load — bot status dropped from ~4s to ~3ms on repeat loads. Cache auto-invalidates on update/rebuild/restart/install.
|
|
38
48
|
|
|
49
|
+
</details>
|
|
50
|
+
|
|
39
51
|
<details>
|
|
40
52
|
<summary><b>Previous: What's new in v5.8.24 (Launcher Auto-Update)</b></summary>
|
|
41
53
|
|
package/README.vi.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*Chạy một lệnh → mở dashboard → bot lên sóng. Windows · macOS · Linux · VPS, chạy Docker hoặc native.*
|
|
8
8
|
|
|
9
9
|
<p align="center">
|
|
10
|
-
<a href="https://github.com/tuanminhhole/openclaw-setup/releases"><img src="https://img.shields.io/badge/RELEASE-v5.
|
|
10
|
+
<a href="https://github.com/tuanminhhole/openclaw-setup/releases"><img src="https://img.shields.io/badge/RELEASE-v5.10.1-0EA5E9?style=for-the-badge" alt="Version 5.10.1" /></a>
|
|
11
11
|
<a href="https://github.com/tuanminhhole/openclaw-setup?tab=MIT-1-ov-file"><img src="https://img.shields.io/badge/LICENSE-MIT-success?style=for-the-badge" alt="MIT License" /></a>
|
|
12
12
|
<a href="https://www.npmjs.com/package/create-openclaw-bot"><img src="https://img.shields.io/npm/v/create-openclaw-bot?style=for-the-badge&label=CLI&color=2563EB&logo=npm&logoColor=white" alt="NPM Version" /></a>
|
|
13
13
|
<a href="https://github.com/tuanminhhole/openclaw-setup/stargazers"><img src="https://img.shields.io/github/stars/tuanminhhole/openclaw-setup?style=for-the-badge&color=eab308&logo=github&logoColor=white" alt="GitHub Stars" /></a>
|
|
@@ -27,7 +27,17 @@
|
|
|
27
27
|
|
|
28
28
|
---
|
|
29
29
|
|
|
30
|
-
## 🆕 Có gì mới trong v5.
|
|
30
|
+
## 🆕 Có gì mới trong v5.10.1
|
|
31
|
+
|
|
32
|
+
- 🔧 **Sửa lỗi Docker**: update plugin giữ nguyên port dashboard zalo-mod, và ổ đĩa/thư mục đã cấp quyền (kể cả nguyên ổ Windows như `D:\`) không còn bị mất sau khi rebuild.
|
|
33
|
+
- 🔄 **Tự khởi động lại tiến trình (native)**: cài native giờ đăng ký gateway + 9router thành service hệ điều hành (macOS launchd, Linux systemd, Windows detached), tự chạy lại khi crash/reboot — như `restart: always` của Docker. Best-effort, lỗi thì fallback chạy detached.
|
|
34
|
+
- 🔐 **Sync 9router lần đầu — đã sửa**: không còn tắt vĩnh viễn "Require login". `sync.js` đăng nhập bằng mật khẩu mặc định `123456`, tạo combo `smart-route` từ provider đang active **một lần** rồi dừng. Require login giữ ON (đổi mật khẩu sau); model-call `/v1` không bị ảnh hưởng.
|
|
35
|
+
- 📁 **Đường dẫn workspace native — đã sửa**: `workspace` của agent giờ là tương đối, persona/memory/skills nằm đúng chỗ khi cài native (trước là đường dẫn tuyệt đối kiểu container → trỏ sai trên host).
|
|
36
|
+
- 🧩 **Không ghi đè khối `meta` trong config**: generator ngừng seed `meta` để OpenClaw tự quản — tránh lỗi parse config do `lastTouchedVersion` là dải version.
|
|
37
|
+
- 💅 **UI dashboard gọn hơn**: nút tắt Bot/Cài đặt ở hero, badge version plugin, layout toggle tính năng, responsive/mobile, bỏ tiêu đề trùng ở tab dashboard.
|
|
38
|
+
|
|
39
|
+
<details>
|
|
40
|
+
<summary><b>Trước đó: Có gì mới trong v5.9.0</b></summary>
|
|
31
41
|
|
|
32
42
|
- 🧠 **TencentDB Agent Memory — cài 1 chạm**: Plugin bộ nhớ mới ngay trên UI. Bộ nhớ phân tầng 4 lớp (L0–L3) + nén ngữ cảnh, giữ session dài mạch lạc và **tiết kiệm tới ~61% token**. Chạy local hoàn toàn (SQLite), không cần API key, sẵn sàng cho Docker.
|
|
33
43
|
- ⚡ **Cấu hình tiết kiệm token mặc định cho mọi bot mới**: Tự có context pruning (cache-TTL) + compaction `safeguard` — **hội thoại dài rẻ hơn & sắc nét hơn**, không cần chỉnh tay.
|
|
@@ -36,6 +46,8 @@
|
|
|
36
46
|
- 🐳 **Nút điều khiển Docker 1 chạm**: Restart / Rebuild container bot và cấp quyền ổ đĩa (mount thư mục host bất kỳ vào `/mnt/<tên>`, đa OS) ngay trên dashboard — khởi động lại bot mà không cần gõ lệnh.
|
|
37
47
|
- ⚡ **Trang Dashboard & Bot load nhanh hơn**: Việc dò runtime/version giờ chỉ chạy **một lần** rồi cache thay vì lặp lại mỗi lần tải trang — bot status giảm từ ~4s xuống ~3ms ở các lần tải sau. Cache tự xoá khi update/rebuild/restart/cài đặt.
|
|
38
48
|
|
|
49
|
+
</details>
|
|
50
|
+
|
|
39
51
|
<details>
|
|
40
52
|
<summary><b>Trước đó: Có gì mới trong v5.8.24 (Tự động cập nhật Launcher)</b></summary>
|
|
41
53
|
|
|
@@ -605,6 +605,136 @@ Timed out after ${opts.timeout}ms`.trim() : stderr });
|
|
|
605
605
|
});
|
|
606
606
|
}
|
|
607
607
|
|
|
608
|
+
// ── Native process supervision (auto-restart) ───────────────────────────────
|
|
609
|
+
// Docker containers get `restart: always` for free. For native installs we register the gateway
|
|
610
|
+
// and 9router as OS services so they survive crashes and reboots, mirroring that guarantee:
|
|
611
|
+
// • macOS → per-user launchd LaunchAgent (KeepAlive + RunAtLoad)
|
|
612
|
+
// • Linux → systemd unit (system unit when root, else --user) with Restart=always
|
|
613
|
+
// • Windows / anything else → plain detached process (no supervision; unchanged behaviour)
|
|
614
|
+
// Every step is best-effort: any failure falls back to startDetached, so a native install can
|
|
615
|
+
// never end up worse off than before this feature existed.
|
|
616
|
+
function escapeXml(s) { return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
|
617
|
+
function nativeServiceId(projectDir) { return slugify(basename(projectDir || 'openclaw')) || 'bot'; }
|
|
618
|
+
function nativeServiceLabels(projectDir) {
|
|
619
|
+
const id = nativeServiceId(projectDir);
|
|
620
|
+
return {
|
|
621
|
+
'9router': { launchd: `com.openclaw.9router.${id}`, systemd: `openclaw-9router-${id}.service` },
|
|
622
|
+
sync: { launchd: `com.openclaw.sync.${id}`, systemd: `openclaw-sync-${id}.service` },
|
|
623
|
+
gateway: { launchd: `com.openclaw.gateway.${id}`, systemd: `openclaw-gateway-${id}.service` },
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
async function resolveAbsoluteBin(cmd) {
|
|
627
|
+
try {
|
|
628
|
+
const out = await runCapture(process.platform === 'win32' ? 'where' : 'which', [cmd], { shell: false, timeout: 5000 });
|
|
629
|
+
const first = String(out.stdout || '').split(/\r?\n/).map((s) => s.trim()).find(Boolean);
|
|
630
|
+
if (out.code === 0 && first) return first;
|
|
631
|
+
} catch {}
|
|
632
|
+
return resolveBinPath(cmd);
|
|
633
|
+
}
|
|
634
|
+
function systemdUserUnitDir() { return join(getRealHomedir(), '.config', 'systemd', 'user'); }
|
|
635
|
+
function isRootUser() { return typeof process.getuid === 'function' && process.getuid() === 0; }
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Start one native process under OS supervision (auto-restart). Falls back to a plain detached
|
|
639
|
+
* process on Windows/unknown platforms or if the service tooling errors. Returns the method used:
|
|
640
|
+
* 'launchd' | 'systemd' | 'detached'.
|
|
641
|
+
*/
|
|
642
|
+
async function startNativeService({ projectDir, name, cmd, args, desc, env }) {
|
|
643
|
+
const labels = nativeServiceLabels(projectDir)[name];
|
|
644
|
+
const logDir = join(projectDir, '.openclaw', 'logs');
|
|
645
|
+
await fsp.mkdir(logDir, { recursive: true }).catch(() => {});
|
|
646
|
+
const serviceEnv = { PATH: process.env.PATH || '', HOME: getRealHomedir(), ...env };
|
|
647
|
+
try {
|
|
648
|
+
const absBin = await resolveAbsoluteBin(cmd);
|
|
649
|
+
if (process.platform === 'darwin') {
|
|
650
|
+
const agentsDir = join(getRealHomedir(), 'Library', 'LaunchAgents');
|
|
651
|
+
await fsp.mkdir(agentsDir, { recursive: true });
|
|
652
|
+
const progArgs = [absBin, ...args].map((a) => ` <string>${escapeXml(a)}</string>`).join('\n');
|
|
653
|
+
const envDict = Object.entries(serviceEnv).map(([k, v]) => ` <key>${escapeXml(k)}</key><string>${escapeXml(String(v))}</string>`).join('\n');
|
|
654
|
+
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
655
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
656
|
+
<plist version="1.0"><dict>
|
|
657
|
+
<key>Label</key><string>${labels.launchd}</string>
|
|
658
|
+
<key>ProgramArguments</key><array>
|
|
659
|
+
${progArgs}
|
|
660
|
+
</array>
|
|
661
|
+
<key>WorkingDirectory</key><string>${escapeXml(projectDir)}</string>
|
|
662
|
+
<key>EnvironmentVariables</key><dict>
|
|
663
|
+
${envDict}
|
|
664
|
+
</dict>
|
|
665
|
+
<key>RunAtLoad</key><true/>
|
|
666
|
+
<key>KeepAlive</key><true/>
|
|
667
|
+
<key>StandardOutPath</key><string>${escapeXml(join(logDir, name + '.out.log'))}</string>
|
|
668
|
+
<key>StandardErrorPath</key><string>${escapeXml(join(logDir, name + '.err.log'))}</string>
|
|
669
|
+
</dict></plist>`;
|
|
670
|
+
const plistPath = join(agentsDir, `${labels.launchd}.plist`);
|
|
671
|
+
await fsp.writeFile(plistPath, plist, 'utf8');
|
|
672
|
+
await run('launchctl', ['unload', plistPath], {}).catch(() => {});
|
|
673
|
+
await run('launchctl', ['load', '-w', plistPath], {});
|
|
674
|
+
sendLog(`[native] launchd service ${labels.launchd} loaded (auto-restart)`);
|
|
675
|
+
return 'launchd';
|
|
676
|
+
}
|
|
677
|
+
if (process.platform === 'linux') {
|
|
678
|
+
const root = isRootUser();
|
|
679
|
+
const unitDir = root ? '/etc/systemd/system' : systemdUserUnitDir();
|
|
680
|
+
await fsp.mkdir(unitDir, { recursive: true });
|
|
681
|
+
const sc = (a) => run('systemctl', root ? a : ['--user', ...a], {});
|
|
682
|
+
const envLines = Object.entries(serviceEnv).map(([k, v]) => `Environment="${k}=${String(v).replace(/\n/g, ' ')}"`).join('\n');
|
|
683
|
+
const execStart = [absBin, ...args].map((a) => (/[\s"']/.test(a) ? JSON.stringify(a) : a)).join(' ');
|
|
684
|
+
const unit = `[Unit]
|
|
685
|
+
Description=${desc}
|
|
686
|
+
After=network-online.target
|
|
687
|
+
Wants=network-online.target
|
|
688
|
+
|
|
689
|
+
[Service]
|
|
690
|
+
Type=simple
|
|
691
|
+
WorkingDirectory=${projectDir}
|
|
692
|
+
${envLines}
|
|
693
|
+
ExecStart=${execStart}
|
|
694
|
+
Restart=always
|
|
695
|
+
RestartSec=3
|
|
696
|
+
|
|
697
|
+
[Install]
|
|
698
|
+
WantedBy=${root ? 'multi-user.target' : 'default.target'}
|
|
699
|
+
`;
|
|
700
|
+
await fsp.writeFile(join(unitDir, labels.systemd), unit, 'utf8');
|
|
701
|
+
await sc(['daemon-reload']);
|
|
702
|
+
await sc(['enable', '--now', labels.systemd]);
|
|
703
|
+
sendLog(`[native] systemd ${root ? 'system' : 'user'} service ${labels.systemd} enabled (Restart=always)`);
|
|
704
|
+
return 'systemd';
|
|
705
|
+
}
|
|
706
|
+
} catch (e) {
|
|
707
|
+
sendLog(`[native] ${name} service setup failed (${e.message}); falling back to detached process`);
|
|
708
|
+
}
|
|
709
|
+
const pid = startDetached(cmd, args, { cwd: projectDir, env });
|
|
710
|
+
if (name === 'gateway') state.botPid = pid;
|
|
711
|
+
return 'detached';
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/** Remove any launchd/systemd services registered for a native project (used on delete). */
|
|
715
|
+
async function removeNativeAutostart(projectDir) {
|
|
716
|
+
const labels = nativeServiceLabels(projectDir);
|
|
717
|
+
try {
|
|
718
|
+
if (process.platform === 'darwin') {
|
|
719
|
+
const agentsDir = join(getRealHomedir(), 'Library', 'LaunchAgents');
|
|
720
|
+
for (const k of ['9router', 'sync', 'gateway']) {
|
|
721
|
+
const p = join(agentsDir, `${labels[k].launchd}.plist`);
|
|
722
|
+
if (existsSync(p)) { await run('launchctl', ['unload', p], {}).catch(() => {}); await fsp.unlink(p).catch(() => {}); }
|
|
723
|
+
}
|
|
724
|
+
} else if (process.platform === 'linux') {
|
|
725
|
+
const root = isRootUser();
|
|
726
|
+
const unitDir = root ? '/etc/systemd/system' : systemdUserUnitDir();
|
|
727
|
+
const sc = (a) => run('systemctl', root ? a : ['--user', ...a], {}).catch(() => {});
|
|
728
|
+
let changed = false;
|
|
729
|
+
for (const k of ['9router', 'sync', 'gateway']) {
|
|
730
|
+
const u = join(unitDir, labels[k].systemd);
|
|
731
|
+
if (existsSync(u)) { await sc(['disable', '--now', labels[k].systemd]); await fsp.unlink(u).catch(() => {}); changed = true; }
|
|
732
|
+
}
|
|
733
|
+
if (changed) await sc(['daemon-reload']);
|
|
734
|
+
}
|
|
735
|
+
} catch (e) { sendLog(`[native] autostart teardown skipped: ${e.message}`); }
|
|
736
|
+
}
|
|
737
|
+
|
|
608
738
|
function safeJoin(root, name) {
|
|
609
739
|
const clean = normalize(String(name || '')).replace(/^([/\\])+/, '');
|
|
610
740
|
if (!clean || clean.includes('..')) throw httpError(400, 'Invalid file path');
|
|
@@ -1445,7 +1575,9 @@ async function createBotInProject(projectDir, body = {}, runtime = {}) {
|
|
|
1445
1575
|
cfg.agents.list.push({
|
|
1446
1576
|
id: agentId,
|
|
1447
1577
|
name: botName,
|
|
1448
|
-
workspace
|
|
1578
|
+
// Relative workspace path — resolves against the process cwd (project root) in both docker
|
|
1579
|
+
// and native. See buildOpenclawJson() for the full rationale.
|
|
1580
|
+
workspace: `.openclaw/${workspaceDir}`,
|
|
1449
1581
|
agentDir: `agents/${agentId}/agent`,
|
|
1450
1582
|
model: { primary: model === '9router/smart-route' || model === 'openai/smart-route' ? DEFAULT_MODEL : model, fallbacks: [] },
|
|
1451
1583
|
});
|
|
@@ -2145,6 +2277,13 @@ async function syncDockerInfra(projectDir, force = false) {
|
|
|
2145
2277
|
|
|
2146
2278
|
sendLog(`[sync] Updating Docker infrastructure files (v${existingVersion} \u2192 v${SETUP_VERSION})`);
|
|
2147
2279
|
await fsp.writeFile(join(dockerDir, 'Dockerfile'), docker.dockerfile, 'utf8');
|
|
2280
|
+
// Capture the user's custom disk/folder mounts from the OLD compose before we overwrite it — a
|
|
2281
|
+
// full regen only re-emits the default volumes, so without this the bot loses granted drives.
|
|
2282
|
+
let carriedMounts = [];
|
|
2283
|
+
try {
|
|
2284
|
+
const prevCompose = join(dockerDir, 'docker-compose.yml');
|
|
2285
|
+
if (existsSync(prevCompose)) carriedMounts = parseComposeMounts(await fsp.readFile(prevCompose, 'utf8'));
|
|
2286
|
+
} catch {}
|
|
2148
2287
|
await fsp.writeFile(join(dockerDir, 'docker-compose.yml'), newCompose, 'utf8');
|
|
2149
2288
|
// Preserve zalo-mod dashboard port if plugin is active
|
|
2150
2289
|
try {
|
|
@@ -2155,14 +2294,29 @@ async function syncDockerInfra(projectDir, force = false) {
|
|
|
2155
2294
|
let cc = await fsp.readFile(join(dockerDir, 'docker-compose.yml'), 'utf8');
|
|
2156
2295
|
if (!cc.includes(`:${dp}`)) {
|
|
2157
2296
|
const gpStr = String(gatewayPort);
|
|
2297
|
+
// Match the gateway published-port line whatever the host prefix is — the generated form is
|
|
2298
|
+
// "127.0.0.1:<gw>:<gw>", so keying off the container port (":<gw>" before the quote) is the
|
|
2299
|
+
// only reliable anchor. The old `(?:\d+:)?` variant never matched the "127.0.0.1:" prefix.
|
|
2158
2300
|
cc = cc.replace(
|
|
2159
|
-
new RegExp(`^(\\s*-\\s*"
|
|
2301
|
+
new RegExp(`^(\\s*-\\s*"[^"\\n]*:${gpStr}")\\s*$`, 'm'),
|
|
2160
2302
|
`$1\n - "127.0.0.1:${dp}:${dp}" # zalo-mod dashboard`
|
|
2161
2303
|
);
|
|
2162
2304
|
await fsp.writeFile(join(dockerDir, 'docker-compose.yml'), cc, 'utf8');
|
|
2163
2305
|
}
|
|
2164
2306
|
}
|
|
2165
2307
|
} catch {}
|
|
2308
|
+
// Re-inject the user's custom mounts carried over from the old compose.
|
|
2309
|
+
if (carriedMounts.length) {
|
|
2310
|
+
try {
|
|
2311
|
+
const cpath = join(dockerDir, 'docker-compose.yml');
|
|
2312
|
+
const cur = await fsp.readFile(cpath, 'utf8');
|
|
2313
|
+
const merged = injectMountsIntoCompose(cur, carriedMounts);
|
|
2314
|
+
if (merged !== cur) {
|
|
2315
|
+
await fsp.writeFile(cpath, merged, 'utf8');
|
|
2316
|
+
sendLog(`[sync] Preserved ${carriedMounts.length} granted mount(s) across the compose regen`);
|
|
2317
|
+
}
|
|
2318
|
+
} catch (e) { sendLog(`[sync] Warning: could not re-apply granted mounts: ${e.message}`); }
|
|
2319
|
+
}
|
|
2166
2320
|
await fsp.writeFile(entrypointPath, entryScript, 'utf8');
|
|
2167
2321
|
if (docker.syncScript) await fsp.writeFile(join(dockerDir, 'sync.js'), docker.syncScript, 'utf8');
|
|
2168
2322
|
if (docker.patchScript) await fsp.writeFile(join(dockerDir, 'patch-9router.js'), docker.patchScript, 'utf8');
|
|
@@ -2260,6 +2414,45 @@ async function restartDockerBotContainer(projectDir = state.projectDir) {
|
|
|
2260
2414
|
return true;
|
|
2261
2415
|
}
|
|
2262
2416
|
|
|
2417
|
+
// Parse user-granted disk/folder mounts (/mnt/<name>, excluding the always-present /mnt/project)
|
|
2418
|
+
// from a docker-compose.yml string. Handles both the long-form bind (type/source/target) and the
|
|
2419
|
+
// legacy short form `- "<host>:/mnt/<name>"`.
|
|
2420
|
+
function parseComposeMounts(compose) {
|
|
2421
|
+
const mounts = [];
|
|
2422
|
+
const lines = String(compose || '').split('\n');
|
|
2423
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2424
|
+
const tm = lines[i].match(/^\s*target:\s*['"]?(\/mnt\/[A-Za-z0-9._-]+)['"]?\s*$/);
|
|
2425
|
+
if (tm && tm[1] !== '/mnt/project') {
|
|
2426
|
+
let host = '';
|
|
2427
|
+
for (let j = i - 1; j >= Math.max(0, i - 3); j--) {
|
|
2428
|
+
const sm = lines[j].match(/^\s*source:\s*['"]?(.+?)['"]?\s*$/);
|
|
2429
|
+
if (sm) { host = sm[1].replace(/''/g, "'"); break; }
|
|
2430
|
+
}
|
|
2431
|
+
if (host) mounts.push({ host, target: tm[1] });
|
|
2432
|
+
continue;
|
|
2433
|
+
}
|
|
2434
|
+
const ssm = lines[i].match(/^\s*-\s*"?([^":\n]+):(\/mnt\/[A-Za-z0-9._-]+)"?\s*$/);
|
|
2435
|
+
if (ssm && ssm[2] !== '/mnt/project') mounts.push({ host: ssm[1].trim(), target: ssm[2] });
|
|
2436
|
+
}
|
|
2437
|
+
return mounts;
|
|
2438
|
+
}
|
|
2439
|
+
|
|
2440
|
+
// Insert long-form bind mounts (idempotent) after the project's .openclaw volume line. Returns the
|
|
2441
|
+
// original string unchanged if the anchor is missing or a mount is already present.
|
|
2442
|
+
function injectMountsIntoCompose(compose, mounts) {
|
|
2443
|
+
const anchor = /^(\s*-\s*\.\.\/\.\.\/\.openclaw:\/home\/node\/project\/\.openclaw)\s*$/m;
|
|
2444
|
+
if (!Array.isArray(mounts) || !mounts.length || !anchor.test(compose)) return compose;
|
|
2445
|
+
let out = compose;
|
|
2446
|
+
for (const m of mounts) {
|
|
2447
|
+
if (!m || !m.host || !m.target) continue;
|
|
2448
|
+
if (out.includes(`target: ${m.target}`) || out.includes(`:${m.target}"`) || out.includes(`:${m.target}\n`) || out.includes(m.host)) continue;
|
|
2449
|
+
const src = `'${String(m.host).replace(/'/g, "''")}'`;
|
|
2450
|
+
const block = ` - type: bind\n source: ${src}\n target: ${m.target}`;
|
|
2451
|
+
out = out.replace(anchor, `$1\n${block}`);
|
|
2452
|
+
}
|
|
2453
|
+
return out;
|
|
2454
|
+
}
|
|
2455
|
+
|
|
2263
2456
|
// Grant the bot access to a host disk/folder by mounting it into the container at /mnt/<name>.
|
|
2264
2457
|
// Project-scoped: all bots in this project share the container, so all of them can use it.
|
|
2265
2458
|
// Per-bot limits are described in AGENTS.md (not enforced at the mount layer).
|
|
@@ -2267,7 +2460,10 @@ async function addBotMount(projectDir, hostPath, mountName = '') {
|
|
|
2267
2460
|
// Cross-OS normalize: trim, convert Windows backslashes → forward slashes (Docker accepts
|
|
2268
2461
|
// forward slashes on every OS, incl. `C:/Users/...`), drop trailing separators. This avoids
|
|
2269
2462
|
// YAML backslash issues and keeps the path uniform.
|
|
2270
|
-
|
|
2463
|
+
let cleanPath = String(hostPath || '').trim().replace(/\\+/g, '/').replace(/\/+$/, '');
|
|
2464
|
+
// A bare Windows drive letter ("D:") is an INVALID Docker bind source — the trailing-slash strip
|
|
2465
|
+
// above turns "D:/" into "D:". Restore the slash so mounting a whole drive (e.g. D:\) works.
|
|
2466
|
+
if (/^[a-zA-Z]:$/.test(cleanPath)) cleanPath += '/';
|
|
2271
2467
|
if (!cleanPath) throw httpError(400, 'Đường dẫn ổ đĩa/thư mục đang trống');
|
|
2272
2468
|
const composeFile = join(projectDir, 'docker', 'openclaw', 'docker-compose.yml');
|
|
2273
2469
|
if (!existsSync(composeFile)) throw httpError(400, 'Không tìm thấy docker-compose.yml (project có thể không chạy ở chế độ Docker)');
|
|
@@ -2436,17 +2632,62 @@ async function installCore({ osChoice, mode, projectDir, gatewayPort = 18789, ro
|
|
|
2436
2632
|
await applyResolved9RouterApiKey(projectDir).catch(() => {});
|
|
2437
2633
|
await recreateDockerBot(projectDir).catch(() => {});
|
|
2438
2634
|
} else {
|
|
2635
|
+
const nineRouterDataDir = join(projectDir, '.9router');
|
|
2636
|
+
const nineRouterDbPath = join(nineRouterDataDir, 'db', 'data.sqlite');
|
|
2439
2637
|
const runtimeEnv = {
|
|
2440
2638
|
OPENCLAW_HOME: join(projectDir, '.openclaw'),
|
|
2441
2639
|
OPENCLAW_STATE_DIR: join(projectDir, '.openclaw'),
|
|
2442
|
-
DATA_DIR:
|
|
2640
|
+
DATA_DIR: nineRouterDataDir,
|
|
2443
2641
|
OPENCLAW_GATEWAY_PORT: String(gatewayPort),
|
|
2444
2642
|
OPENCLAW_PORT: String(gatewayPort),
|
|
2643
|
+
// Parity with the docker runtime env (docker-gen.js) so the gateway and browser plugin
|
|
2644
|
+
// behave the same natively: allow the private-network control-UI websocket and tell the
|
|
2645
|
+
// browser plugin which host OS it is running on.
|
|
2646
|
+
OPENCLAW_ALLOW_INSECURE_PRIVATE_WS: '1',
|
|
2647
|
+
OPENCLAW_SETUP_OS: osChoice || '',
|
|
2648
|
+
OPENCLAW_BROWSER_HOST_OS: osChoice || '',
|
|
2445
2649
|
};
|
|
2446
2650
|
await run('openclaw', ['gateway', 'stop'], { cwd: projectDir, env: runtimeEnv }).catch(() => {});
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2651
|
+
// 9router binds loopback in native mode on purpose: openclaw runs on the SAME host and
|
|
2652
|
+
// reaches it via localhost (see get9RouterBaseUrl), so there is no reason to expose the LLM
|
|
2653
|
+
// proxy on 0.0.0.0 — even on a VPS that would be an open relay. Only the gateway is exposed.
|
|
2654
|
+
// Start 9router first (auto-restart supervised), then sync, then the gateway — so the gateway
|
|
2655
|
+
// boots after 9router is reachable and after the API key has been written to its config.
|
|
2656
|
+
const routerMethod = await startNativeService({
|
|
2657
|
+
projectDir, name: '9router', cmd: '9router', desc: `OpenClaw 9router (${nativeServiceId(projectDir)})`,
|
|
2658
|
+
args: ['-n', '-l', '-H', '127.0.0.1', '-p', String(routerPort), '--skip-update'], env: runtimeEnv,
|
|
2659
|
+
});
|
|
2660
|
+
// Smart-route auto-sync: the docker sidecar runs a script that disables 9router's login
|
|
2661
|
+
// requirement and builds the default `smart-route` combo from the active providers. Native
|
|
2662
|
+
// mode skipped this, so the default model had no backing models. Reuse the exact docker
|
|
2663
|
+
// script with the native DB path (passed via env to stay cross-platform / shell-safe).
|
|
2664
|
+
try {
|
|
2665
|
+
const artifacts = buildDockerArtifacts({ is9Router: true, osChoice, openClawNpmSpec: OPENCLAW_NPM_SPEC, gatewayPort, routerPort });
|
|
2666
|
+
if (artifacts.syncScript) {
|
|
2667
|
+
await fsp.mkdir(nineRouterDataDir, { recursive: true });
|
|
2668
|
+
const syncPath = join(nineRouterDataDir, 'sync.js');
|
|
2669
|
+
await fsp.writeFile(syncPath, artifacts.syncScript, 'utf8');
|
|
2670
|
+
// Run the sync under supervision too (managed service) so it is torn down cleanly on
|
|
2671
|
+
// delete instead of leaking an orphaned `node sync.js` process.
|
|
2672
|
+
await startNativeService({
|
|
2673
|
+
projectDir, name: 'sync', cmd: process.execPath, args: [syncPath],
|
|
2674
|
+
desc: `OpenClaw 9router smart-route sync (${nativeServiceId(projectDir)})`,
|
|
2675
|
+
env: { ...runtimeEnv, NINEROUTER_DB_PATH: nineRouterDbPath, PORT: String(routerPort) },
|
|
2676
|
+
});
|
|
2677
|
+
sendLog('[native] 9router smart-route sync started');
|
|
2678
|
+
}
|
|
2679
|
+
} catch (e) {
|
|
2680
|
+
sendLog(`[native] smart-route sync setup skipped: ${e.message}`);
|
|
2681
|
+
}
|
|
2682
|
+
// Give 9router a moment to boot + sync to disable its login gate, then resolve its API key
|
|
2683
|
+
// into openclaw.json BEFORE the gateway starts (so the gateway reads it on first boot).
|
|
2684
|
+
await new Promise((r) => setTimeout(r, 8000));
|
|
2685
|
+
await applyResolved9RouterApiKey(projectDir).catch(() => {});
|
|
2686
|
+
const gatewayMethod = await startNativeService({
|
|
2687
|
+
projectDir, name: 'gateway', cmd: 'openclaw', desc: `OpenClaw gateway (${nativeServiceId(projectDir)})`,
|
|
2688
|
+
args: ['gateway', 'run'], env: runtimeEnv,
|
|
2689
|
+
});
|
|
2690
|
+
sendLog(`Native runtime started — 9router via ${routerMethod}, gateway via ${gatewayMethod}${state.botPid ? ` (pid=${state.botPid})` : ''}`);
|
|
2450
2691
|
}
|
|
2451
2692
|
state.installed = true;
|
|
2452
2693
|
sendLog('✅ Install completed');
|
|
@@ -2851,6 +3092,10 @@ async function deleteProjectFolder(projectDir, rootProjectDir) {
|
|
|
2851
3092
|
});
|
|
2852
3093
|
// Sleep 2.5 seconds to let Windows file system release overlays/locks
|
|
2853
3094
|
await new Promise((resolve) => setTimeout(resolve, 2500));
|
|
3095
|
+
} else {
|
|
3096
|
+
// Native project: stop & remove any launchd/systemd auto-restart services so they don't keep
|
|
3097
|
+
// respawning a deleted bot. No-op when no services were registered.
|
|
3098
|
+
await removeNativeAutostart(resolved);
|
|
2854
3099
|
}
|
|
2855
3100
|
|
|
2856
3101
|
try {
|
|
@@ -3079,7 +3324,7 @@ async function applyFeatureToggle(projectDir, agentId, kind, id, enabled) {
|
|
|
3079
3324
|
if (dashPort && !composeContent.includes(`:${dashPort}`)) {
|
|
3080
3325
|
const gwPortStr = String(Number(cfg.gateway?.port) || state.gatewayPort || 18789);
|
|
3081
3326
|
composeContent = composeContent.replace(
|
|
3082
|
-
new RegExp(`^(\\s*-\\s*"
|
|
3327
|
+
new RegExp(`^(\\s*-\\s*"[^"\\n]*:${gwPortStr}")\\s*$`, 'm'),
|
|
3083
3328
|
`$1\n - "127.0.0.1:${dashPort}:${dashPort}" # zalo-mod dashboard`
|
|
3084
3329
|
);
|
|
3085
3330
|
await fsp.writeFile(composeFile, composeContent, 'utf8');
|
|
@@ -3278,7 +3523,7 @@ async function installFeature(projectDir, agentId, kind, id) {
|
|
|
3278
3523
|
// Insert dashboard port after the gateway port line
|
|
3279
3524
|
const gwPortStr = String(gwPort);
|
|
3280
3525
|
composeContent = composeContent.replace(
|
|
3281
|
-
new RegExp(`^(\\s*-\\s*"
|
|
3526
|
+
new RegExp(`^(\\s*-\\s*"[^"\\n]*:${gwPortStr}")\\s*$`, 'm'),
|
|
3282
3527
|
`$1\n - ${dashPortMapping} # zalo-mod dashboard`
|
|
3283
3528
|
);
|
|
3284
3529
|
await fsp.writeFile(composeFile, composeContent, 'utf8');
|
|
@@ -4073,7 +4318,7 @@ export async function startLocalInstaller({ host = '127.0.0.1', preferredPort =
|
|
|
4073
4318
|
printRemoteAccessHint(port).catch(() => {});
|
|
4074
4319
|
}
|
|
4075
4320
|
|
|
4076
|
-
export { createBotInProject, updateBotInProject, deleteBotInProject, validateOpenclawConfig, startZaloUserLogin, readBotCredentials, resolveProject9RouterApiKey };
|
|
4321
|
+
export { createBotInProject, updateBotInProject, deleteBotInProject, validateOpenclawConfig, startZaloUserLogin, readBotCredentials, resolveProject9RouterApiKey, installCore, deleteProjectFolder, removeNativeAutostart };
|
|
4077
4322
|
|
|
4078
4323
|
|
|
4079
4324
|
|
|
@@ -82,23 +82,29 @@
|
|
|
82
82
|
const isLocal = !!provider.isLocal;
|
|
83
83
|
|
|
84
84
|
// ── agents ────────────────────────────────────────────────────────────────
|
|
85
|
+
// Workspace is a RELATIVE path (resolved by OpenClaw against the process cwd, which is the
|
|
86
|
+
// project root in both modes: WORKDIR=/home/node/project in docker, cwd=projectDir natively).
|
|
87
|
+
// OpenClaw's resolveUserPath() keeps absolute paths as-is, so the old container-absolute
|
|
88
|
+
// "/home/node/project/.openclaw/…" pointed at a non-existent path on a native host (the bot's
|
|
89
|
+
// persona/memory/skills silently lived in the wrong place). `agentDir` is already relative and
|
|
90
|
+
// proven to resolve correctly in docker, so relative `workspace` is byte-identical for docker
|
|
91
|
+
// and finally correct for native. See workspaceRelForAgent() which accepts both forms.
|
|
85
92
|
const agentsList = agentMetas.map((meta) => ({
|
|
86
93
|
id: meta.agentId,
|
|
87
94
|
...(meta.name ? { name: meta.name } : {}),
|
|
88
|
-
workspace:
|
|
95
|
+
workspace: `.openclaw/${meta.workspaceDir || 'workspace-' + meta.agentId}`,
|
|
89
96
|
agentDir: `agents/${meta.agentId}/agent`,
|
|
90
97
|
model: { primary: model, fallbacks: [] },
|
|
91
98
|
}));
|
|
92
99
|
|
|
93
100
|
const cfg = {
|
|
94
|
-
// NOTE: do NOT seed `
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
// `
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
},
|
|
101
|
+
// NOTE: do NOT seed a `meta` block here. `meta` is owned by OpenClaw — it stamps
|
|
102
|
+
// `{ lastTouchedVersion, lastTouchedAt }` itself on first run and adds the block if
|
|
103
|
+
// missing. Writing our own fields into it (osChoice/deployMode), or seeding
|
|
104
|
+
// `lastTouchedVersion` from OPENCLAW_NPM_SPEC which is a range/`latest`
|
|
105
|
+
// (e.g. `>=2026.6.10`) not a concrete version, makes OpenClaw fail to parse the config
|
|
106
|
+
// on boot and crash the container. osChoice/deployMode are installer-only concerns and
|
|
107
|
+
// are persisted via the browser plugin's `config.hostOs` + in-memory state instead.
|
|
102
108
|
agents: {
|
|
103
109
|
defaults: {
|
|
104
110
|
model: { primary: model, fallbacks: [] },
|
|
@@ -18,103 +18,100 @@
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
function build9RouterSmartRouteSyncScript() {
|
|
21
|
+
// First-install auto-sync: đăng nhập 9router bằng MẬT KHẨU MẶC ĐỊNH 123456 (9router cấp),
|
|
22
|
+
// gom model của các provider đang active → tạo combo 'smart-route' MỘT LẦN rồi DỪNG.
|
|
23
|
+
// KHÔNG tự tắt Require login nữa (require login giữ ON, pass mặc định 123456, user đổi sau).
|
|
21
24
|
const lines = [
|
|
22
25
|
"const fs = require('fs');",
|
|
23
26
|
"const INTERVAL = 30000;",
|
|
24
|
-
"const
|
|
27
|
+
"const MAX_ATTEMPTS = 20; // ~10 phút; tạo combo lần đầu rồi dừng, không lặp vĩnh viễn",
|
|
28
|
+
"const DEFAULT_PASSWORD = '123456'; // mật khẩu mặc định 9router",
|
|
29
|
+
// Docker mounts the DB at /root/.9router; native installs pass NINEROUTER_DB_PATH so the
|
|
30
|
+
// same sync script works on the host without hardcoding the container path.
|
|
31
|
+
"const DB_PATH = process.env.NINEROUTER_DB_PATH || '/root/.9router/db/data.sqlite';",
|
|
25
32
|
"const PORT = process.env.PORT || 20128;",
|
|
26
33
|
"const COMBO_NAME = 'smart-route';",
|
|
27
34
|
"const API_BASE = `http://localhost:${PORT}`;",
|
|
28
35
|
"",
|
|
29
|
-
"
|
|
36
|
+
"// Đăng nhập bằng mật khẩu mặc định → lấy cookie auth_token để gọi API quản trị.",
|
|
37
|
+
"async function login() {",
|
|
30
38
|
" try {",
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
-
" }
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
" }",
|
|
42
|
-
' const existing = db.prepare("SELECT * FROM settings WHERE id = 1").get();',
|
|
43
|
-
" if (!existing) {",
|
|
44
|
-
' db.prepare("INSERT INTO settings (id, data) VALUES (1, ?)").run(JSON.stringify({ requireLogin: false }));',
|
|
45
|
-
" } else {",
|
|
46
|
-
" try {",
|
|
47
|
-
" const data = JSON.parse(existing.data || '{}');",
|
|
48
|
-
" if (data.requireLogin !== false) {",
|
|
49
|
-
" data.requireLogin = false;",
|
|
50
|
-
' db.prepare("UPDATE settings SET data = ? WHERE id = 1").run(JSON.stringify(data));',
|
|
51
|
-
" }",
|
|
52
|
-
" } catch {}",
|
|
53
|
-
" }",
|
|
54
|
-
" db.close();",
|
|
55
|
-
" } catch (e) {}",
|
|
39
|
+
" const r = await fetch(`${API_BASE}/api/auth/login`, {",
|
|
40
|
+
" method: 'POST',",
|
|
41
|
+
" headers: { 'Content-Type': 'application/json' },",
|
|
42
|
+
" body: JSON.stringify({ password: DEFAULT_PASSWORD })",
|
|
43
|
+
" });",
|
|
44
|
+
" if (!r.ok) return '';",
|
|
45
|
+
" const sc = r.headers.get('set-cookie') || '';",
|
|
46
|
+
" const m = sc.match(/auth_token=[^;]+/);",
|
|
47
|
+
" return m ? m[0] : '';",
|
|
48
|
+
" } catch (e) { return ''; }",
|
|
56
49
|
"}",
|
|
57
50
|
"",
|
|
58
|
-
"
|
|
59
|
-
"
|
|
60
|
-
"
|
|
61
|
-
"",
|
|
62
|
-
" let existingCombo = null;",
|
|
63
|
-
" try {",
|
|
64
|
-
" const resp = await fetch(`${API_BASE}/api/combos`);",
|
|
65
|
-
" if (resp.status === 401) {",
|
|
66
|
-
" ensureSettings();",
|
|
67
|
-
" return;",
|
|
68
|
-
" }",
|
|
69
|
-
" const data = await resp.json();",
|
|
70
|
-
" if (data.combos) {",
|
|
71
|
-
" existingCombo = data.combos.find(c => c.name === COMBO_NAME);",
|
|
72
|
-
" }",
|
|
73
|
-
" } catch (e) { return; }",
|
|
51
|
+
"async function trySync() {",
|
|
52
|
+
" if (!fs.existsSync(DB_PATH)) return false;",
|
|
53
|
+
" const cookie = await login(); // mk mặc định 123456; nếu user đã đổi thì login fail",
|
|
54
|
+
" const authHeaders = cookie ? { Cookie: cookie } : {};",
|
|
74
55
|
"",
|
|
75
|
-
"
|
|
76
|
-
"",
|
|
77
|
-
"
|
|
78
|
-
"
|
|
79
|
-
"
|
|
80
|
-
"
|
|
81
|
-
"
|
|
82
|
-
" activeProviders = [...new Set(",
|
|
83
|
-
" conns.filter(c => c && c.provider && c.isActive !== false && !c.disabled).map(c => c.provider)",
|
|
84
|
-
" )];",
|
|
85
|
-
" } catch (e) { return; }",
|
|
86
|
-
"",
|
|
87
|
-
" if (!activeProviders.length) return;",
|
|
56
|
+
" // Combo đã tồn tại? → coi như xong (chỉ tạo lần đầu).",
|
|
57
|
+
" try {",
|
|
58
|
+
" const r = await fetch(`${API_BASE}/api/combos`, { headers: authHeaders });",
|
|
59
|
+
" if (r.status === 401) return false;",
|
|
60
|
+
" const d = await r.json();",
|
|
61
|
+
" if ((d.combos || []).some(c => c.name === COMBO_NAME)) return true;",
|
|
62
|
+
" } catch (e) { return false; }",
|
|
88
63
|
"",
|
|
89
|
-
"
|
|
90
|
-
"
|
|
91
|
-
"
|
|
92
|
-
"
|
|
93
|
-
"
|
|
94
|
-
"
|
|
95
|
-
"
|
|
96
|
-
"
|
|
97
|
-
"
|
|
98
|
-
"
|
|
99
|
-
"
|
|
100
|
-
" } catch (e) { return; }",
|
|
64
|
+
" // Provider đang active.",
|
|
65
|
+
" let activeProviders = [];",
|
|
66
|
+
" try {",
|
|
67
|
+
" const r = await fetch(`${API_BASE}/api/providers`, { headers: authHeaders });",
|
|
68
|
+
" const d = await r.json();",
|
|
69
|
+
" const conns = d.connections || d.providerConnections || [];",
|
|
70
|
+
" activeProviders = [...new Set(",
|
|
71
|
+
" conns.filter(c => c && c.provider && c.isActive !== false && !c.disabled).map(c => c.provider)",
|
|
72
|
+
" )];",
|
|
73
|
+
" } catch (e) { return false; }",
|
|
74
|
+
" if (!activeProviders.length) return false;",
|
|
101
75
|
"",
|
|
102
|
-
"
|
|
76
|
+
" // Model của provider active (bỏ embedding/image/tts/…).",
|
|
77
|
+
" let models = [];",
|
|
78
|
+
" try {",
|
|
79
|
+
" const r = await fetch(`${API_BASE}/api/models`, { headers: authHeaders });",
|
|
80
|
+
" const d = await r.json();",
|
|
81
|
+
" if (Array.isArray(d.models)) {",
|
|
82
|
+
" models = [...new Set(d.models",
|
|
83
|
+
" .filter(m => activeProviders.includes(m.provider))",
|
|
84
|
+
" .filter(m => !/(embedding|image|tts|stt|audio|vision)/i.test(m.model))",
|
|
85
|
+
" .map(m => m.fullModel))];",
|
|
86
|
+
" }",
|
|
87
|
+
" } catch (e) { return false; }",
|
|
88
|
+
" if (!models.length) return false;",
|
|
103
89
|
"",
|
|
104
|
-
"
|
|
105
|
-
"
|
|
106
|
-
"
|
|
107
|
-
"
|
|
108
|
-
"
|
|
109
|
-
" })
|
|
110
|
-
"
|
|
111
|
-
"
|
|
112
|
-
"
|
|
113
|
-
"};",
|
|
90
|
+
" // Tạo combo smart-route (lần đầu).",
|
|
91
|
+
" try {",
|
|
92
|
+
" await fetch(`${API_BASE}/api/combos`, {",
|
|
93
|
+
" method: 'POST',",
|
|
94
|
+
" headers: { 'Content-Type': 'application/json', ...authHeaders },",
|
|
95
|
+
" body: JSON.stringify({ name: COMBO_NAME, models })",
|
|
96
|
+
" });",
|
|
97
|
+
" console.log('[sync-combo] Created smart-route with ' + models.length + ' models');",
|
|
98
|
+
" return true;",
|
|
99
|
+
" } catch (e) { return false; }",
|
|
100
|
+
"}",
|
|
114
101
|
"",
|
|
115
|
-
"
|
|
116
|
-
"
|
|
117
|
-
"
|
|
102
|
+
"let attempts = 0;",
|
|
103
|
+
"let timer = null;",
|
|
104
|
+
"async function tick() {",
|
|
105
|
+
" attempts++;",
|
|
106
|
+
" let done = false;",
|
|
107
|
+
" try { done = await trySync(); } catch (e) {}",
|
|
108
|
+
" if (done || attempts >= MAX_ATTEMPTS) {",
|
|
109
|
+
" if (timer) clearInterval(timer);",
|
|
110
|
+
" console.log('[sync-combo] finished (attempts=' + attempts + ', done=' + done + ')');",
|
|
111
|
+
" }",
|
|
112
|
+
"}",
|
|
113
|
+
"timer = setInterval(tick, INTERVAL);",
|
|
114
|
+
"setTimeout(tick, 10000);",
|
|
118
115
|
];
|
|
119
116
|
return lines.join('\n');
|
|
120
117
|
}
|
|
@@ -187,7 +184,7 @@ if(touched){console.log('[patch-9router] Applied Codex compatibility patch.');}e
|
|
|
187
184
|
return [
|
|
188
185
|
`npm install -g ` + nineRouterSpec,
|
|
189
186
|
'node /tmp/patch-9router.js || true',
|
|
190
|
-
'node -e "const fs=require(\'fs\'),path=require(\'path\'); const DB_PATH=\'/root/.9router/db/data.sqlite\'; const dir=path.dirname(DB_PATH); if(!fs.existsSync(dir))fs.mkdirSync(dir,{recursive:true}); try{ const {DatabaseSync}=require(\'node:sqlite\'); const db=new DatabaseSync(DB_PATH); db.prepare(\'CREATE TABLE IF NOT EXISTS settings (id INTEGER PRIMARY KEY CHECK (id = 1), data TEXT NOT NULL)\').run(); const existing=db.prepare(\'SELECT * FROM settings WHERE id = 1\').get(); if(!existing){ db.prepare(\'INSERT INTO settings (id, data) VALUES (1, ?)\').run(JSON.stringify({requireLogin:
|
|
187
|
+
'node -e "const fs=require(\'fs\'),path=require(\'path\'); const DB_PATH=\'/root/.9router/db/data.sqlite\'; const dir=path.dirname(DB_PATH); if(!fs.existsSync(dir))fs.mkdirSync(dir,{recursive:true}); try{ const {DatabaseSync}=require(\'node:sqlite\'); const db=new DatabaseSync(DB_PATH); db.prepare(\'CREATE TABLE IF NOT EXISTS settings (id INTEGER PRIMARY KEY CHECK (id = 1), data TEXT NOT NULL)\').run(); const existing=db.prepare(\'SELECT * FROM settings WHERE id = 1\').get(); if(!existing){ db.prepare(\'INSERT INTO settings (id, data) VALUES (1, ?)\').run(JSON.stringify({requireLogin:true})); } db.close(); }catch(e){}" || true',
|
|
191
188
|
'node /tmp/sync.js > /tmp/sync.log 2>&1 &',
|
|
192
189
|
`exec 9router -n -l -H 0.0.0.0 -p ${port} --skip-update`
|
|
193
190
|
].join('\n');
|
package/dist/web/app.js
CHANGED
|
@@ -405,6 +405,9 @@ function topbarActionsHtml() {
|
|
|
405
405
|
|
|
406
406
|
function render() {
|
|
407
407
|
applyPrefs();
|
|
408
|
+
// Expose the active tab on <body> so CSS can drop the redundant page-title header on the
|
|
409
|
+
// dashboard (the dashboard hero already carries its own title).
|
|
410
|
+
document.body.dataset.activeTab = state.tab;
|
|
408
411
|
const tabs = [['dashboard',t('Dashboard','Dashboard')],['setup',ui('setup')],['bot',ui('bot')],['logs',ui('logs')]];
|
|
409
412
|
|
|
410
413
|
let mainContainer = $('#app-main-content');
|
|
@@ -617,9 +620,9 @@ function dashboardView() {
|
|
|
617
620
|
<h2>${t('Dashboard v\u1eadn h\u00e0nh', 'Operational Dashboard')}</h2>
|
|
618
621
|
<p class="lead" style="margin-top:6px">${t('M\u1edf website, xem version, tr\u1ea1ng th\u00e1i, bot v\u00e0 project.', 'Open website, view versions, status, bots and projects.')}</p>
|
|
619
622
|
</div>
|
|
620
|
-
<div class="dash-actions" style="flex-direction:
|
|
621
|
-
<button class="primary icon-btn2" data-tab-jump="bot" type="button" style="justify-content:center; min-width:
|
|
622
|
-
<button class="secondary icon-btn2" data-tab-jump="setup" type="button" style="justify-content:center; border-width:2px; min-width:
|
|
623
|
+
<div class="dash-actions" style="flex-direction:row; gap:10px;">
|
|
624
|
+
<button class="primary icon-btn2" data-tab-jump="bot" type="button" style="justify-content:center; flex:1; min-width:0;"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a2 2 0 0 1 2 2c0 .74-.4 1.39-1 1.73V7h1a7 7 0 0 1 7 7h1a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-1v1a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-1H2a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1h1a7 7 0 0 1 7-7h1V5.73c-.6-.34-1-.99-1-1.73a2 2 0 0 1 2-2z"></path></svg>${t('Bot','Bot')}</button>
|
|
625
|
+
<button class="secondary icon-btn2" data-tab-jump="setup" type="button" style="justify-content:center; border-width:2px; flex:1; min-width:0;"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15V3m0 12l-4-4m4 4l4-4M2 17l.621 2.485A2 2 0 0 0 4.561 21h14.878a2 2 0 0 0 1.94-1.515L22 17"></path></svg>${t('C\u00e0i \u0111\u1eb7t','Setup')}</button>
|
|
623
626
|
</div>
|
|
624
627
|
</div>
|
|
625
628
|
<div class="project-tabs" style="display:flex; gap:8px; flex-wrap:wrap; padding-top:10px; border-top:1px solid rgba(255,255,255,0.06);">
|
|
@@ -627,7 +630,7 @@ function dashboardView() {
|
|
|
627
630
|
</div>
|
|
628
631
|
</section>
|
|
629
632
|
|
|
630
|
-
<section class="dash-layout"
|
|
633
|
+
<section class="dash-layout">
|
|
631
634
|
<div style="display:flex; flex-direction:column; gap:18px;">
|
|
632
635
|
<div class="dash-grid" style="grid-template-columns: repeat(2, minmax(0, 1fr)); align-content: start;">
|
|
633
636
|
${widgets.map(w => `<article class="card dash-metric"><span>${escapeHtml(w.label)}</span><strong style="font-size:22px; word-break:break-all;">${w.value}</strong><small>${escapeHtml(w.meta)}</small></article>`).join('')}
|
|
@@ -985,26 +988,31 @@ function botSkillsPanel() {
|
|
|
985
988
|
const requiresInstall = group === 'plugin' || (group === 'skill' && (item.id === 'image-gen' || item.id === 'sticker-mention' || item.id === 'learning-memory'));
|
|
986
989
|
const isInstalled = !requiresInstall || !!state.featureInstalled?.[key];
|
|
987
990
|
|
|
988
|
-
|
|
991
|
+
const version = requiresInstall && isInstalled ? (state.featureVersions?.[key] || '') : '';
|
|
992
|
+
const versionBadge = version ? `<span class="plugin-version-badge" style="display:inline-block; font-size: 11px; background: rgba(66, 133, 244, 0.15); color: #4285F4; padding: 2px 6px; border-radius: 4px; font-weight: 600; border: 1px solid rgba(66,133,244,0.25); flex:0 0 auto;">v${escapeHtml(version)}</span>` : '';
|
|
993
|
+
|
|
994
|
+
// The on/off toggle stays inline with the title (one line, name truncates with ellipsis if
|
|
995
|
+
// long). The wider Open / Update / Install controls drop to their own row below the description.
|
|
996
|
+
let toggleHtml = '';
|
|
997
|
+
const secs = [];
|
|
989
998
|
if (isInstalled) {
|
|
990
|
-
|
|
999
|
+
toggleHtml = `<label class="feature-switch"><input type="checkbox" data-feature-toggle="${key}" ${flags[key] ? 'checked' : ''} ${loading ? 'disabled' : ''}/><span></span></label>`;
|
|
991
1000
|
if (item.openWebPort) {
|
|
992
|
-
|
|
1001
|
+
secs.push(`<a class="secondary icon-btn2" href="${sameHostUrl('', item.openWebPort)}${item.openWebPath || ''}" target="_blank" rel="noopener" title="${t('Mở dashboard của plugin','Open the plugin dashboard')}" style="padding: 4px 8px; font-size: 11px; height: 28px; border-width: 1px;">${actionIcon('link')}<span>${t('Mở web','Open')}</span></a>`);
|
|
993
1002
|
}
|
|
994
1003
|
if (requiresInstall) {
|
|
995
|
-
|
|
1004
|
+
secs.push(`<button class="secondary icon-btn2 update-plugin-btn" type="button" data-feature-install="${key}" ${loading ? 'disabled' : ''} title="${t('Cập nhật lên bản mới nhất','Update to latest version')}" style="padding: 4px 8px; font-size: 11px; height: 28px; border-width: 1px; color:#ffb020; border-color: rgba(255,176,32,0.25); background: rgba(255,176,32,0.05);">${actionIcon('refresh')}<span>${t('Cập nhật','Update')}</span></button>`);
|
|
996
1005
|
}
|
|
997
|
-
actionsHtml += `<label class="feature-switch"><input type="checkbox" data-feature-toggle="${key}" ${flags[key] ? 'checked' : ''} ${loading ? 'disabled' : ''}/><span></span></label></div>`;
|
|
998
1006
|
} else {
|
|
999
|
-
|
|
1007
|
+
secs.push(`<button class="secondary icon-btn2" type="button" data-feature-install="${key}" ${loading ? 'disabled' : ''}>${actionIcon('download')} ${ui('installVerb')}</button>`);
|
|
1000
1008
|
}
|
|
1009
|
+
const secondaryHtml = secs.length ? `<div class="feature-actions">${secs.join('')}</div>` : '';
|
|
1001
1010
|
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
`</div>${loading ? '<div class="feature-progress"><i></i></div>' : ''}</article>`;
|
|
1011
|
+
return `<article class="card feature-card ${loading ? 'is-loading' : ''}">` +
|
|
1012
|
+
`<div class="feature-head"><div class="feature-title"><b class="feature-name">${escapeHtml(item.title)}</b>${versionBadge}</div>${toggleHtml}</div>` +
|
|
1013
|
+
`<p class="feature-desc">${escapeHtml(item.desc)}</p>` +
|
|
1014
|
+
secondaryHtml +
|
|
1015
|
+
`${loading ? '<div class="feature-progress"><i></i></div>' : ''}</article>`;
|
|
1008
1016
|
};
|
|
1009
1017
|
return `
|
|
1010
1018
|
<h4 class="feature-group">⚡ ${t('Skills','Skills')}</h4>
|
package/dist/web/styles.css
CHANGED
|
@@ -49,7 +49,7 @@ html[data-theme="light"] .topbar{background:rgba(255,255,255,.82);box-shadow:0 1
|
|
|
49
49
|
.setup-copy{display:grid!important;grid-template-columns:minmax(240px,.78fr) 1fr;align-items:center!important;gap:22px!important;min-height:128px;padding:22px 24px!important;background:linear-gradient(135deg,rgba(18,201,255,.075),rgba(255,255,255,.035),rgba(124,58,237,.055))!important;border-color:rgba(96,165,250,.20)!important}.setup-copy h2{margin:12px 0 0;font-size:22px;letter-spacing:-.035em;line-height:1.15}.setup-copy .lead{margin:0;font-size:18px;line-height:1.55;max-width:560px;color:var(--body);font-weight:500}.mini-pill{border-color:rgba(18,201,255,.38)!important;background:rgba(18,201,255,.09)!important;color:var(--soft)!important}
|
|
50
50
|
.setup-section,.card,.choice-card,.system-card,.terminal{backdrop-filter:blur(14px);background:linear-gradient(145deg,rgba(8,18,32,.72),rgba(5,8,14,.82))!important;border-color:var(--hair)!important}.choice-card:has(input:checked),.choice-card.is-selected{border-color:var(--primary)!important;background:linear-gradient(145deg,rgba(18,201,255,.13),rgba(8,18,32,.88))!important;box-shadow:0 0 0 1px rgba(18,201,255,.25) inset,0 24px 70px rgba(18,201,255,.10)!important}.choice-card:has(input:checked) .choice-card__meta i,.choice-card.is-selected .choice-card__meta i{border-color:var(--primary)!important;background:radial-gradient(circle,var(--primary) 0 42%,transparent 46%)!important;box-shadow:0 0 0 5px rgba(18,201,255,.14),0 0 28px rgba(18,201,255,.34)!important}.install-cta{background:linear-gradient(135deg,var(--primary),var(--soft) 48%,#0284c7)!important;color:#001018!important}.install-cta small{color:rgba(0,16,24,.72)!important}
|
|
51
51
|
html[data-theme="light"] .setup-section,html[data-theme="light"] .card,html[data-theme="light"] .choice-card,html[data-theme="light"] .system-card,html[data-theme="light"] .terminal{background:rgba(255,255,255,.82)!important;color:var(--ink)!important}.choice-card__body small{color:var(--body)!important}
|
|
52
|
-
@media(max-width:900px){.topbar{height:auto
|
|
52
|
+
@media(max-width:900px){.topbar{height:auto}.setup-copy{grid-template-columns:1fr!important}.setup-copy .lead{font-size:16px}}
|
|
53
53
|
|
|
54
54
|
|
|
55
55
|
/* OpenClaw red theme + overflow containment fix */
|
|
@@ -651,6 +651,10 @@ html[data-theme="light"] .donut-chart::before {
|
|
|
651
651
|
.chart-info span{color:var(--muted);font-size:12px;font-weight:800;text-transform:uppercase}
|
|
652
652
|
.chart-info strong{font-size:18px;letter-spacing:-0.02em;line-height:1.2}
|
|
653
653
|
.chart-info small{color:var(--body);font-size:13px}
|
|
654
|
+
.chart-info span,.chart-info strong,.chart-info small{overflow-wrap:anywhere}
|
|
655
|
+
/* On narrow screens the chart cards sit 2-up (~162px each); stack the donut above the
|
|
656
|
+
text so the labels ("Installed Extensions", model id) get the full card width. */
|
|
657
|
+
@media(max-width:760px){.chart-card{flex-direction:column;text-align:center;gap:10px;padding:14px}.chart-info{align-items:center}}
|
|
654
658
|
|
|
655
659
|
/* Premium Toast Notification System */
|
|
656
660
|
.toast-container {
|
|
@@ -1912,3 +1916,99 @@ main{transition:margin .2s}
|
|
|
1912
1916
|
/* Tablet/mobile bot page: stop horizontal overflow so .channel-tabs scrolls instead of stretching the layout */
|
|
1913
1917
|
.bot-page,.bot-top-row,.bot-main,.bot-main-content,.bot-projects-and-bots,.bot-side-col,.bot-list{min-width:0}
|
|
1914
1918
|
@media(max-width:1180px){.bot-top-row{grid-template-columns:minmax(0,1fr)}}
|
|
1919
|
+
|
|
1920
|
+
/* ════════════════════════════════════════════════════════════════════════════
|
|
1921
|
+
Mobile UX refinements (user feedback)
|
|
1922
|
+
════════════════════════════════════════════════════════════════════════════ */
|
|
1923
|
+
|
|
1924
|
+
/* [Hero] Dashboard already shows its title inside the hero card — drop the duplicate
|
|
1925
|
+
page-title header so it doesn't repeat "Dashboard vận hành" twice. */
|
|
1926
|
+
body[data-active-tab="dashboard"] .top{display:none}
|
|
1927
|
+
|
|
1928
|
+
/* [Modals] While any modal is open, hide the fixed bottom nav so it can't cover the modal's
|
|
1929
|
+
lower content (and footer buttons) on mobile. The backdrop owns the screen at that point. */
|
|
1930
|
+
body:has(.modal-backdrop) .bottom{display:none!important}
|
|
1931
|
+
/* Bot / Cài đặt hero buttons sit side-by-side — keep their labels on one line. */
|
|
1932
|
+
.dash-actions .icon-btn2{white-space:nowrap}
|
|
1933
|
+
|
|
1934
|
+
@media(max-width:760px){
|
|
1935
|
+
/* [Topbar] Keep search + theme + language on ONE row; theme becomes icon-only and the
|
|
1936
|
+
language globe icon is dropped so the whole bar stays compact. */
|
|
1937
|
+
.topbar{flex-direction:row!important;align-items:center!important;gap:8px;padding:8px 10px}
|
|
1938
|
+
.topbar__actions{width:auto!important;flex-wrap:nowrap!important;justify-content:flex-end!important;gap:6px}
|
|
1939
|
+
.topbar__actions .seg{padding:3px;gap:2px}
|
|
1940
|
+
.topbar__actions .seg[aria-label="theme"] .seg__btn span{display:none}
|
|
1941
|
+
.topbar__actions .seg[aria-label="theme"] .seg__btn{padding:8px 9px}
|
|
1942
|
+
.topbar__actions .seg[aria-label="lang"]>span{display:none!important}
|
|
1943
|
+
.topbar__actions .seg__btn{padding:6px 9px;font-size:11px}
|
|
1944
|
+
.search input{font-size:13px}
|
|
1945
|
+
|
|
1946
|
+
/* [Connect project] The action buttons were absolutely positioned and overlapped the
|
|
1947
|
+
path text. Put the card into normal flow with full-width buttons below the content.
|
|
1948
|
+
The card used overflow:hidden + a fixed min-height and the list capped at 260px with an
|
|
1949
|
+
inner scroll — all of which clipped the now-in-flow buttons. Let everything grow. */
|
|
1950
|
+
.detected-projects{max-height:none!important;overflow:visible!important}
|
|
1951
|
+
.detected-project{padding:12px!important;min-height:0!important;height:auto!important;overflow:visible!important}
|
|
1952
|
+
.detected-project__actions{position:static!important;transform:none!important;width:100%;margin-top:4px}
|
|
1953
|
+
.detected-project__actions .secondary{min-width:0!important;flex:1}
|
|
1954
|
+
.detected-project b{font-size:14px}
|
|
1955
|
+
.detected-project small{font-size:11px}
|
|
1956
|
+
.detected-project__meta{gap:6px}
|
|
1957
|
+
|
|
1958
|
+
/* [Connect project header] Title was squeezed to a 3-line column on the left with the two
|
|
1959
|
+
buttons crammed to the right. Stack it: full-width title on top, buttons full-width below. */
|
|
1960
|
+
.card-head:has(.existing-project-actions){flex-direction:column;align-items:stretch;gap:10px}
|
|
1961
|
+
.existing-project-actions{width:100%;flex-direction:column}
|
|
1962
|
+
.existing-project-actions .icon-btn2{width:100%;justify-content:center}
|
|
1963
|
+
|
|
1964
|
+
/* [Donate modal] The QR cards went full-width (one per row) and filled the screen. Cap each
|
|
1965
|
+
card so the QR is just big enough to scan comfortably, centered in the modal. */
|
|
1966
|
+
.donate-grid article{max-width:248px;width:100%;margin:0 auto}
|
|
1967
|
+
|
|
1968
|
+
/* [Skills/plugins] Mobile: tighter padding + smaller text (see the global feature-card design
|
|
1969
|
+
block below for the one-line name + inline toggle layout). */
|
|
1970
|
+
.feature-card{padding:14px}
|
|
1971
|
+
.feature-name{font-size:13.5px!important}
|
|
1972
|
+
.feature-desc{font-size:12px!important}
|
|
1973
|
+
|
|
1974
|
+
/* [Maintenance row] Restart / Rebuild / Grant-disk were locked to a 34px height in one row,
|
|
1975
|
+
so "Cấp quyền ổ đĩa" overflowed. Let them wrap and grow vertically. */
|
|
1976
|
+
.bot-docker-actions{flex-wrap:wrap}
|
|
1977
|
+
.bot-docker-actions button{height:auto!important;min-height:40px;white-space:normal;line-height:1.2;flex:1 1 92px}
|
|
1978
|
+
|
|
1979
|
+
/* [File tree] Give the tree a touch more room on mobile and let the editor breathe. */
|
|
1980
|
+
.file-tree{max-height:300px!important}
|
|
1981
|
+
.tree-editor{min-height:300px!important}
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
@media(max-width:520px){
|
|
1985
|
+
/* [Setup OS/mode] Shrink the logo cards: keep them 2-up (compact, less scrolling) with
|
|
1986
|
+
much smaller icons — just large enough to tap comfortably. */
|
|
1987
|
+
.os-grid,.mode-grid{grid-template-columns:repeat(2,minmax(0,1fr))!important}
|
|
1988
|
+
.logo-card{min-height:0!important;gap:8px!important;padding:12px 8px!important;border-radius:14px!important}
|
|
1989
|
+
.logo-card .choice-card__icon{width:40px!important;height:40px!important;border-radius:11px!important}
|
|
1990
|
+
.logo-card .choice-card__icon img{width:24px!important;height:24px!important}
|
|
1991
|
+
.logo-card .choice-card__body strong{font-size:13px!important}
|
|
1992
|
+
.logo-card .choice-card__body small{font-size:10px!important}
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
/* ── Feature (skill/plugin) card design ──────────────────────────────────────
|
|
1996
|
+
Name + a compact native-style toggle sit on ONE line; the name truncates with an ellipsis
|
|
1997
|
+
instead of ever wrapping to a second line. Description goes below, and the wider
|
|
1998
|
+
Open / Update / Install controls (when present) get their own row. */
|
|
1999
|
+
/* min-width:0 lets the card (a grid item, default min-width:auto) shrink below its nowrap
|
|
2000
|
+
content so the name can ellipsis-truncate instead of overflowing the row off-screen. */
|
|
2001
|
+
.feature-card{min-width:0}
|
|
2002
|
+
.feature-head{display:flex;align-items:center;justify-content:space-between;gap:10px;min-width:0}
|
|
2003
|
+
.feature-title{display:flex;align-items:center;gap:8px;min-width:0;flex:1 1 auto}
|
|
2004
|
+
.feature-name{display:block;font-size:15px;font-weight:800;line-height:1.2;margin:0;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
2005
|
+
.feature-desc{margin:8px 0 0;color:var(--body);font-size:13px;line-height:1.45}
|
|
2006
|
+
.feature-actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}
|
|
2007
|
+
/* Keep the Install / Update / Open buttons small & compact (in line with the toggle), so the
|
|
2008
|
+
"Cài đặt" button isn't the default oversized size. */
|
|
2009
|
+
.feature-actions .secondary,.feature-actions .icon-btn2{height:28px;min-height:0;padding:4px 11px;font-size:11px;font-weight:700;border-radius:8px;border-width:1px}
|
|
2010
|
+
.feature-actions svg{width:13px;height:13px}
|
|
2011
|
+
/* Smaller, native-app style on/off toggle (was 52×30). */
|
|
2012
|
+
.feature-switch span{width:42px;height:24px}
|
|
2013
|
+
.feature-switch span:after{top:2px;left:2px;width:18px;height:18px}
|
|
2014
|
+
.feature-switch input:checked + span:after{left:22px}
|