fix hosted session binding and harden panel config resolution

This commit is contained in:
晴天
2026-04-10 18:52:03 +08:00
parent 6beaa7f46a
commit 3105e56fd6
4 changed files with 211 additions and 20 deletions

View File

@@ -1040,7 +1040,7 @@ function scanLocalSkillsFallback(agentSkillsDir = null) {
status: 'scanned',
scannedAt: new Date().toISOString(),
scannedRoots,
cli: cliError ? { status: 'exec-failed', message: String(cliError?.message || cliError) } : null,
cli: null,
},
}
}

View File

@@ -32,6 +32,78 @@ fn default_openclaw_dir() -> PathBuf {
dirs::home_dir().unwrap_or_default().join(".openclaw")
}
fn panel_path_key(path: &std::path::Path) -> String {
#[cfg(target_os = "windows")]
{
return path
.to_string_lossy()
.replace('/', "\\")
.to_lowercase();
}
#[cfg(not(target_os = "windows"))]
{
path.to_string_lossy().to_string()
}
}
fn push_unique_panel_config_path(paths: &mut Vec<PathBuf>, path: PathBuf) {
let key = panel_path_key(&path);
if paths.iter().any(|existing| panel_path_key(existing) == key) {
return;
}
paths.push(path);
}
fn panel_config_candidate_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
push_unique_panel_config_path(&mut paths, default_openclaw_dir().join("clawpanel.json"));
#[cfg(target_os = "windows")]
{
if let Ok(profile) = std::env::var("USERPROFILE") {
let trimmed = profile.trim();
if !trimmed.is_empty() {
push_unique_panel_config_path(
&mut paths,
PathBuf::from(trimmed).join(".openclaw").join("clawpanel.json"),
);
}
}
if let (Ok(home_drive), Ok(home_path)) = (
std::env::var("HOMEDRIVE"),
std::env::var("HOMEPATH"),
) {
let combined = format!("{}{}", home_drive.trim(), home_path.trim());
let trimmed = combined.trim();
if !trimmed.is_empty() {
push_unique_panel_config_path(
&mut paths,
PathBuf::from(trimmed).join(".openclaw").join("clawpanel.json"),
);
}
}
if let Ok(appdata) = std::env::var("APPDATA") {
let appdata_path = PathBuf::from(appdata.trim());
if let Some(profile_dir) = appdata_path.parent().and_then(|p| p.parent()) {
push_unique_panel_config_path(
&mut paths,
profile_dir.join(".openclaw").join("clawpanel.json"),
);
}
}
}
paths
}
fn read_panel_config_from(path: &std::path::Path) -> Option<serde_json::Value> {
std::fs::read_to_string(path)
.ok()
.and_then(|content| serde_json::from_str(&content).ok())
}
fn normalize_custom_openclaw_dir(raw: &str) -> Option<PathBuf> {
let trimmed = raw.trim();
if trimmed.is_empty() {
@@ -121,8 +193,21 @@ fn read_gateway_port_from_config() -> u16 {
}
fn panel_config_path() -> PathBuf {
// ClawPanel 自身配置始终在默认目录,不随 openclawDir 变化
default_openclaw_dir().join("clawpanel.json")
let candidates = panel_config_candidate_paths();
for path in &candidates {
if read_panel_config_from(path).is_some() {
return path.clone();
}
}
for path in &candidates {
if path.exists() {
return path.clone();
}
}
candidates
.into_iter()
.next()
.unwrap_or_else(|| default_openclaw_dir().join("clawpanel.json"))
}
#[cfg(target_os = "windows")]
@@ -151,9 +236,12 @@ pub(crate) fn windows_npm_global_prefix() -> Option<String> {
}
pub fn read_panel_config_value() -> Option<serde_json::Value> {
std::fs::read_to_string(panel_config_path())
.ok()
.and_then(|content| serde_json::from_str(&content).ok())
for path in panel_config_candidate_paths() {
if let Some(value) = read_panel_config_from(&path) {
return Some(value);
}
}
None
}
pub fn configured_proxy_url() -> Option<String> {

View File

@@ -102,6 +102,7 @@ let _hostedAutoStopEl = null
let _hostedSaveBtn = null, _hostedStopBtn = null, _hostedCloseBtn = null
let _hostedDefaults = null
let _hostedSessionConfig = null
let _hostedBoundSessionKey = null
let _hostedRuntime = { ...HOSTED_RUNTIME_DEFAULT }
let _hostedBusy = false
let _hostedAbort = null
@@ -1692,8 +1693,39 @@ function handleEvent(msg) {
}
function handleChatEvent(payload) {
// sessionKey 过滤
if (payload.sessionKey && payload.sessionKey !== _sessionKey && _sessionKey) return
const hostedSessionKey = getHostedBoundSessionKey()
const isCurrentSession = !payload.sessionKey || !_sessionKey || payload.sessionKey === _sessionKey
const isHostedSession = !!payload.sessionKey && !!hostedSessionKey && payload.sessionKey === hostedSessionKey
// sessionKey 过滤:当前会话照常渲染;托管绑定会话在后台继续驱动循环
if (!isCurrentSession && !isHostedSession) return
if (!isCurrentSession && isHostedSession) {
if (payload.state === 'final' && shouldCaptureHostedTarget(payload)) {
const c = extractChatContent(payload.message)
const capturedText = c?.text || ''
if (capturedText) {
appendHostedTarget(capturedText)
if (detectStopFromText(capturedText)) {
stopHostedAgent()
} else {
maybeTriggerHostedRun()
}
}
}
if (payload.state === 'error' && _hostedSessionConfig?.enabled) {
_hostedRuntime.errorCount = (_hostedRuntime.errorCount || 0) + 1
_hostedRuntime.lastError = payload.errorMessage || payload.error?.message || t('common.error')
_hostedRuntime.pending = false
if (_hostedRuntime.errorCount >= _hostedSessionConfig.retryLimit) {
_hostedRuntime.status = HOSTED_STATUS.ERROR
}
persistHostedRuntime()
updateHostedBadge()
}
return
}
const { state } = payload
const runId = payload.runId
@@ -2814,6 +2846,10 @@ function getHostedSessionKey() {
return _sessionKey || localStorage.getItem(STORAGE_SESSION_KEY) || 'agent:main:main'
}
function getHostedBoundSessionKey() {
return _hostedSessionConfig?.boundSessionKey || _hostedBoundSessionKey || ''
}
async function loadHostedDefaults() {
try {
const panel = await api.readPanelConfig()
@@ -2827,23 +2863,28 @@ function loadHostedSessionConfig() {
const key = getHostedSessionKey()
const current = data[key] || {}
_hostedSessionConfig = { ...HOSTED_DEFAULTS, ..._hostedDefaults, ...current }
if (_hostedSessionConfig.enabled && !_hostedSessionConfig.boundSessionKey) {
_hostedSessionConfig.boundSessionKey = key
}
_hostedBoundSessionKey = _hostedSessionConfig.boundSessionKey || null
if (!_hostedSessionConfig.state) _hostedSessionConfig.state = { ...HOSTED_RUNTIME_DEFAULT }
if (!_hostedSessionConfig.history) _hostedSessionConfig.history = []
_hostedRuntime = { ...HOSTED_RUNTIME_DEFAULT, ..._hostedSessionConfig.state }
updateHostedBadge()
}
function saveHostedSessionConfig(nextConfig) {
function saveHostedSessionConfig(nextConfig, key = null) {
let data = {}
try { data = JSON.parse(localStorage.getItem(HOSTED_SESSIONS_KEY) || '{}') } catch { data = {} }
data[getHostedSessionKey()] = nextConfig
data[key || getHostedSessionKey()] = nextConfig
localStorage.setItem(HOSTED_SESSIONS_KEY, JSON.stringify(data))
}
function persistHostedRuntime() {
function persistHostedRuntime(persistKey = null) {
if (!_hostedSessionConfig) return
_hostedSessionConfig.state = { ..._hostedRuntime }
saveHostedSessionConfig(_hostedSessionConfig)
const key = persistKey || getHostedBoundSessionKey() || getHostedSessionKey()
saveHostedSessionConfig(_hostedSessionConfig, key)
}
function updateHostedBadge() {
@@ -2943,7 +2984,9 @@ async function startHostedAgent() {
const retryLimit = Math.max(0, parseInt(_hostedRetryLimitEl?.value || HOSTED_DEFAULTS.retryLimit, 10))
const timerOn = _page?.querySelector('#hosted-agent-timer-on')?.checked
const autoStopMinutes = timerOn ? Math.max(0, parseInt(_hostedAutoStopEl?.value || 0, 10)) : 0
_hostedSessionConfig = { ..._hostedSessionConfig, prompt, enabled: true, maxSteps, stepDelayMs, retryLimit, autoStopMinutes }
const boundSessionKey = getHostedSessionKey()
_hostedBoundSessionKey = boundSessionKey
_hostedSessionConfig = { ..._hostedSessionConfig, prompt, enabled: true, maxSteps, stepDelayMs, retryLimit, autoStopMinutes, boundSessionKey }
const sysContent = HOSTED_SYSTEM_PROMPT + '\n\nUser goal: ' + prompt
if (!_hostedSessionConfig.history?.length) _hostedSessionConfig.history = [{ role: 'system', content: sysContent }]
else if (_hostedSessionConfig.history[0]?.role === 'system') _hostedSessionConfig.history[0].content = sysContent
@@ -2968,6 +3011,7 @@ async function startHostedAgent() {
function stopHostedAgent() {
if (!_hostedSessionConfig) return
const boundSessionKey = getHostedBoundSessionKey() || getHostedSessionKey()
if (_hostedAbort) { _hostedAbort.abort(); _hostedAbort = null }
clearTimeout(_hostedAutoStopTimer); _hostedAutoStopTimer = null
clearInterval(_countdownInterval); _countdownInterval = null
@@ -2979,7 +3023,8 @@ function stopHostedAgent() {
_hostedRuntime.lastError = ''
_hostedRuntime.errorCount = 0
_hostedStartTime = 0
persistHostedRuntime()
persistHostedRuntime(boundSessionKey)
_hostedBoundSessionKey = null
renderHostedPanel()
updateHostedBadge()
toast(t('chat.hostedStopped'), 'info')
@@ -2987,6 +3032,8 @@ function stopHostedAgent() {
function shouldCaptureHostedTarget(payload) {
if (!_hostedSessionConfig?.enabled) return false
const hostedSessionKey = getHostedBoundSessionKey()
if (payload?.sessionKey && hostedSessionKey && payload.sessionKey !== hostedSessionKey) return false
if (_hostedRuntime.status === HOSTED_STATUS.PAUSED || _hostedRuntime.status === HOSTED_STATUS.ERROR || _hostedRuntime.status === HOSTED_STATUS.IDLE) return false
if (payload?.message?.role && payload.message.role !== 'assistant') return false
const ts = payload?.timestamp || Date.now()
@@ -3050,8 +3097,9 @@ function detectStopFromText(text) {
async function runHostedAgentStep() {
if (_hostedBusy || !_hostedSessionConfig?.enabled) return
const prompt = (_hostedSessionConfig.prompt || '').trim()
const hostedSessionKey = getHostedBoundSessionKey() || getHostedSessionKey()
if (!prompt) return
if (!wsClient.gatewayReady || !_sessionKey) {
if (!wsClient.gatewayReady || !hostedSessionKey) {
_hostedRuntime.status = HOSTED_STATUS.PAUSED
_hostedRuntime.lastError = 'Gateway not ready'
persistHostedRuntime(); updateHostedBadge()
@@ -3099,7 +3147,7 @@ async function runHostedAgentStep() {
_hostedRuntime.pending = false
persistHostedRuntime(); updateHostedBadge()
// 将指令发给 Gateway Agent
try { await wsClient.chatSend(_sessionKey, instruction) } catch {}
try { await wsClient.chatSend(hostedSessionKey, instruction) } catch {}
} else {
_hostedRuntime.status = HOSTED_STATUS.IDLE
_hostedRuntime.pending = false
@@ -3221,6 +3269,8 @@ function normalizeHostedBaseUrl(raw, apiType) {
function appendHostedOutput(text) {
if (!text || !_messagesEl) return
const hostedSessionKey = getHostedBoundSessionKey()
if (hostedSessionKey && _sessionKey && hostedSessionKey !== _sessionKey) return
const wrap = document.createElement('div')
wrap.className = 'msg msg-system msg-hosted'
wrap.textContent = `[${t('chat.hostedAgent')}] ${text}`

View File

@@ -3,7 +3,7 @@
* 自动检测环境 → 版本选择 → 一键安装 → 自动跳转
*/
import { api, invalidate } from '../lib/tauri-api.js'
import { showUpgradeModal } from '../components/modal.js'
import { showConfirm, showUpgradeModal } from '../components/modal.js'
import { toast } from '../components/toast.js'
import { setUpgrading, isMacPlatform } from '../lib/app-state.js'
import { diagnoseInstallError } from '../lib/error-diagnosis.js'
@@ -111,6 +111,51 @@ export async function render() {
return page
}
async function maybeRefreshGatewayServiceBinding() {
if (!isMacPlatform()) return false
const [versionInfo, dirInfo] = await Promise.all([
api.getVersionInfo().catch(() => null),
api.getOpenclawDir().catch(() => null),
])
if (!versionInfo?.cli_path || dirInfo?.configExists === false) {
return false
}
const shouldRefresh = await showConfirm(t('settings.gatewayServiceRefreshConfirm'))
if (!shouldRefresh) return false
toast(t('settings.gatewayServiceRefreshing'), 'info')
try {
const services = await api.getServicesStatus().catch(() => [])
const gw = services?.find?.(s => s.label === 'ai.openclaw.gateway') || services?.[0] || null
const shouldStartAgain = gw?.running === true && gw?.owned_by_current_instance !== false
await api.uninstallGateway().catch(() => {})
await api.installGateway()
if (shouldStartAgain) {
await api.startService('ai.openclaw.gateway')
}
toast(t('settings.gatewayServiceRefreshed'), 'success')
return true
} catch (e) {
toast(`${t('settings.gatewayServiceRefreshFailed')}: ${e?.message || e}`, 'warning')
return false
}
}
async function promptRestart(msg) {
if (!window.__TAURI_INTERNALS__) { toast(msg, 'success'); return }
const ok = await showConfirm(`${msg}\n\n${t('settings.restartConfirm')}`)
if (ok) {
toast(t('settings.restarting'), 'info')
try { await api.relaunchApp() } catch { toast(t('settings.restartFailed'), 'warning') }
} else {
toast(`${msg}, ${t('settings.effectNextLaunch')}`, 'success')
}
}
async function runDetect(page) {
const stepsEl = page.querySelector('#setup-steps')
stepsEl.innerHTML = `
@@ -620,7 +665,10 @@ function bindEvents(page, nodeOk, detectState) {
await api.writePanelConfig(cfg)
invalidate()
if (dirResultEl) dirResultEl.innerHTML = `<span style="color:var(--success)">✓ ${t('setup.pathSaved')}</span>`
toast(t('setup.customPathSaved'), 'success')
const savedMsg = t('setup.customPathSaved')
const refreshed = await maybeRefreshGatewayServiceBinding()
if (refreshed) toast(savedMsg, 'success')
else await promptRestart(savedMsg)
setTimeout(() => runDetect(page), 500)
} catch (e) {
if (dirResultEl) dirResultEl.innerHTML = `<span style="color:var(--error)">${t('setup.saveFailed', { err: e })}</span>`
@@ -673,7 +721,10 @@ function bindEvents(page, nodeOk, detectState) {
invalidate()
if (dirInput) dirInput.value = ''
if (dirResultEl) { dirResultEl.style.display = 'block'; dirResultEl.innerHTML = `<span style="color:var(--success)">✓ ${t('setup.defaultRestored')}</span>` }
toast(t('setup.defaultRestoredToast'), 'success')
const restoredMsg = t('setup.defaultRestoredToast')
const refreshed = await maybeRefreshGatewayServiceBinding()
if (refreshed) toast(restoredMsg, 'success')
else await promptRestart(restoredMsg)
setTimeout(() => runDetect(page), 500)
} catch (e) {
toast(t('setup.restoreFailed', { err: e }), 'error')
@@ -779,7 +830,9 @@ function bindEvents(page, nodeOk, detectState) {
resultEl.style.display = 'block'
resultEl.innerHTML = `<span style="color:var(--success)">✓ ${successText}</span>`
}
toast(successText, 'success')
const refreshed = await maybeRefreshGatewayServiceBinding()
if (refreshed) toast(successText, 'success')
else await promptRestart(successText)
setTimeout(() => runDetect(page), 300)
return true
} catch (e) {