From 1ef5b45b5b91d0e6b049a602fa56d3dcbd579d99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?= Date: Wed, 5 Aug 2026 12:35:52 +0800 Subject: [PATCH] chore(release): prepare v0.19.2 --- .dockerignore | 6 + CHANGELOG.md | 31 +++ Dockerfile | 21 +- docker-compose.yml | 4 +- package-lock.json | 10 +- package.json | 2 +- scripts/dev-api.js | 268 +++++++++++++++++++--- scripts/linux-deploy.sh | 3 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/commands/hermes.rs | 55 +++-- src-tauri/tauri.conf.json | 2 +- src/engines/hermes/pages/chat.js | 28 ++- src/engines/hermes/pages/profiles.js | 35 +++ src/engines/hermes/style/hermes.css | 8 + src/lib/model-channels.js | 17 +- src/locales/modules/engine.js | 9 + src/locales/modules/models.js | 7 +- src/pages/models.js | 21 +- src/style/components.css | 16 ++ tests/dev-api-gateway-probe.test.js | 20 ++ tests/dev-api-models-sync.test.js | 117 ++++++++++ tests/docker-base-user.test.js | 29 +++ tests/hermes-profiles.test.js | 46 ++++ tests/linux-deploy-upgrade-policy.test.js | 4 + tests/model-channels-runtime.test.js | 27 +++ 26 files changed, 707 insertions(+), 83 deletions(-) create mode 100644 tests/dev-api-gateway-probe.test.js create mode 100644 tests/dev-api-models-sync.test.js create mode 100644 tests/docker-base-user.test.js create mode 100644 tests/hermes-profiles.test.js diff --git a/.dockerignore b/.dockerignore index 3546318..9f8014c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -44,6 +44,8 @@ docs/ !src-tauri/icons/*.ico *.icns !src-tauri/icons/*.icns +!public/ +!public/** # ----------------------------------------------------------------------------- # 测试文件 @@ -76,6 +78,10 @@ docs/ scripts/*.js scripts/*.sh !scripts/serve.js +!scripts/dev-api.js +!scripts/media-background-queue.js +!scripts/lib/ +!scripts/lib/** # ----------------------------------------------------------------------------- # CI/CD 配置 diff --git a/CHANGELOG.md b/CHANGELOG.md index fbb0464..1c05a5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,37 @@ ## [未发布 (Unreleased)] +## [0.19.2] - 2026-08-05 + +### 兼容性 (Compatibility) + +- **OpenClaw 新版 Web 配置同步恢复** — Web/headless 保存模型 Provider 后,会同步更新 `openclaw.json` 与每个 Agent 的 `models.json`,连接信息、模型能力、上下文长度和结构化密钥保持一致;运行中的 Gateway 会在回读确认后重启加载新配置 +- **Hermes Agent 新版 Profile 适配** — 同时兼容新版纯名称列表和旧版状态表格,Profile 页面可直接配置 Provider/模型,聊天页可在 Gateway 停止时直接启动 + +### 改进 (Improvements) + +- **LM Studio 上下文长度可编辑** — 模型编辑器新增 `contextTokens` 字段,不再固定为 `50000`;保存后同步到主配置和所有 Agent,并在刷新后正确回显 +- **Linux Web 自定义端口** — 原生部署支持 `CLAWPANEL_PORT` / `PANEL_PORT`,Docker 与 Compose 的监听端口和健康检查会跟随 `PORT` 配置 +- **Hermes Profile 移动端布局** — Profile 操作区支持自动换行,按钮满足移动端触控高度并消除横向溢出 + +### 修复 (Fixes) + +- **Web Gateway 探测返回 HTTP 500** — 移除 ESM 运行时残留的 `require('net')`,端口关闭时稳定返回 `false`,不再污染控制台或阻断模型保存流程 +- **Docker 镜像构建和运行失败** — 复用官方 Node 镜像内置用户,避免 Alpine UID/GID 1000 冲突;补齐 Web API、运行时模块和 `public` 静态资源,生产镜像可完整构建并加载页面资源 +- **Hermes Profile 信息缺失** — 修复新版 `profile list` 输出被旧表格解析器过滤的问题,并保持桌面端与 Web 端解析行为一致 + +### 安全 (Security) + +- **PostCSS 构建依赖升级** — 锁定到 `8.5.25`,修复受控 `sourceMappingURL` 在缺少 `from` 时可能读取任意 `.map` 文件的问题;`npm audit` 当前为 0 vulnerabilities + +### 测试与验证 (Testing) + +- `node --test tests/*.test.js`:537 passed +- `npm run build`:通过 +- `cargo fmt --all -- --check`、`cargo check --locked`、`cargo clippy --locked --all-targets -- -D warnings`、`cargo test --locked`:通过 +- 本地生产 Web Playwright:OpenClaw 核心路由、LM Studio 配置同步、Hermes Gateway/Profile 和移动端布局全部通过,页面错误、HTTP 500、失败请求和控制台错误均为 0 +- Linux Docker 实测:镜像构建、OpenClaw CLI、LM Studio `50000 → 196608` 写入/Agent 同步/刷新回读、自定义端口及健康检查全部通过 + ## [0.19.1] - 2026-07-24 ### 改进 (Improvements) diff --git a/Dockerfile b/Dockerfile index 6693b18..f0e55be 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,6 +31,7 @@ WORKDIR /build COPY package*.json ./ COPY vite.config.js ./ COPY index.html ./ +COPY public/ ./public/ COPY scripts/ ./scripts/ COPY src/ ./src/ @@ -54,18 +55,18 @@ RUN apk add --no-cache \ ENV TZ=Asia/Shanghai ENV NODE_ENV=production ENV HOME=/root +ENV PORT=1420 -# 创建非 root 用户 (可选,主要用于日志查看) -RUN addgroup -g 1000 appgroup && \ - adduser -u 1000 -G appgroup -s /bin/sh -D appuser +# 官方 Node 镜像已内置 node 用户(UID/GID 1000),直接复用,避免新版 Alpine 的 GID 冲突。 WORKDIR /app # 复制构建产物 -COPY --from=builder --chown=appuser:appgroup /build/dist ./dist -COPY --from=builder --chown=appuser:appgroup /build/scripts ./scripts -COPY --from=builder --chown=appuser:appgroup /build/package*.json ./ -COPY --from=builder --chown=appuser:appgroup /build/node_modules ./node_modules +COPY --from=builder --chown=node:node /build/dist ./dist +COPY --from=builder --chown=node:node /build/scripts ./scripts +COPY --from=builder --chown=node:node /build/package*.json ./ +COPY --from=builder --chown=node:node /build/node_modules ./node_modules +COPY --from=builder --chown=node:node /build/src/lib/model-presets.js ./src/lib/model-presets.js # 安装 OpenClaw CLI(用于读写配置) # 使用国内镜像源加速 @@ -74,18 +75,18 @@ RUN npm install -g @qingchencloud/openclaw-zh --registry https://registry.npmmir # 创建数据目录 RUN mkdir -p /app/data && \ - chown -R appuser:appgroup /app + chown -R node:node /app # 暴露端口 EXPOSE 1420 # 使用 root 用户运行(确保能管理 Gateway 等) -# 如需安全性,可切换到 appuser,但需确保卷挂载权限正确 +# 如需安全性,可切换到 node,但需确保卷挂载权限正确 USER root # 健康检查 HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ - CMD curl -f http://localhost:1420/ || exit 1 + CMD curl -f "http://localhost:${PORT:-1420}/__api/health" || exit 1 # 启动命令 CMD ["node", "scripts/serve.js"] diff --git a/docker-compose.yml b/docker-compose.yml index 92fc040..da1213e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -70,6 +70,8 @@ services: - TZ=Asia/Shanghai # 确保 HOME 环境变量正确 - HOME=/root + # Web 监听端口;可在启动前设置 CLAWPANEL_PORT 覆盖 + - PORT=${CLAWPANEL_PORT:-1420} # ------------------------------------------------------------------------- # 重启策略 @@ -80,7 +82,7 @@ services: # 健康检查 # ------------------------------------------------------------------------- healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:1420/"] + test: ["CMD-SHELL", "curl -f \"http://localhost:$${PORT:-1420}/__api/health\" || exit 1"] interval: 30s timeout: 5s retries: 3 diff --git a/package-lock.json b/package-lock.json index b0be2a2..44aee0c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "clawpanel", - "version": "0.19.1", + "version": "0.19.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clawpanel", - "version": "0.19.1", + "version": "0.19.2", "license": "AGPL-3.0", "dependencies": { "@tauri-apps/api": "^2.11.1", @@ -1178,9 +1178,9 @@ } }, "node_modules/postcss": { - "version": "8.5.22", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz", - "integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==", + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", "dev": true, "funding": [ { diff --git a/package.json b/package.json index c70719e..bbe2a4d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "clawpanel", - "version": "0.19.1", + "version": "0.19.2", "private": true, "description": "ClawPanel - OpenClaw 可视化管理面板,基于 Tauri v2 的跨平台桌面应用", "type": "module", diff --git a/scripts/dev-api.js b/scripts/dev-api.js index e3dfa7c..4e4fdbb 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -19,6 +19,22 @@ import { createBackgroundJobQueue } from './media-background-queue.js' import { normalizeModelApiType } from '../src/lib/model-presets.js' const DOCKER_TASK_TIMEOUT_MS = 10 * 60 * 1000 +export function probeTcpPort(port, host = '127.0.0.1', timeoutMs = 3000) { + return new Promise(resolve => { + let settled = false + const socket = net.createConnection({ host, port, timeout: timeoutMs }) + const finish = reachable => { + if (settled) return + settled = true + socket.destroy() + resolve(reachable) + } + socket.once('connect', () => finish(true)) + socket.once('error', () => finish(false)) + socket.once('timeout', () => finish(false)) + }) +} + // --------------------------------------------------------------------------- // Hermes Agent — 路径 / 工具函数 // --------------------------------------------------------------------------- @@ -116,6 +132,69 @@ function hermesHome() { return process.env.HERMES_HOME || HERMES_HOME } +const HERMES_PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/ + +function stripAnsi(text) { + return String(text || '').replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '') +} + +/** + * 兼容 Hermes 新旧版本的 profile list 输出。 + * 新版只输出 profile 名称并用 * 标记当前项,旧版还会输出模型和 Gateway 状态。 + */ +export function parseHermesProfileListOutput(output = '') { + const profiles = [] + const byName = new Map() + let active = 'default' + + for (const line of String(output || '').split(/\r?\n/)) { + let row = stripAnsi(line).trim() + if (!row || /^[-─=]+$/.test(row)) continue + if (/^(?:available\s+)?profiles?:?$/i.test(row) || /^(?:name|profile)\s+/i.test(row)) continue + + const isActive = /^[*◆]/.test(row) + if (isActive) row = row.slice(1).trim() + + const parts = row.split(/\s+/) + const name = parts[0] + if (!name || !HERMES_PROFILE_NAME_RE.test(name)) continue + + const gatewayIdx = parts.findIndex(part => part === 'running' || part === 'stopped') + const hasLegacyMeta = gatewayIdx >= 1 + const model = gatewayIdx > 1 ? parts.slice(1, gatewayIdx).join(' ') : '' + const alias = hasLegacyMeta ? (parts[gatewayIdx + 1] || '') : '' + const profile = byName.get(name) + + if (isActive) active = name + if (profile) { + profile.active ||= isActive + if (!profile.model && model && model !== '—') profile.model = model + if (!profile.alias && alias && alias !== '—') profile.alias = alias + profile.gatewayRunning ||= hasLegacyMeta && parts[gatewayIdx] === 'running' + continue + } + + const next = { + name, + active: isActive, + model: model === '—' ? '' : model, + gatewayRunning: hasLegacyMeta && parts[gatewayIdx] === 'running', + alias: alias === '—' ? '' : alias, + } + profiles.push(next) + byName.set(name, next) + } + + if (!profiles.some(profile => profile.active)) { + const defaultProfile = profiles.find(profile => profile.name === 'default') + if (defaultProfile) { + defaultProfile.active = true + active = 'default' + } + } + return { active, profiles } +} + export function validateHermesConfigYamlText(yamlText = '') { const raw = String(yamlText || '') if (!raw.trim()) return {} @@ -3109,12 +3188,163 @@ function validateOpenclawModelCandidate(config) { } } +function modelEntryId(entry) { + if (typeof entry === 'string') return entry.trim() + return String(entry?.id || '').trim() +} + +function cloneJsonValue(value) { + return value === undefined ? undefined : JSON.parse(JSON.stringify(value)) +} + +function syncProviderModels(dstProvider, srcProvider) { + if (!Array.isArray(srcProvider?.models)) return false + if (!Array.isArray(dstProvider.models)) { + dstProvider.models = cloneJsonValue(srcProvider.models) + return true + } + + let changed = false + const dstIndexes = new Map() + dstProvider.models.forEach((entry, index) => { + const id = modelEntryId(entry) + if (id && !dstIndexes.has(id)) dstIndexes.set(id, index) + }) + + for (const srcModel of srcProvider.models) { + const id = modelEntryId(srcModel) + if (!id) continue + const index = dstIndexes.get(id) + if (index === undefined) { + dstProvider.models.push(cloneJsonValue(srcModel)) + dstIndexes.set(id, dstProvider.models.length - 1) + changed = true + continue + } + + // openclaw.json 是面板的模型配置来源;同步同 ID 模型的能力元数据, + // 尤其是 contextWindow/contextTokens,避免 Agent 继续使用旧的 50000 上限。 + const dstModel = dstProvider.models[index] + const nextModel = srcModel && typeof srcModel === 'object' && !Array.isArray(srcModel) + && dstModel && typeof dstModel === 'object' && !Array.isArray(dstModel) + ? { ...dstModel, ...cloneJsonValue(srcModel) } + : cloneJsonValue(srcModel) + if (JSON.stringify(nextModel) !== JSON.stringify(dstModel)) { + dstProvider.models[index] = nextModel + changed = true + } + } + return changed +} + +function syncProviderModelOverrides(dstProvider, srcProvider) { + const overrides = dstProvider?.modelOverrides + if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) return false + if (!Array.isArray(srcProvider?.models)) return false + + let changed = false + for (const srcModel of srcProvider.models) { + if (!srcModel || typeof srcModel !== 'object' || Array.isArray(srcModel)) continue + const id = modelEntryId(srcModel) + const override = id ? overrides[id] : null + if (!override || typeof override !== 'object' || Array.isArray(override)) continue + for (const field of ['contextWindow', 'contextTokens', 'maxTokens']) { + if (!Object.hasOwn(srcModel, field)) continue + if (JSON.stringify(override[field]) !== JSON.stringify(srcModel[field])) { + override[field] = cloneJsonValue(srcModel[field]) + changed = true + } + } + } + return changed +} + +/** + * Web 模式下把全局 providers 同步到 OpenClaw 每个 Agent 的运行时注册表。 + * OpenClaw 会优先读取 agents//agent/models.json;只写 openclaw.json 会导致 + * Web 面板显示已保存,但 Gateway 仍使用旧的 Provider、模型能力和上下文上限。 + */ +export function syncProvidersToAgentModels(config, openclawDir = OPENCLAW_DIR) { + const srcProviders = config?.models?.providers + if (!srcProviders || typeof srcProviders !== 'object' || Array.isArray(srcProviders)) { + return { updated: [], skipped: [] } + } + + const agentIds = ['main'] + for (const agent of Array.isArray(config?.agents?.list) ? config.agents.list : []) { + const id = String(agent?.id || '').trim() + if (id && id !== 'main') agentIds.push(id) + } + + const result = { updated: [], skipped: [] } + const agentsDir = path.join(openclawDir, 'agents') + for (const agentId of agentIds) { + const modelsPath = path.join(agentsDir, agentId, 'agent', 'models.json') + if (!fs.existsSync(modelsPath)) continue + + let modelsJson + try { + modelsJson = JSON.parse(fs.readFileSync(modelsPath, 'utf8')) + } catch { + result.skipped.push({ path: modelsPath, reason: 'invalid-json' }) + continue + } + if (!modelsJson || typeof modelsJson !== 'object' || Array.isArray(modelsJson)) { + result.skipped.push({ path: modelsPath, reason: 'invalid-root' }) + continue + } + + let changed = false + if (!modelsJson.providers || typeof modelsJson.providers !== 'object' || Array.isArray(modelsJson.providers)) { + modelsJson.providers = {} + changed = true + } + + const dstProviders = modelsJson.providers + for (const providerName of Object.keys(dstProviders)) { + if (!Object.hasOwn(srcProviders, providerName)) { + delete dstProviders[providerName] + changed = true + } + } + + for (const [providerName, srcProvider] of Object.entries(srcProviders)) { + if (!srcProvider || typeof srcProvider !== 'object' || Array.isArray(srcProvider)) continue + const dstProvider = dstProviders[providerName] + if (!dstProvider || typeof dstProvider !== 'object' || Array.isArray(dstProvider)) { + dstProviders[providerName] = cloneJsonValue(srcProvider) + changed = true + continue + } + + // 同步连接信息及结构化 SecretRef;源配置缺少字段时保留 Agent 现有值。 + for (const field of ['baseUrl', 'apiKey', 'api']) { + if (!Object.hasOwn(srcProvider, field)) continue + const srcValue = srcProvider[field] + if (JSON.stringify(dstProvider[field]) !== JSON.stringify(srcValue)) { + dstProvider[field] = cloneJsonValue(srcValue) + changed = true + } + } + if (syncProviderModels(dstProvider, srcProvider)) changed = true + if (syncProviderModelOverrides(dstProvider, srcProvider)) changed = true + } + + if (changed) { + writeJsonAtomic(modelsPath, modelsJson) + result.updated.push(modelsPath) + } + } + return result +} + function writeOpenclawConfigFile(config) { const cleaned = stripUiFields(config) const previous = fs.existsSync(CONFIG_PATH) ? readJsonFileRelaxed(CONFIG_PATH) : null validateModelProviderEnvRefs(cleaned, previous) validateOpenclawModelCandidate(cleaned) writeJsonAtomic(CONFIG_PATH, cleaned, { backup: true }) + syncProvidersToAgentModels(cleaned) } function ensureAgentsList(config) { @@ -12460,13 +12690,7 @@ const handlers = { async probe_gateway_port() { const port = readGatewayPort() - return new Promise(resolve => { - const net = require('net') - const sock = net.createConnection({ host: '127.0.0.1', port, timeout: 3000 }) - sock.on('connect', () => { sock.destroy(); resolve(true) }) - sock.on('error', () => resolve(false)) - sock.on('timeout', () => { sock.destroy(); resolve(false) }) - }) + return probeTcpPort(port) }, // @homebridge/ciao windowsHide bug — Windows only. Linux/macOS stubs return false. @@ -17023,35 +17247,7 @@ const handlers = { hermes_profiles_list() { const r = runHermesSilent('hermes', ['profile', 'list']) if (!r.ok) return { active: 'default', profiles: [] } - let active = 'default' - const profiles = [] - for (const line of r.stdout.split('\n')) { - const trimmed = line.trim() - if (!trimmed || trimmed.includes('Profile') || trimmed.startsWith('─') || trimmed.startsWith('-')) continue - const isActive = trimmed.startsWith('◆') - const row = trimmed.replace(/^◆/, '').trim() - const parts = row.split(/\s+/) - if (parts.length < 3) continue - const name = parts[0] - if (name !== 'default' && !/^[a-z0-9][a-z0-9_-]{0,63}$/.test(name)) continue - const gatewayIdx = parts.findIndex(p => p === 'running' || p === 'stopped') - if (gatewayIdx <= 1) continue - const model = parts.slice(1, gatewayIdx).join(' ') - const alias = parts[gatewayIdx + 1] || '' - if (isActive) active = name - profiles.push({ - name, - active: isActive, - model: model === '—' ? '' : model, - gatewayRunning: parts[gatewayIdx] === 'running', - alias: alias === '—' ? '' : alias, - }) - } - if (!profiles.some(p => p.active)) { - const d = profiles.find(p => p.name === 'default') - if (d) d.active = true - } - return { active, profiles } + return parseHermesProfileListOutput(r.stdout) }, hermes_profile_use({ name } = {}) { diff --git a/scripts/linux-deploy.sh b/scripts/linux-deploy.sh index 2254bf4..83761d4 100644 --- a/scripts/linux-deploy.sh +++ b/scripts/linux-deploy.sh @@ -7,7 +7,8 @@ echo " 在 Linux 上通过浏览器管理 OpenClaw" echo "==========================================" echo "" -PANEL_PORT=1420 +# 可通过 CLAWPANEL_PORT 或 PANEL_PORT 为 Web 面板选择未占用端口。 +PANEL_PORT="${CLAWPANEL_PORT:-${PANEL_PORT:-1420}}" REPO_URL="https://github.com/qingchencloud/clawpanel.git" REPO_URL_GITEE="https://gitee.com/QtCodeCreators/clawpanel.git" DEPLOY_SCRIPT_URL="https://raw.githubusercontent.com/qingchencloud/clawpanel/main/scripts/linux-deploy.sh" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 82a4254..38b11d2 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -366,7 +366,7 @@ dependencies = [ [[package]] name = "clawpanel" -version = "0.19.1" +version = "0.19.2" dependencies = [ "base64 0.22.1", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index da27722..af43ad3 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "clawpanel" -version = "0.19.1" +version = "0.19.2" edition = "2021" description = "ClawPanel - OpenClaw 可视化管理面板" authors = ["qingchencloud"] diff --git a/src-tauri/src/commands/hermes.rs b/src-tauri/src/commands/hermes.rs index 161494f..47c2581 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -16555,37 +16555,54 @@ pub async fn hermes_profiles_list() -> Result { let mut profiles: Vec = Vec::new(); for line in output.lines() { let trimmed = line.trim(); + let lower = trimmed.to_ascii_lowercase(); if trimmed.is_empty() - || trimmed.contains("Profile") || trimmed.starts_with('─') || trimmed.starts_with('-') + || lower == "profiles:" + || lower == "available profiles:" + || lower.starts_with("name ") + || lower.starts_with("profile ") { continue; } - let is_active = trimmed.starts_with('◆'); - let row = trimmed.trim_start_matches('◆').trim(); + let is_active = trimmed.starts_with('◆') || trimmed.starts_with('*'); + let row = if is_active { + let marker_len = trimmed.chars().next().unwrap().len_utf8(); + trimmed[marker_len..].trim() + } else { + trimmed + }; let parts: Vec<&str> = row.split_whitespace().collect(); - if parts.len() < 3 { + if parts.is_empty() { continue; } let name = parts[0]; - if name != "default" - && !name - .chars() - .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-') - { + let valid_name = name.chars().enumerate().all(|(index, c)| { + if index == 0 { + c.is_ascii_lowercase() || c.is_ascii_digit() + } else { + c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-' + } + }); + if !valid_name { continue; } let gateway_idx = parts .iter() - .position(|p| *p == "running" || *p == "stopped") - .unwrap_or(2); - if gateway_idx <= 1 || gateway_idx >= parts.len() { - continue; - } - let model = parts[1..gateway_idx].join(" "); - let gateway = parts[gateway_idx]; - let alias = parts.get(gateway_idx + 1).copied().unwrap_or("—"); + .position(|p| *p == "running" || *p == "stopped"); + let (model, gateway_running, alias) = match gateway_idx { + Some(index) if index >= 1 => ( + if index > 1 { + parts[1..index].join(" ") + } else { + String::new() + }, + parts[index] == "running", + parts.get(index + 1).copied().unwrap_or("—").to_string(), + ), + _ => (String::new(), false, String::new()), + }; if is_active { active = name.to_string(); } @@ -16593,8 +16610,8 @@ pub async fn hermes_profiles_list() -> Result { "name": name, "active": is_active, "model": if model == "—" { "" } else { &model }, - "gatewayRunning": gateway == "running", - "alias": if alias == "—" { "" } else { alias }, + "gatewayRunning": gateway_running, + "alias": if alias == "—" { "" } else { &alias }, })); } if !profiles diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index c142ad6..b989d95 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json", "productName": "ClawPanel", - "version": "0.19.1", + "version": "0.19.2", "identifier": "ai.openclaw.clawpanel", "build": { "frontendDist": "../dist", diff --git a/src/engines/hermes/pages/chat.js b/src/engines/hermes/pages/chat.js index bb73baa..bbae113 100644 --- a/src/engines/hermes/pages/chat.js +++ b/src/engines/hermes/pages/chat.js @@ -287,6 +287,7 @@ export function render() { let showSlash = false let slashFilter = '' let gwOnline = false + let gatewayStarting = false // null = 仍在加载首次 check,先不显示 banner 防首屏闪烁 let hermesInstalled = null let currentModel = '' @@ -774,7 +775,7 @@ export function render() { ` } - // 健康状态 banner:未装/未启动 → 在输入区上方显示一条警告 + 「去仪表盘」按钮。 + // 健康状态 banner:未装/未启动 → 在输入区上方显示警告和可执行操作。 // 首次 fetch 完成前返回空字符串,避免首屏闪烁。 function renderHealthBanner() { if (hermesInstalled === null) return '' @@ -792,6 +793,9 @@ export function render() {
${escHtml(t('engine.chatHealthGatewayDown'))} + ${escHtml(t('engine.chatHealthGoDashboard'))}
` @@ -874,6 +878,27 @@ export function render() { // ----------------------------------------------------------- event binding + async function startGateway() { + if (gatewayStarting) return + gatewayStarting = true + draw() + try { + await api.hermesGatewayAction('start') + invalidate('check_hermes') + const info = await api.checkHermes() + if (!info?.gatewayRunning) throw new Error(t('engine.gatewayStartFailed')) + hermesInstalled = !!info?.installed + gwOnline = true + currentModel = info?.model || currentModel + toast(t('engine.gatewayRunning', { port: info?.gatewayPort || 8642 }), 'success') + } catch (e) { + toast(humanizeError(e, t('engine.gatewayStartFailed')), 'error') + } finally { + gatewayStarting = false + draw() + } + } + function toggleSelected(sid) { if (!sid) return if (selected.has(sid)) selected.delete(sid) @@ -882,6 +907,7 @@ export function render() { } function bind() { + el.querySelector('#hm-chat-start-gateway')?.addEventListener('click', startGateway) // --- Sidebar header --- el.querySelector('.hm-chat-new-btn')?.addEventListener('click', () => { store.newChat() diff --git a/src/engines/hermes/pages/profiles.js b/src/engines/hermes/pages/profiles.js index de013b7..550ee83 100644 --- a/src/engines/hermes/pages/profiles.js +++ b/src/engines/hermes/pages/profiles.js @@ -6,6 +6,7 @@ * - POST /api/profiles { name, clone_from_default, no_skills } - 创建 * - PATCH /api/profiles/{name} { new_name } - 重命名 * - DELETE /api/profiles/{name} - 删除 + * - PUT /api/profiles/{name}/model { provider, model } - 配置模型 * * 切换 active profile 仍走现有 chat-store.switchProfile(CLI 实现), * 因为 dashboard server 绑定的 active profile 改变后还需要重启 dashboard。 @@ -80,14 +81,20 @@ export function render() { function renderProfileCard(p) { const isActive = !!p.active const desc = p.description ? `
${escHtml(p.description)}
` : '' + const provider = p.raw?.provider || '' + const model = p.raw?.model || '' + const modelText = model ? `${provider ? `${provider}/` : ''}${model}` : t('engine.hermesProfileModelUnset') + const modelMeta = `
${escHtml(t('engine.hermesProfileModelLabel'))}: ${escHtml(modelText)}
` return `
${escHtml(p.name)}
${isActive ? `${escHtml(t('engine.hermesProfileActive'))}` : ''}
+ ${modelMeta} ${desc}
+ ${isActive ? '' : ``} ${isActive ? '' : ``} @@ -104,6 +111,7 @@ export function render() { const name = btn.dataset.name btn.addEventListener('click', () => { if (action === 'switch') onSwitch(name) + else if (action === 'configure') onConfigure(name) else if (action === 'rename') onRename(name) else if (action === 'delete') onDelete(name) }) @@ -170,6 +178,33 @@ export function render() { }) } + function onConfigure(name) { + const profile = profiles.find(item => item.name === name) + const raw = profile?.raw || {} + showModal({ + title: t('engine.hermesProfileConfigureTitle', { name }), + fields: [ + { name: 'provider', label: t('engine.hermesProfileProviderLabel'), value: raw.provider || '', placeholder: 'lmstudio, anthropic, openai, ...' }, + { name: 'model', label: t('engine.hermesProfileModelLabel'), value: raw.model || '', placeholder: 'model-name' }, + ], + onConfirm: async (data) => { + const provider = (data.provider || '').trim() + const model = (data.model || '').trim() + if (!provider || !model) { + toast(t('engine.hermesProfileModelRequired'), 'error') + return + } + try { + await api.hermesDashboardApi('PUT', `/api/profiles/${encodeURIComponent(name)}/model`, { provider, model }) + toast(t('engine.hermesProfileModelSaved', { name }), 'success') + await load() + } catch (e) { + toast(humanizeError(e, t('engine.hermesProfileModelSaveFailed')), 'error') + } + }, + }) + } + function onRename(name) { showModal({ title: t('engine.hermesProfileRenameTitle', { name }), diff --git a/src/engines/hermes/style/hermes.css b/src/engines/hermes/style/hermes.css index efd23dc..b27fa16 100644 --- a/src/engines/hermes/style/hermes.css +++ b/src/engines/hermes/style/hermes.css @@ -4624,6 +4624,14 @@ body[data-active-engine="hermes"][data-theme="dark"] { background: currentColor; color: var(--hm-surface-0); } +[data-engine="hermes"] button.hm-chat-health-action { + cursor: pointer; + font-family: inherit; +} +[data-engine="hermes"] button.hm-chat-health-action:disabled { + cursor: wait; + opacity: 0.65; +} /* ---- Messages area ---- */ [data-engine="hermes"] .hm-chat-messages { diff --git a/src/lib/model-channels.js b/src/lib/model-channels.js index 5a73a1c..ef365f7 100644 --- a/src/lib/model-channels.js +++ b/src/lib/model-channels.js @@ -8,7 +8,7 @@ * - 助手:一次性拷贝到 localStorage(clawpanel-assistant) * 本模块自身不直接写任何引擎配置文件。 */ -import { api } from './tauri-api.js' +import { api, isTauriRuntime } from './tauri-api.js' import { normalizeModelApiType } from './model-presets.js' export const ASSISTANT_STORAGE_KEY = 'clawpanel-assistant' @@ -90,6 +90,18 @@ function matchesSubset(expected, actual) { return Object.is(expected, actual) } +async function applyOpenclawModelConfig() { + if (isTauriRuntime()) { + await api.reloadGateway() + return + } + + // Web 版没有 Tauri 的 native config reload;同步 models.json 后, + // 运行中的 Gateway 需要完整重启才能重新加载 Agent 模型注册表。 + const running = await api.probeGatewayPort().catch(() => false) + if (running) await api.restartGateway() +} + /** * 同步到 OpenClaw:只 upsert 渠道对应的 models.providers.{key}, * 展开旧对象保留未知字段;渠道模型为准但保留目标已有模型的测试元数据。 @@ -135,7 +147,7 @@ export async function syncChannelToOpenclaw(channel, { setDefault = false } = {} patch.agents = { defaults: { model: { primary: `${providerKey}/${channel.defaultModel}` } } } } - await api.writeOpenclawConfig(patch) + await api.writeOpenclawConfig(patch, { noReload: true }) const readback = asObject(await api.readOpenclawConfig()) const savedProvider = asObject(asObject(readback.models).providers)[providerKey] const expectedProvider = { @@ -154,6 +166,7 @@ export async function syncChannelToOpenclaw(channel, { setDefault = false } = {} throw new Error(`OpenClaw 默认模型写入后回读核对失败: ${providerKey}/${channel.defaultModel}`) } } + await applyOpenclawModelConfig() return { providerKey, modelCount: models.length, verified: true } } diff --git a/src/locales/modules/engine.js b/src/locales/modules/engine.js index e0e75f9..96347c3 100644 --- a/src/locales/modules/engine.js +++ b/src/locales/modules/engine.js @@ -363,6 +363,7 @@ export default { 'Hermes Gateway is not running, messages cannot be sent. Please start the Gateway on the dashboard.', 'Hermes Gateway 未啟動,無法發送訊息。請在儀表板啟動 Gateway。' ), + chatHealthStartGateway: _('启动 Gateway', 'Start Gateway', '啟動 Gateway'), chatHealthGoDashboard: _('去仪表盘', 'Open Dashboard', '去儀表板'), chatWelcome: _('你好!我是 Hermes Agent,有什么可以帮你的?', 'Hello! I\'m Hermes Agent, how can I help?', '你好!我是 Hermes Agent,有什麼可以幫你的?'), chatEmptyHint: _('开始一段对话吧', 'Start a conversation', '開始一段對話吧'), @@ -1315,6 +1316,14 @@ export default { hermesProfileNameRequired: _('名称不能为空', 'Name is required', '名稱不能為空'), hermesProfileCloneFromDefault: _('从默认 Profile 复制(推荐)', 'Clone from default (recommended)', '從預設 Profile 複製(推薦)'), hermesProfileCloneHint: _('打勾会复制 default 的模型、技能、记忆。不勾会创建空白 Profile。', 'Checked: clone default\'s models / skills / memory. Unchecked: empty profile.', '勾選會複製 default 的模型、技能、記憶。不勾建立空白 Profile。'), + hermesProfileConfigure: _('配置模型', 'Configure model', '配置模型'), + hermesProfileConfigureTitle: _('配置 Profile 模型:{name}', 'Configure profile model: {name}', '配置 Profile 模型:{name}'), + hermesProfileProviderLabel: _('服务商', 'Provider', '服務商'), + hermesProfileModelLabel: _('模型', 'Model', '模型'), + hermesProfileModelUnset: _('未配置', 'Not configured', '未配置'), + hermesProfileModelRequired: _('服务商和模型不能为空', 'Provider and model are required', '服務商和模型不能為空'), + hermesProfileModelSaved: _('Profile "{name}" 模型已保存', 'Model saved for profile "{name}"', 'Profile "{name}" 模型已儲存'), + hermesProfileModelSaveFailed: _('模型保存失败', 'Failed to save model', '模型儲存失敗'), hermesProfileSwitch: _('切换到此', 'Switch to', '切換到此'), hermesProfileSwitched: _('已切换到 {name}', 'Switched to {name}', '已切換到 {name}'), hermesProfileSwitchFailed: _('切换失败', 'Switch failed', '切換失敗'), diff --git a/src/locales/modules/models.js b/src/locales/modules/models.js index 98daa9b..f789586 100644 --- a/src/locales/modules/models.js +++ b/src/locales/modules/models.js @@ -159,15 +159,18 @@ export default { displayName: _('显示名称(选填)', 'Display Name (optional)', '顯示名稱(選填)'), displayNamePlaceholder: _('如 GPT-4o', 'e.g. GPT-4o'), displayNameHint: _('方便识别的友好名称', 'A friendly name for identification', '方便識別的友好名稱'), - contextLength: _('上下文长度(选填)', 'Context Length (optional)', '上下文長度(選填)', 'コンテキスト長'), + contextLength: _('原生上下文窗口(选填)', 'Native Context Window (optional)', '原生上下文視窗(選填)', 'ネイティブコンテキスト長'), contextLengthPlaceholder: _('如 128000', 'e.g. 128000'), contextLengthHint: _('模型支持的最大 Token 数', 'Maximum token count supported by the model', '模型支援的最大 Token 數'), + contextTokensLabel: _('运行时上下文上限(选填)', 'Runtime Context Cap (optional)', '運行時上下文上限(選填)'), + contextTokensPlaceholder: _('如 50000;留空跟随模型窗口', 'e.g. 50000; leave empty to follow the model window', '如 50000;留空跟隨模型視窗'), + contextTokensHint: _('OpenClaw 实际会话预算;设置后会同步到 Agent 运行时注册表', 'OpenClaw runtime conversation budget; synced to the Agent runtime registry', 'OpenClaw 實際會話預算;設定後會同步到 Agent 執行時註冊表'), isReasoning: _('这是推理模型(如 o3、R1、QwQ 等)', 'This is a reasoning model (e.g. o3, R1, QwQ)'), reasoningHint: _('推理模型会使用特殊的调用方式', 'Reasoning models use special invocation methods', '推理模型會使用特殊的呼叫方式'), modelAdded: _('已添加模型: {name}', 'Model added: {name}', '已新增模型: {name}'), editModelTitle: _('编辑模型: {name}', 'Edit Model: {name}', '編輯模型: {name}'), displayNameLabel: _('显示名称', 'Display Name', '顯示名稱'), - contextLengthLabel: _('上下文长度', 'Context Length', '上下文長度'), + contextLengthLabel: _('原生上下文窗口', 'Native Context Window', '原生上下文視窗'), isReasoningLabel: _('这是推理模型', 'Reasoning model'), modelUpdated: _('模型已更新', 'Model updated'), stopBatchTest: _('终止测试', 'Stop Test', '終止測試'), diff --git a/src/pages/models.js b/src/pages/models.js index f7ffbd5..00ee441 100644 --- a/src/pages/models.js +++ b/src/pages/models.js @@ -1618,6 +1618,7 @@ function addModel(page, state, providerKey) { { name: 'id', label: t('models.modelId'), placeholder: t('models.modelIdPlaceholder'), hint: t('models.modelIdHint') }, { name: 'name', label: t('models.displayName'), placeholder: t('models.displayNamePlaceholder'), hint: t('models.displayNameHint') }, { name: 'contextWindow', label: t('models.contextLength'), placeholder: t('models.contextLengthPlaceholder'), hint: t('models.contextLengthHint') }, + { name: 'contextTokens', label: t('models.contextTokensLabel'), placeholder: t('models.contextTokensPlaceholder'), hint: t('models.contextTokensHint') }, { name: 'reasoning', label: t('models.isReasoning'), type: 'checkbox', value: false, hint: t('models.reasoningHint') }, ] @@ -1738,11 +1739,21 @@ function doAddModel(state, providerKey, vals) { reasoning: !!vals.reasoning, input: ['text', 'image'], } - if (vals.contextWindow) model.contextWindow = parseInt(vals.contextWindow) || 0 + const contextWindow = parseOptionalPositiveInteger(vals.contextWindow) + const contextTokens = parseOptionalPositiveInteger(vals.contextTokens) + if (contextWindow) model.contextWindow = contextWindow + if (contextTokens) model.contextTokens = contextTokens state.config.models.providers[providerKey].models.push(model) toast(t('models.modelAdded', { name: model.name }), 'success') } +function parseOptionalPositiveInteger(value) { + const raw = String(value ?? '').trim() + if (!raw) return null + const parsed = Number(raw) + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null +} + // 编辑模型 function editModel(page, state, providerKey, idx) { const m = state.config.models.providers[providerKey].models[idx] @@ -1752,6 +1763,7 @@ function editModel(page, state, providerKey, idx) { { name: 'id', label: t('models.modelId'), value: m.id || '', hint: t('models.modelIdHint') }, { name: 'name', label: t('models.displayNameLabel'), value: m.name || '', hint: t('models.displayNameHint') }, { name: 'contextWindow', label: t('models.contextLengthLabel'), value: String(m.contextWindow || ''), hint: t('models.contextLengthHint') }, + { name: 'contextTokens', label: t('models.contextTokensLabel'), value: String(m.contextTokens || ''), hint: t('models.contextTokensHint') }, { name: 'reasoning', label: t('models.isReasoningLabel'), type: 'checkbox', value: !!m.reasoning, hint: t('models.reasoningHint') }, ], onConfirm: (vals) => { @@ -1760,7 +1772,12 @@ function editModel(page, state, providerKey, idx) { m.id = vals.id.trim() m.name = vals.name?.trim() || vals.id.trim() m.reasoning = !!vals.reasoning - if (vals.contextWindow) m.contextWindow = parseInt(vals.contextWindow) || 0 + const contextWindow = parseOptionalPositiveInteger(vals.contextWindow) + const contextTokens = parseOptionalPositiveInteger(vals.contextTokens) + if (contextWindow) m.contextWindow = contextWindow + else delete m.contextWindow + if (contextTokens) m.contextTokens = contextTokens + else delete m.contextTokens renderProviders(page, state) renderDefaultBar(page, state) updateUndoBtn(page, state) diff --git a/src/style/components.css b/src/style/components.css index 466043f..caa3142 100644 --- a/src/style/components.css +++ b/src/style/components.css @@ -591,6 +591,22 @@ mark { justify-content: flex-end; } +@media (max-width: 640px) { + .lazy-deps-grid { + grid-template-columns: minmax(0, 1fr); + } + + .lazy-deps-card-actions { + flex-wrap: wrap; + justify-content: stretch; + } + + .lazy-deps-card-actions .btn { + flex: 1 1 auto; + min-height: 40px; + } +} + .lazy-deps-badge { font-size: 11px; padding: 2px 8px; diff --git a/tests/dev-api-gateway-probe.test.js b/tests/dev-api-gateway-probe.test.js new file mode 100644 index 0000000..78d6191 --- /dev/null +++ b/tests/dev-api-gateway-probe.test.js @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict' +import net from 'node:net' +import test from 'node:test' + +import { probeTcpPort } from '../scripts/dev-api.js' + +test('Web Gateway 端口探测在 ESM 运行时返回布尔值', async () => { + const server = net.createServer(socket => socket.end()) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + + const address = server.address() + assert.equal(typeof address, 'object') + assert.equal(await probeTcpPort(address.port, '127.0.0.1', 1000), true) + + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())) + assert.equal(await probeTcpPort(address.port, '127.0.0.1', 1000), false) +}) diff --git a/tests/dev-api-models-sync.test.js b/tests/dev-api-models-sync.test.js new file mode 100644 index 0000000..5f25471 --- /dev/null +++ b/tests/dev-api-models-sync.test.js @@ -0,0 +1,117 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import { syncProvidersToAgentModels } from '../scripts/dev-api.js' + +function writeAgentModels(root, agentId, value) { + const file = path.join(root, 'agents', agentId, 'agent', 'models.json') + fs.mkdirSync(path.dirname(file), { recursive: true }) + fs.writeFileSync(file, JSON.stringify(value, null, 2)) + return file +} + +function readJson(file) { + return JSON.parse(fs.readFileSync(file, 'utf8')) +} + +test('Web API 同步 providers 时会更新每个 Agent 的连接信息和上下文元数据', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'clawpanel-models-sync-')) + try { + const oldModels = { + providers: { + lmstudio: { + baseUrl: 'http://127.0.0.1:1234/v1-old', + apiKey: 'old-key', + api: 'openai-completions', + models: [{ + id: 'qwen-local', + name: 'Qwen Local', + contextWindow: 50000, + contextTokens: 50000, + maxTokens: 4096, + customRuntimeFlag: true, + }], + modelOverrides: { + 'qwen-local': { contextWindow: 50000, contextTokens: 50000 }, + }, + }, + stale: { baseUrl: 'http://stale.example/v1', models: [{ id: 'old' }] }, + }, + } + const mainPath = writeAgentModels(tmp, 'main', oldModels) + const workerPath = writeAgentModels(tmp, 'worker', oldModels) + + const result = syncProvidersToAgentModels({ + agents: { list: [{ id: 'worker' }] }, + models: { + providers: { + lmstudio: { + baseUrl: 'http://127.0.0.1:1234/v1', + apiKey: 'new-key', + api: 'openai-completions', + models: [{ + id: 'qwen-local', + name: 'Qwen Local', + contextWindow: 131072, + contextTokens: 131072, + maxTokens: 8192, + }, { id: 'new-local', name: 'New Local', contextWindow: 65536 }], + }, + }, + }, + }, tmp) + + assert.deepEqual(result.updated.sort(), [mainPath, workerPath].sort()) + for (const file of [mainPath, workerPath]) { + const synced = readJson(file) + assert.equal(synced.providers.lmstudio.baseUrl, 'http://127.0.0.1:1234/v1') + assert.equal(synced.providers.lmstudio.apiKey, 'new-key') + assert.equal(synced.providers.stale, undefined) + const local = synced.providers.lmstudio.models.find(model => model.id === 'qwen-local') + assert.equal(local.contextWindow, 131072) + assert.equal(local.contextTokens, 131072) + assert.equal(local.maxTokens, 8192) + assert.equal(local.customRuntimeFlag, true) + assert.equal(synced.providers.lmstudio.modelOverrides['qwen-local'].contextWindow, 131072) + assert.equal(synced.providers.lmstudio.modelOverrides['qwen-local'].contextTokens, 131072) + assert.equal(synced.providers.lmstudio.models.some(model => model.id === 'new-local'), true) + } + } finally { + fs.rmSync(tmp, { recursive: true, force: true }) + } +}) + +test('Web API 同步保留 Agent 运行时手动添加的模型', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'clawpanel-models-sync-')) + try { + const modelsPath = writeAgentModels(tmp, 'main', { + providers: { + lmstudio: { + models: [ + { id: 'qwen-local', contextWindow: 50000 }, + { id: 'manual-only', contextWindow: 32768 }, + ], + }, + }, + }) + + syncProvidersToAgentModels({ + models: { + providers: { + lmstudio: { + models: [{ id: 'qwen-local', contextWindow: 65536 }], + }, + }, + }, + }, tmp) + + const synced = readJson(modelsPath) + assert.equal(synced.providers.lmstudio.models.find(model => model.id === 'qwen-local').contextWindow, 65536) + assert.equal(synced.providers.lmstudio.models.some(model => model.id === 'manual-only'), true) + } finally { + fs.rmSync(tmp, { recursive: true, force: true }) + } +}) diff --git a/tests/docker-base-user.test.js b/tests/docker-base-user.test.js new file mode 100644 index 0000000..cacc433 --- /dev/null +++ b/tests/docker-base-user.test.js @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import test from 'node:test' + +const dockerfile = fs.readFileSync(new URL('../Dockerfile', import.meta.url), 'utf8') +const dockerignore = fs.readFileSync(new URL('../.dockerignore', import.meta.url), 'utf8') +const compose = fs.readFileSync(new URL('../docker-compose.yml', import.meta.url), 'utf8') + +test('Docker 生产镜像复用官方 node 用户,避免 Alpine UID/GID 1000 冲突', () => { + assert.doesNotMatch(dockerfile, /addgroup\s+-g\s+1000/) + assert.match(dockerfile, /--chown=node:node/) + assert.match(dockerfile, /chown -R node:node \/app/) +}) + +test('Docker 构建上下文和生产镜像包含 Web API 运行时依赖', () => { + assert.match(dockerignore, /!public\/\*\*/) + assert.match(dockerfile, /COPY public\/ \.\/public\//) + assert.match(dockerignore, /!scripts\/dev-api\.js/) + assert.match(dockerignore, /!scripts\/media-background-queue\.js/) + assert.match(dockerignore, /!scripts\/lib\/\*\*/) + assert.match(dockerfile, /\/build\/src\/lib\/model-presets\.js/) +}) + +test('Docker 健康检查跟随 Web 自定义端口', () => { + assert.match(dockerfile, /ENV PORT=1420/) + assert.match(dockerfile, /localhost:\$\{PORT:-1420\}\/__api\/health/) + assert.match(compose, /PORT=\$\{CLAWPANEL_PORT:-1420\}/) + assert.match(compose, /localhost:\$\$\{PORT:-1420\}\/__api\/health/) +}) diff --git a/tests/hermes-profiles.test.js b/tests/hermes-profiles.test.js new file mode 100644 index 0000000..5de8dd2 --- /dev/null +++ b/tests/hermes-profiles.test.js @@ -0,0 +1,46 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' + +import { parseHermesProfileListOutput } from '../scripts/dev-api.js' + +const profilePage = readFileSync(new URL('../src/engines/hermes/pages/profiles.js', import.meta.url), 'utf8') +const chatPage = readFileSync(new URL('../src/engines/hermes/pages/chat.js', import.meta.url), 'utf8') +const componentCss = readFileSync(new URL('../src/style/components.css', import.meta.url), 'utf8') + +test('Hermes 新版 profile list 的星号格式可以正确解析', () => { + const result = parseHermesProfileListOutput(`Available profiles:\n default\n* work\n dev\n personal`) + + assert.equal(result.active, 'work') + assert.deepEqual(result.profiles.map(profile => profile.name), ['default', 'work', 'dev', 'personal']) + assert.equal(result.profiles.find(profile => profile.name === 'work')?.active, true) + assert.equal(result.profiles.find(profile => profile.name === 'default')?.active, false) +}) + +test('Hermes 旧版 profile 表格仍保留模型和 Gateway 状态', () => { + const result = parseHermesProfileListOutput([ + 'Profile Model Gateway Alias', + '◆ default anthropic/claude stopped —', + ' work openai/gpt running work', + ].join('\n')) + + assert.equal(result.active, 'default') + assert.deepEqual(result.profiles, [ + { name: 'default', active: true, model: 'anthropic/claude', gatewayRunning: false, alias: '' }, + { name: 'work', active: false, model: 'openai/gpt', gatewayRunning: true, alias: 'work' }, + ]) +}) + +test('Hermes Profile 页面提供按 Profile 写入模型的入口', () => { + assert.match(profilePage, /hermesDashboardApi\('PUT', `\/api\/profiles\/\$\{encodeURIComponent\(name\)\}\/model`/) + assert.match(profilePage, /data-action="configure"/) +}) + +test('Hermes 聊天页提供直接启动 Gateway 的入口', () => { + assert.match(chatPage, /id="hm-chat-start-gateway"/) + assert.match(chatPage, /hermesGatewayAction\('start'\)/) +}) + +test('Hermes Profile 移动端操作按钮满足触控高度', () => { + assert.match(componentCss, /\.lazy-deps-card-actions \.btn\s*\{[^}]*min-height:\s*40px/s) +}) diff --git a/tests/linux-deploy-upgrade-policy.test.js b/tests/linux-deploy-upgrade-policy.test.js index 61f2025..3cdd9dd 100644 --- a/tests/linux-deploy-upgrade-policy.test.js +++ b/tests/linux-deploy-upgrade-policy.test.js @@ -28,6 +28,10 @@ test('Linux Web 构建后必须重启已有服务并输出实际版本', () => { assert.match(script, /ClawPanel 版本:.*PANEL_VERSION/) }) +test('Linux Web 部署允许通过环境变量切换到未占用端口', () => { + assert.match(script, /PANEL_PORT="\$\{CLAWPANEL_PORT:-\$\{PANEL_PORT:-1420\}\}"/) +}) + test('Linux Web 升级文档必须区分 system 与 user 服务并提供可诊断命令', () => { for (const [name, content] of [ ['README.md', readme], diff --git a/tests/model-channels-runtime.test.js b/tests/model-channels-runtime.test.js index 2fc32d6..1df1668 100644 --- a/tests/model-channels-runtime.test.js +++ b/tests/model-channels-runtime.test.js @@ -17,8 +17,18 @@ const originalApi = { readOpenclawConfig: api.readOpenclawConfig, writeOpenclawConfig: api.writeOpenclawConfig, revealModelChannelKey: api.revealModelChannelKey, + probeGatewayPort: api.probeGatewayPort, + restartGateway: api.restartGateway, + reloadGateway: api.reloadGateway, } +test.beforeEach(() => { + // 运行时应用动作由专门测试覆盖;其余同步测试保持纯配置回读。 + api.probeGatewayPort = async () => false + api.restartGateway = async () => true + api.reloadGateway = async () => true +}) + function restoreApi() { Object.assign(api, originalApi) storage.clear() @@ -138,6 +148,23 @@ test('同步旧 Codex 渠道时写入 7.1 正式 API 类型', async () => { assert.equal(written.models.providers.legacy.api, 'openai-chatgpt-responses') }) +test('Web 模型渠道同步后会重启运行中的 Gateway 以加载 Agent 模型注册表', async () => { + let written = null + let restartCount = 0 + api.probeGatewayPort = async () => true + api.restartGateway = async () => { restartCount += 1 } + api.revealModelChannelKey = async () => 'sk-test' + api.readOpenclawConfig = async () => written || ({ models: { providers: {} } }) + api.writeOpenclawConfig = async config => { written = config } + + await channels.syncChannelToOpenclaw({ + id: 'lmstudio', name: 'LM Studio', baseUrl: 'http://127.0.0.1:1234/v1', + apiType: 'openai-completions', models: [{ id: 'qwen-local' }], defaultModel: 'qwen-local', + }) + + assert.equal(restartCount, 1) +}) + test('OpenClaw 同步必须通过目标配置回读核对', async () => { api.revealModelChannelKey = async () => 'sk-test' api.readOpenclawConfig = async () => ({ models: { providers: {} } })