fix: dashboard null crash, chat layout, markdown escaping, gzip, gateway banner delay

feat: hosted agent with auto-stop, context compression, visual sliders
feat: auto-reload gateway after config save (debounced 3s)
style: toast solid bg, chat input enlargement, hosted agent panel CSS
chore: fix dev.ps1 encoding, engagement share text
This commit is contained in:
晴天
2026-03-18 15:02:04 +08:00
parent 08031b9dca
commit 7764a32799
11 changed files with 757 additions and 41 deletions

34
dev.ps1
View File

@@ -1,30 +1,12 @@
#!/usr/bin/env pwsh
# ClawPanel 开发服务器启动脚本
#!/usr/bin/env pwsh
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
Write-Host "🚀 启动 ClawPanel 开发服务器..." -ForegroundColor Cyan
Write-Host '[*] Starting ClawPanel dev server...' -ForegroundColor Cyan
# 检查 Node.js
if (-not (Get-Command node -ErrorAction SilentlyContinue)) {
Write-Host "❌ 未找到 Node.js请先安装" -ForegroundColor Red
exit 1
}
if (-not (Get-Command node -ErrorAction SilentlyContinue)) { Write-Host '[ERR] Node.js not found' -ForegroundColor Red; exit 1 }
if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) { Write-Host '[ERR] Rust not found' -ForegroundColor Red; exit 1 }
if (-not (Test-Path 'node_modules')) { Write-Host '[*] Installing deps...' -ForegroundColor Yellow; npm install }
if (-not (Test-Path 'src-tauri\target')) { Write-Host '[*] First Rust build (may take minutes)...' -ForegroundColor Yellow }
# 检查 Rust
if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) {
Write-Host "❌ 未找到 Rust请先安装" -ForegroundColor Red
exit 1
}
# 检查依赖
if (-not (Test-Path "node_modules")) {
Write-Host "📦 安装前端依赖..." -ForegroundColor Yellow
npm install
}
if (-not (Test-Path "src-tauri/target")) {
Write-Host "🦀 首次编译 Rust可能需要几分钟..." -ForegroundColor Yellow
}
# 启动开发服务器
Write-Host "✨ 启动中..." -ForegroundColor Green
Write-Host '[*] Launching...' -ForegroundColor Green
npm run tauri dev

34
src-tauri/Cargo.lock generated
View File

@@ -56,6 +56,18 @@ dependencies = [
"derive_arbitrary",
]
[[package]]
name = "async-compression"
version = "0.4.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1"
dependencies = [
"compression-codecs",
"compression-core",
"pin-project-lite",
"tokio",
]
[[package]]
name = "atk"
version = "0.18.2"
@@ -359,6 +371,23 @@ dependencies = [
"memchr",
]
[[package]]
name = "compression-codecs"
version = "0.4.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7"
dependencies = [
"compression-core",
"flate2",
"memchr",
]
[[package]]
name = "compression-core"
version = "0.4.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d"
[[package]]
name = "const-oid"
version = "0.9.6"
@@ -4206,13 +4235,18 @@ version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
dependencies = [
"async-compression",
"bitflags 2.11.0",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"iri-string",
"pin-project-lite",
"tokio",
"tokio-util",
"tower",
"tower-layer",
"tower-service",

View File

@@ -23,7 +23,7 @@ serde_json = "1"
dirs = "6"
chrono = "0.4"
zip = { version = "2", default-features = false, features = ["deflate"] }
reqwest = { version = "0.12", features = ["json", "rustls-tls", "stream"], default-features = false }
reqwest = { version = "0.12", features = ["json", "rustls-tls", "stream", "gzip"], default-features = false }
futures-util = "0.3"
ed25519-dalek = { version = "2", features = ["rand_core"] }
sha2 = "0.10"

View File

@@ -92,7 +92,7 @@ fn build_http_client_opt(
user_agent: Option<&str>,
use_proxy: bool,
) -> Result<reqwest::Client, String> {
let mut builder = reqwest::Client::builder().timeout(timeout);
let mut builder = reqwest::Client::builder().timeout(timeout).gzip(true);
if let Some(ua) = user_agent {
builder = builder.user_agent(ua);
}

View File

@@ -66,7 +66,7 @@ export function tryShowEngagement() {
_showing = true
localStorage.setItem(KEYS.lastShown, String(Date.now()))
const shareText = '推荐一个开源的 OpenClaw 管理面板 — ClawPanel一键搭建、便捷管理模型和 Agent还内置 AI 助手帮你排查问题,小白也能轻松上手 👉 https://claw.qt.cool'
const shareText = '推荐一个开源的 OpenClaw 管理面板 — ClawPanel一键搭建、便捷管理模型和 Agent还内置 AI 助手帮你排查问题,小白也能轻松上手https://claw.qt.cool'
const overlay = document.createElement('div')
overlay.className = 'engage-overlay'

View File

@@ -25,7 +25,7 @@ function highlightCode(code, lang) {
.replace(/\b(\d+\.?\d*)\b/g, `${S}0${E}$1${S}c${E}`)
.replace(/(\/\/.*$|#.*$)/gm, `${S}1${E}$1${S}c${E}`)
.replace(/(\/\*[\s\S]*?\*\/)/g, `${S}1${E}$1${S}c${E}`)
.replace(/(&quot;(?:[^&]|&(?!quot;))*?&quot;|&#x27;(?:[^&]|&(?!#x27;))*?&#x27;|`[^`]*`)/g,
.replace(/(&quot;(?:[^&]|&(?!quot;))*?&quot;|'[^'\n]*'|`[^`]*`)/g,
`${S}2${E}$1${S}c${E}`)
.replace(/\b([A-Z][a-zA-Z0-9_]*)\b/g, (m, w) =>
KEYWORDS.has(w) ? m : `${S}3${E}${w}${S}c${E}`)
@@ -43,7 +43,6 @@ function escapeHtml(str) {
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;')
}
// 预加载 Tauri convertFileSrc
@@ -150,7 +149,8 @@ function inlineFormat(text) {
.replace(/(?<!\w)_(.+?)_(?!\w)/g, '<em>$1</em>')
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, src) => {
const safeSrc = resolveImageSrc(src.trim())
return `<img src="${safeSrc}" alt="${alt}" class="msg-img" onerror="this.onerror=null;this.style.display='none';this.insertAdjacentHTML('afterend','<span style=\\'color:var(--text-tertiary);font-size:12px\\'>[图片无法加载: ${escapeHtml(src)}]</span>')" />`
const escapedSrc = escapeHtml(src).replace(/\\/g, '&#x5c;')
return `<img src="${safeSrc}" alt="${alt}" class="msg-img" onerror="this.onerror=null;this.style.display='none';this.insertAdjacentHTML('afterend','<span style=\\'color:var(--text-tertiary);font-size:12px\\'>[图片无法加载: ${escapedSrc}]</span>')" />`
})
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => {
const safe = /^https?:|^mailto:/i.test(url.trim()) ? url : '#'

View File

@@ -156,6 +156,13 @@ export async function checkBackendHealth() {
}
}
// 配置保存后防抖重载 Gateway3 秒内多次写入只触发一次重载)
let _reloadTimer = null
function _debouncedReloadGateway() {
clearTimeout(_reloadTimer)
_reloadTimer = setTimeout(() => { invoke('reload_gateway').catch(() => {}) }, 3000)
}
// 导出 API
export const api = {
// 服务管理(状态用短缓存,操作不缓存)
@@ -169,7 +176,7 @@ export const api = {
getVersionInfo: () => cachedInvoke('get_version_info', {}, 30000),
getStatusSummary: () => cachedInvoke('get_status_summary', {}, 60000),
readOpenclawConfig: () => cachedInvoke('read_openclaw_config'),
writeOpenclawConfig: (config) => { invalidate('read_openclaw_config'); return invoke('write_openclaw_config', { config }) },
writeOpenclawConfig: (config) => { invalidate('read_openclaw_config'); return invoke('write_openclaw_config', { config }).then(r => { _debouncedReloadGateway(); return r }) },
readMcpConfig: () => cachedInvoke('read_mcp_config'),
writeMcpConfig: (config) => { invalidate('read_mcp_config'); return invoke('write_mcp_config', { config }) },
reloadGateway: () => invoke('reload_gateway'),

View File

@@ -79,6 +79,32 @@ let _primaryModel = ''
let _selectedModel = ''
let _isApplyingModel = false
// ── 托管 Agent ──
const HOSTED_STATUS = { IDLE: 'idle', RUNNING: 'running', WAITING: 'waiting_reply', PAUSED: 'paused', ERROR: 'error' }
const HOSTED_SESSIONS_KEY = 'clawpanel-hosted-agent-sessions'
const HOSTED_SYSTEM_PROMPT = `你是一个托管调度 Agent。你的职责是根据用户设定的目标持续引导 OpenClaw AI Agent 完成任务。
规则:
1. 你每一轮只输出一条简洁的指令1-3 句话),发给 OpenClaw 执行
2. 根据 OpenClaw 的回复评估进展,决定下一步指令
3. 如果任务已完成或无法继续,回复包含"完成"或"停止"来结束循环
4. 不要重复相同的指令,不要输出解释性文字,只输出下一步要执行的指令`
const HOSTED_DEFAULTS = { enabled: false, prompt: '', autoRunAfterTarget: true, stopPolicy: 'self', maxSteps: 50, stepDelayMs: 1200, retryLimit: 2, autoStopMinutes: 0 }
const HOSTED_RUNTIME_DEFAULT = { status: HOSTED_STATUS.IDLE, stepCount: 0, lastRunAt: 0, lastRunId: '', lastError: '', pending: false, errorCount: 0 }
const HOSTED_CONTEXT_MAX = 30
const HOSTED_COMPRESS_THRESHOLD = 20
let _hostedBtn = null, _hostedPanelEl = null, _hostedBadgeEl = null
let _hostedPromptEl = null, _hostedMaxStepsEl = null, _hostedStepDelayEl = null, _hostedRetryLimitEl = null
let _hostedAutoStopEl = null
let _hostedSaveBtn = null, _hostedStopBtn = null, _hostedCloseBtn = null
let _hostedDefaults = null
let _hostedSessionConfig = null
let _hostedRuntime = { ...HOSTED_RUNTIME_DEFAULT }
let _hostedBusy = false
let _hostedAbort = null
let _hostedLastTargetTs = 0
let _hostedAutoStopTimer = null
let _hostedStartTime = 0
export async function render() {
const page = document.createElement('div')
page.className = 'page chat-page'
@@ -145,6 +171,48 @@ export async function render() {
<button class="chat-send-btn" id="chat-send-btn" disabled>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
</button>
<button class="chat-hosted-btn btn btn-sm btn-ghost" id="chat-hosted-btn" title="托管 Agent">
<span class="chat-hosted-label">⊕</span>
<span class="chat-hosted-badge idle" id="chat-hosted-badge">托管</span>
</button>
</div>
<div class="hosted-agent-panel" id="hosted-agent-panel" style="display:none">
<div class="hosted-agent-header">
<strong>托管 Agent</strong>
<button class="hosted-agent-close" id="hosted-agent-close" title="关闭">&times;</button>
</div>
<div class="hosted-agent-body">
<div class="form-group">
<label class="form-label" style="color:var(--accent);font-weight:600">任务目标</label>
<textarea class="form-input hosted-agent-prompt" id="hosted-agent-prompt" rows="3" placeholder="例如:持续优化此仓库代码质量,直到没有可改进的地方"></textarea>
<div class="form-hint">托管 Agent 会持续引导 OpenClaw 完成此目标。模型使用 <a href="#/assistant" class="hosted-agent-link">AI 助手</a> 的配置。</div>
</div>
<div class="ha-slider-group">
<div class="ha-slider-label">最大回复次数 <span class="ha-slider-val" id="ha-steps-val">50</span></div>
<input type="range" class="ha-slider" id="hosted-agent-max-steps" min="5" max="205" step="5" value="50">
<div class="ha-slider-ticks"><span>5</span><span>50</span><span>100</span><span>200</span><span>∞</span></div>
</div>
<div class="ha-timer-group">
<div class="ha-timer-header">
<span>定时自动停止</span>
<label class="ha-toggle"><input type="checkbox" id="hosted-agent-timer-on"><span class="ha-toggle-track"></span></label>
</div>
<div class="ha-timer-body" id="ha-timer-body" style="display:none">
<input type="range" class="ha-slider" id="hosted-agent-auto-stop" min="5" max="120" step="5" value="30">
<div class="ha-slider-ticks"><span>5分</span><span>30分</span><span>60分</span><span>120分</span></div>
<div class="ha-countdown" id="ha-countdown" style="display:none">
<div class="ha-countdown-bar"><div class="ha-countdown-fill" id="ha-countdown-fill"></div></div>
<span class="ha-countdown-text" id="ha-countdown-text">剩余 --:--</span>
</div>
</div>
</div>
<input type="hidden" id="hosted-agent-step-delay" value="1200">
<input type="hidden" id="hosted-agent-retry" value="2">
</div>
<div class="hosted-agent-actions">
<button class="btn btn-primary" id="hosted-agent-save" style="flex:1">▶ 启动托管</button>
</div>
<div class="hosted-agent-footer" id="hosted-agent-status">就绪</div>
</div>
<div class="chat-disconnect-bar" id="chat-disconnect-bar" style="display:none">连接已断开,正在重连...</div>
<div class="chat-connect-overlay" id="chat-connect-overlay" style="display:none">
@@ -175,6 +243,16 @@ export async function render() {
_attachPreviewEl = page.querySelector('#chat-attachments-preview')
_fileInputEl = page.querySelector('#chat-file-input')
_modelSelectEl = page.querySelector('#chat-model-select')
_hostedBtn = page.querySelector('#chat-hosted-btn')
_hostedBadgeEl = page.querySelector('#chat-hosted-badge')
_hostedPanelEl = page.querySelector('#hosted-agent-panel')
_hostedPromptEl = page.querySelector('#hosted-agent-prompt')
_hostedMaxStepsEl = page.querySelector('#hosted-agent-max-steps')
_hostedStepDelayEl = page.querySelector('#hosted-agent-step-delay')
_hostedRetryLimitEl = page.querySelector('#hosted-agent-retry')
_hostedAutoStopEl = page.querySelector('#hosted-agent-auto-stop')
_hostedSaveBtn = page.querySelector('#hosted-agent-save')
_hostedCloseBtn = page.querySelector('#hosted-agent-close')
page.querySelector('#chat-sidebar')?.classList.toggle('open', getSidebarOpen())
bindEvents(page)
@@ -183,6 +261,7 @@ export async function render() {
// 首次使用引导提示
showPageGuide(_messagesEl)
loadHostedDefaults().then(() => { loadHostedSessionConfig(); renderHostedPanel(); updateHostedBadge() })
loadModelOptions()
// 非阻塞:先返回 DOM后台连接 Gateway
connectGateway()
@@ -247,6 +326,21 @@ function bindEvents(page) {
else sendMessage()
})
if (_hostedBtn) _hostedBtn.addEventListener('click', (e) => { e.stopPropagation(); toggleHostedPanel() })
if (_hostedCloseBtn) _hostedCloseBtn.addEventListener('click', () => hideHostedPanel())
if (_hostedSaveBtn) _hostedSaveBtn.addEventListener('click', () => toggleHostedRun())
// 滑块实时值显示
if (_hostedMaxStepsEl) _hostedMaxStepsEl.addEventListener('input', () => {
const valEl = page.querySelector('#ha-steps-val')
if (valEl) valEl.textContent = parseInt(_hostedMaxStepsEl.value) >= 205 ? '∞' : _hostedMaxStepsEl.value
})
// 定时器开关
const timerToggle = page.querySelector('#hosted-agent-timer-on')
const timerBody = page.querySelector('#ha-timer-body')
if (timerToggle && timerBody) {
timerToggle.addEventListener('change', () => { timerBody.style.display = timerToggle.checked ? '' : 'none' })
}
const toggleSidebar = () => {
const sidebar = page.querySelector('#chat-sidebar')
if (!sidebar) return
@@ -538,6 +632,8 @@ async function connectGateway() {
_hasEverConnected = true
if (bar) bar.style.display = 'none'
if (overlay) overlay.style.display = 'none'
// WS 已连接,主动刷新 Gateway 状态以消除顶部横条延迟
import('../lib/app-state.js').then(m => m.refreshGatewayStatus()).catch(() => {})
} else if (status === 'error') {
// 连接错误:显示引导遮罩而非底部条
if (bar) bar.style.display = 'none'
@@ -1095,6 +1191,19 @@ function handleChatEvent(payload) {
attachments: _currentAiImages.map(i => ({ category: 'image', mimeType: i.mediaType || 'image/png', url: i.url, content: i.data })).filter(a => a.url || a.content)
})
}
// 托管 Agent捕获 AI 回复,检测停止信号,决定是否继续
if (shouldCaptureHostedTarget(payload)) {
const capturedText = finalText || _currentAiText || ''
if (capturedText) {
appendHostedTarget(capturedText)
if (detectStopFromText(capturedText)) {
appendHostedOutput('OpenClaw 回复包含完成信号,自动停止')
stopHostedAgent()
} else {
maybeTriggerHostedRun()
}
}
}
resetStreamState()
processMessageQueue()
return
@@ -1928,6 +2037,391 @@ function updateStatusDot(status) {
else _statusDot.classList.add('offline')
}
// ── 托管 Agent 核心逻辑 ──
function toggleHostedPanel() {
if (!_hostedPanelEl) return
const next = _hostedPanelEl.style.display !== 'block'
_hostedPanelEl.style.display = next ? 'block' : 'none'
if (next) renderHostedPanel()
}
function hideHostedPanel() {
if (_hostedPanelEl) _hostedPanelEl.style.display = 'none'
}
function getHostedSessionKey() {
return _sessionKey || localStorage.getItem(STORAGE_SESSION_KEY) || 'agent:main:main'
}
async function loadHostedDefaults() {
try {
const panel = await api.readPanelConfig()
_hostedDefaults = panel?.hostedAgent?.default || null
} catch { _hostedDefaults = null }
}
function loadHostedSessionConfig() {
let data = {}
try { data = JSON.parse(localStorage.getItem(HOSTED_SESSIONS_KEY) || '{}') } catch { data = {} }
const key = getHostedSessionKey()
const current = data[key] || {}
_hostedSessionConfig = { ...HOSTED_DEFAULTS, ..._hostedDefaults, ...current }
if (!_hostedSessionConfig.state) _hostedSessionConfig.state = { ...HOSTED_RUNTIME_DEFAULT }
if (!_hostedSessionConfig.history) _hostedSessionConfig.history = []
_hostedRuntime = { ...HOSTED_RUNTIME_DEFAULT, ..._hostedSessionConfig.state }
updateHostedBadge()
}
function saveHostedSessionConfig(nextConfig) {
let data = {}
try { data = JSON.parse(localStorage.getItem(HOSTED_SESSIONS_KEY) || '{}') } catch { data = {} }
data[getHostedSessionKey()] = nextConfig
localStorage.setItem(HOSTED_SESSIONS_KEY, JSON.stringify(data))
}
function persistHostedRuntime() {
if (!_hostedSessionConfig) return
_hostedSessionConfig.state = { ..._hostedRuntime }
saveHostedSessionConfig(_hostedSessionConfig)
}
function updateHostedBadge() {
if (!_hostedBadgeEl || !_hostedSessionConfig) return
const status = _hostedRuntime.status || HOSTED_STATUS.IDLE
const enabled = _hostedSessionConfig.enabled
let text = '未启用', cls = 'chat-hosted-badge'
if (!enabled) { text = '未启用'; cls += ' idle' }
else if (status === HOSTED_STATUS.RUNNING) { text = '运行中'; cls += ' running' }
else if (status === HOSTED_STATUS.WAITING) { text = '等待回复'; cls += ' waiting' }
else if (status === HOSTED_STATUS.PAUSED) { text = '已暂停'; cls += ' paused' }
else if (status === HOSTED_STATUS.ERROR) { text = '异常'; cls += ' error' }
else { text = '待命'; cls += ' idle' }
_hostedBadgeEl.className = cls
_hostedBadgeEl.textContent = text
}
let _countdownInterval = null
function renderHostedPanel() {
if (!_hostedPanelEl || !_hostedSessionConfig) return
const isRunning = _hostedSessionConfig.enabled && _hostedRuntime.status !== HOSTED_STATUS.IDLE
if (_hostedPromptEl) { _hostedPromptEl.value = _hostedSessionConfig.prompt || ''; _hostedPromptEl.disabled = isRunning }
if (_hostedMaxStepsEl) {
_hostedMaxStepsEl.value = _hostedSessionConfig.maxSteps || HOSTED_DEFAULTS.maxSteps
_hostedMaxStepsEl.disabled = isRunning
const valEl = _hostedPanelEl.querySelector('#ha-steps-val')
if (valEl) valEl.textContent = _hostedMaxStepsEl.value
}
if (_hostedAutoStopEl) { _hostedAutoStopEl.value = _hostedSessionConfig.autoStopMinutes || 30; _hostedAutoStopEl.disabled = isRunning }
const timerToggle = _hostedPanelEl.querySelector('#hosted-agent-timer-on')
const timerBody = _hostedPanelEl.querySelector('#ha-timer-body')
if (timerToggle) { timerToggle.checked = (_hostedSessionConfig.autoStopMinutes || 0) > 0; timerToggle.disabled = isRunning }
if (timerBody) timerBody.style.display = timerToggle?.checked ? '' : 'none'
if (_hostedSaveBtn) {
_hostedSaveBtn.textContent = isRunning ? '⏹ 停止托管' : '▶ 启动托管'
_hostedSaveBtn.className = isRunning ? 'btn btn-ghost' : 'btn btn-primary'
_hostedSaveBtn.style.flex = '1'
}
// 主按钮同时作为停止按钮,无需额外 stop btn
// 状态栏
const statusEl = _hostedPanelEl.querySelector('#hosted-agent-status')
if (statusEl) {
let msg = '就绪'
if (_hostedRuntime.lastError) msg = `错误: ${_hostedRuntime.lastError}`
else if (isRunning) {
const remaining = Math.max(0, _hostedSessionConfig.maxSteps - _hostedRuntime.stepCount)
msg = `运行中 · 剩余 ${remaining}`
}
statusEl.textContent = msg
}
// 倒计时
updateCountdown()
}
function updateCountdown() {
const cdEl = _hostedPanelEl?.querySelector('#ha-countdown')
const fillEl = _hostedPanelEl?.querySelector('#ha-countdown-fill')
const textEl = _hostedPanelEl?.querySelector('#ha-countdown-text')
if (!cdEl || !fillEl || !textEl) return
if (!_hostedAutoStopTimer || !_hostedStartTime || !_hostedSessionConfig?.autoStopMinutes) {
cdEl.style.display = 'none'
clearInterval(_countdownInterval); _countdownInterval = null
return
}
cdEl.style.display = ''
const totalMs = _hostedSessionConfig.autoStopMinutes * 60000
const elapsed = Date.now() - _hostedStartTime
const remaining = Math.max(0, totalMs - elapsed)
const pct = Math.max(0, Math.min(100, (remaining / totalMs) * 100))
fillEl.style.width = pct + '%'
const mins = Math.floor(remaining / 60000)
const secs = Math.floor((remaining % 60000) / 1000)
textEl.textContent = `剩余 ${mins}:${secs.toString().padStart(2, '0')}`
if (!_countdownInterval) {
_countdownInterval = setInterval(() => updateCountdown(), 1000)
}
if (remaining <= 0) { clearInterval(_countdownInterval); _countdownInterval = null }
}
function toggleHostedRun() {
if (!_hostedSessionConfig) return
if (_hostedSessionConfig.enabled && _hostedRuntime.status !== HOSTED_STATUS.IDLE) {
stopHostedAgent()
} else {
startHostedAgent()
}
}
async function startHostedAgent() {
if (!_hostedSessionConfig) return
const prompt = (_hostedPromptEl?.value || '').trim()
if (!prompt) { toast('请输入任务目标', 'warning'); return }
const rawSteps = parseInt(_hostedMaxStepsEl?.value || HOSTED_DEFAULTS.maxSteps, 10)
const maxSteps = rawSteps >= 205 ? 999999 : Math.max(1, rawSteps)
const stepDelayMs = Math.max(200, parseInt(_hostedStepDelayEl?.value || HOSTED_DEFAULTS.stepDelayMs, 10))
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 sysContent = HOSTED_SYSTEM_PROMPT + '\n\n用户目标: ' + prompt
if (!_hostedSessionConfig.history?.length) _hostedSessionConfig.history = [{ role: 'system', content: sysContent }]
else if (_hostedSessionConfig.history[0]?.role === 'system') _hostedSessionConfig.history[0].content = sysContent
else _hostedSessionConfig.history.unshift({ role: 'system', content: sysContent })
_hostedRuntime = { ...HOSTED_RUNTIME_DEFAULT, status: HOSTED_STATUS.RUNNING }
_hostedStartTime = Date.now()
persistHostedRuntime()
renderHostedPanel()
updateHostedBadge()
// 启动定时停止
clearTimeout(_hostedAutoStopTimer)
if (autoStopMinutes > 0) {
_hostedAutoStopTimer = setTimeout(() => {
appendHostedOutput(`定时 ${autoStopMinutes} 分钟已到,自动停止`)
stopHostedAgent()
}, autoStopMinutes * 60000)
}
if (!wsClient.gatewayReady || !_sessionKey) { toast('Gateway 未就绪,暂不启动', 'warning'); return }
toast('托管 Agent 已启动', 'success')
runHostedAgentStep()
}
function stopHostedAgent() {
if (!_hostedSessionConfig) return
if (_hostedAbort) { _hostedAbort.abort(); _hostedAbort = null }
clearTimeout(_hostedAutoStopTimer); _hostedAutoStopTimer = null
clearInterval(_countdownInterval); _countdownInterval = null
_hostedBusy = false
_hostedSessionConfig.enabled = false
_hostedRuntime.status = HOSTED_STATUS.IDLE
_hostedRuntime.pending = false
_hostedRuntime.stepCount = 0
_hostedRuntime.lastError = ''
_hostedRuntime.errorCount = 0
_hostedStartTime = 0
persistHostedRuntime()
renderHostedPanel()
updateHostedBadge()
toast('托管 Agent 已停止', 'info')
}
function shouldCaptureHostedTarget(payload) {
if (!_hostedSessionConfig?.enabled) 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()
if (ts && ts === _hostedLastTargetTs) return false
_hostedLastTargetTs = ts
return true
}
function appendHostedTarget(text) {
if (!_hostedSessionConfig) return
if (!_hostedSessionConfig.history) _hostedSessionConfig.history = []
_hostedSessionConfig.history.push({ role: 'target', content: text, ts: Date.now() })
persistHostedRuntime()
}
function maybeTriggerHostedRun() {
if (!_hostedSessionConfig?.enabled) return
if (_hostedRuntime.status === HOSTED_STATUS.IDLE || _hostedRuntime.status === HOSTED_STATUS.PAUSED || _hostedRuntime.status === HOSTED_STATUS.ERROR) return
if (_hostedRuntime.pending || _hostedBusy) return
if (!wsClient.gatewayReady) { _hostedRuntime.status = HOSTED_STATUS.PAUSED; persistHostedRuntime(); updateHostedBadge(); renderHostedPanel(); return }
_hostedRuntime.status = HOSTED_STATUS.IDLE
runHostedAgentStep()
}
function compressHostedContext() {
if (!_hostedSessionConfig?.history) return
const history = _hostedSessionConfig.history
if (history.length <= HOSTED_COMPRESS_THRESHOLD) return
const sysEntry = history[0]?.role === 'system' ? history[0] : null
const recent = history.slice(-8)
const older = history.slice(sysEntry ? 1 : 0, -8)
const summary = older.map(h => `[${h.role}] ${(h.content || '').slice(0, 80)}`).join('\n')
const compressed = []
if (sysEntry) compressed.push(sysEntry)
compressed.push({ role: 'user', content: `[上下文摘要 - 已压缩 ${older.length} 条历史]\n${summary}`, ts: Date.now() })
compressed.push(...recent)
_hostedSessionConfig.history = compressed
persistHostedRuntime()
}
function buildHostedMessages() {
compressHostedContext()
const history = _hostedSessionConfig?.history || []
const mapped = history.slice(-HOSTED_CONTEXT_MAX).map(item => {
if (item.role === 'system') return { role: 'system', content: item.content }
if (item.role === 'assistant') return { role: 'assistant', content: item.content }
return { role: 'user', content: item.content }
})
const hasUserMsg = mapped.some(m => m.role === 'user' || m.role === 'assistant')
if (!hasUserMsg && _hostedSessionConfig?.prompt) {
mapped.push({ role: 'user', content: _hostedSessionConfig.prompt })
}
return mapped
}
function detectStopFromText(text) {
if (!text) return false
return /\b(完成|无需继续|结束|停止|done|stop|final)\b/i.test(text)
}
async function runHostedAgentStep() {
if (_hostedBusy || !_hostedSessionConfig?.enabled) return
const prompt = (_hostedSessionConfig.prompt || '').trim()
if (!prompt) return
if (!wsClient.gatewayReady || !_sessionKey) {
_hostedRuntime.status = HOSTED_STATUS.PAUSED
_hostedRuntime.lastError = 'Gateway 未就绪'
persistHostedRuntime(); updateHostedBadge()
appendHostedOutput('需要人工介入: Gateway 未就绪')
return
}
if (_hostedRuntime.errorCount >= _hostedSessionConfig.retryLimit) {
_hostedRuntime.status = HOSTED_STATUS.ERROR
persistHostedRuntime(); updateHostedBadge()
appendHostedOutput('需要人工介入: 连续错误超过阈值')
return
}
if (_hostedRuntime.stepCount >= _hostedSessionConfig.maxSteps) {
_hostedRuntime.status = HOSTED_STATUS.IDLE
persistHostedRuntime(); updateHostedBadge()
return
}
_hostedBusy = true
_hostedRuntime.pending = true
_hostedRuntime.status = HOSTED_STATUS.RUNNING
_hostedRuntime.lastRunAt = Date.now()
_hostedRuntime.lastRunId = uuid()
persistHostedRuntime(); updateHostedBadge()
const delay = _hostedSessionConfig.stepDelayMs || HOSTED_DEFAULTS.stepDelayMs
if (delay > 0) await new Promise(r => setTimeout(r, delay))
try {
const messages = buildHostedMessages()
let resultText = ''
await callHostedAI(messages, (chunk) => { resultText += chunk })
_hostedRuntime.stepCount += 1
_hostedRuntime.errorCount = 0
_hostedRuntime.lastError = ''
_hostedSessionConfig.history.push({ role: 'assistant', content: resultText, ts: Date.now() })
persistHostedRuntime()
appendHostedOutput(resultText + ` | step=${_hostedRuntime.stepCount}`)
// 如果 AI 回复中有「执行命令」类内容,通过 Gateway 发送给 Agent
const instruction = resultText.trim()
if (instruction && !detectStopFromText(instruction)) {
_hostedRuntime.status = HOSTED_STATUS.WAITING
_hostedRuntime.pending = false
persistHostedRuntime(); updateHostedBadge()
// 将指令发给 Gateway Agent
try { await wsClient.chatSend(_sessionKey, instruction) } catch {}
} else {
_hostedRuntime.status = HOSTED_STATUS.IDLE
_hostedRuntime.pending = false
persistHostedRuntime(); updateHostedBadge()
}
} catch (e) {
_hostedRuntime.errorCount = (_hostedRuntime.errorCount || 0) + 1
_hostedRuntime.lastError = e.message || String(e)
_hostedRuntime.pending = false
if (_hostedRuntime.errorCount >= _hostedSessionConfig.retryLimit) {
_hostedRuntime.status = HOSTED_STATUS.ERROR
persistHostedRuntime(); updateHostedBadge()
appendHostedOutput('需要人工介入: ' + _hostedRuntime.lastError)
return
}
persistHostedRuntime(); updateHostedBadge()
setTimeout(() => { _hostedBusy = false; runHostedAgentStep() }, delay)
return
} finally {
_hostedBusy = false
}
}
async function callHostedAI(messages, onChunk) {
let config
try {
const raw = localStorage.getItem('clawpanel-assistant')
const stored = raw ? JSON.parse(raw) : {}
config = { baseUrl: stored.baseUrl || '', apiKey: stored.apiKey || '', model: stored.model || '', temperature: stored.temperature || 0.7, apiType: stored.apiType || 'openai-completions' }
} catch { config = { baseUrl: '', apiKey: '', model: '', temperature: 0.7, apiType: 'openai-completions' } }
if (!config.baseUrl || !config.model) throw new Error('托管 Agent 未配置模型(请在 AI 助手页面配置)')
let base = config.baseUrl.replace(/\/+$/, '').replace(/\/chat\/completions\/?$/, '').replace(/\/completions\/?$/, '').replace(/\/messages\/?$/, '').replace(/\/models\/?$/, '')
if (_hostedAbort) { _hostedAbort.abort(); _hostedAbort = null }
_hostedAbort = new AbortController()
const signal = _hostedAbort.signal
const timeout = setTimeout(() => { if (_hostedAbort) _hostedAbort.abort() }, 120000)
try {
const headers = { 'Content-Type': 'application/json' }
if (config.apiKey) headers['Authorization'] = `Bearer ${config.apiKey}`
const body = { model: config.model, messages, stream: true, temperature: config.temperature || 0.7 }
const resp = await fetch(base + '/chat/completions', { method: 'POST', headers, body: JSON.stringify(body), signal })
if (!resp.ok) {
const errText = await resp.text().catch(() => '')
let errMsg = `API 错误 ${resp.status}`
try { errMsg = JSON.parse(errText).error?.message || errMsg } catch {}
throw new Error(errMsg)
}
const reader = resp.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed || !trimmed.startsWith('data:')) continue
const data = trimmed.slice(5).trim()
if (data === '[DONE]') return
try { const json = JSON.parse(data); if (json.choices?.[0]?.delta?.content) onChunk(json.choices[0].delta.content) } catch {}
}
}
} finally {
clearTimeout(timeout)
_hostedAbort = null
}
}
function appendHostedOutput(text) {
if (!text || !_messagesEl) return
const wrap = document.createElement('div')
wrap.className = 'msg msg-system msg-hosted'
wrap.textContent = `[托管 Agent] ${text}`
_messagesEl.insertBefore(wrap, _typingEl)
scrollToBottom()
}
// ── 页面离开清理 ──
export function cleanup() {
@@ -1936,7 +2430,7 @@ export function cleanup() {
if (_unsubReady) { _unsubReady(); _unsubReady = null }
if (_unsubStatus) { _unsubStatus(); _unsubStatus = null }
clearTimeout(_streamSafetyTimer)
// 不断开 wsClient —— 它是全局单例,保持连接供下次进入复用
if (_hostedAbort) { _hostedAbort.abort(); _hostedAbort = null }
_sessionKey = null
_page = null
_messagesEl = null
@@ -1959,4 +2453,21 @@ export function cleanup() {
_isSending = false
_messageQueue = []
_lastHistoryHash = ''
_hostedBtn = null
_hostedPanelEl = null
_hostedBadgeEl = null
_hostedPromptEl = null
_hostedEnableEl = null
_hostedMaxStepsEl = null
_hostedStepDelayEl = null
_hostedRetryLimitEl = null
_hostedSaveBtn = null
_hostedPauseBtn = null
_hostedStopBtn = null
_hostedCloseBtn = null
_hostedGlobalSyncEl = null
_hostedSessionConfig = null
_hostedDefaults = null
_hostedRuntime = { ...HOSTED_RUNTIME_DEFAULT }
_hostedBusy = false
}

View File

@@ -90,7 +90,7 @@ async function loadDashboardData(page, fullRefresh = false) {
// 第一波:服务状态 + 配置 + 版本 → 立即渲染统计卡片
const [servicesRes, configRes, versionRes] = await coreP
const services = servicesRes.status === 'fulfilled' ? servicesRes.value : []
const version = versionRes.status === 'fulfilled' ? versionRes.value : {}
const version = (versionRes.status === 'fulfilled' && versionRes.value) ? versionRes.value : {}
const config = configRes.status === 'fulfilled' ? configRes.value : null
if (servicesRes.status === 'rejected') toast('服务状态加载失败', 'error')
if (versionRes.status === 'rejected') toast('版本信息加载失败', 'error')

View File

@@ -10,6 +10,8 @@
height: 100%;
overflow: hidden;
padding: 0 !important;
max-width: none !important;
width: 100%;
}
.chat-main {
@@ -376,7 +378,8 @@
color: var(--text-primary);
outline: none;
transition: border-color 0.15s;
max-height: 150px;
min-height: 44px;
max-height: 200px;
font-family: inherit;
}
@@ -996,3 +999,176 @@
padding: 6px;
}
}
/* 托管 Agent */
.chat-hosted-btn {
display: flex;
align-items: center;
gap: 4px;
height: 40px;
border-radius: var(--radius-md, 8px);
padding: 0 10px;
border: 1px solid var(--border);
background: var(--bg-secondary, var(--bg-card));
cursor: pointer;
flex-shrink: 0;
transition: background 0.15s, border-color 0.15s;
}
.chat-hosted-btn:hover {
background: var(--bg-hover);
border-color: var(--border-primary);
}
.chat-hosted-label { font-weight: 600; font-size: 14px; }
.chat-hosted-badge {
font-size: 11px;
padding: 2px 6px;
border-radius: 4px;
font-weight: 500;
}
.chat-hosted-badge.running { background: rgba(34, 197, 94, 0.12); color: #22c55e; }
.chat-hosted-badge.waiting { background: rgba(245, 158, 11, 0.12); color: #f59e0b; }
.chat-hosted-badge.paused { background: rgba(148, 163, 184, 0.2); color: #94a3b8; }
.chat-hosted-badge.error { background: rgba(239, 68, 68, 0.12); color: #ef4444; }
.chat-hosted-badge.idle { background: rgba(100, 116, 139, 0.12); color: #94a3b8; }
.hosted-agent-panel {
position: absolute;
right: 16px;
bottom: 80px;
width: 340px;
background: var(--bg-secondary, #fff);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0,0,0,0.18);
z-index: 30;
overflow: hidden;
}
.hosted-agent-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px;
border-bottom: 1px solid var(--border);
font-size: 14px;
}
.hosted-agent-close {
background: none;
border: none;
font-size: 18px;
cursor: pointer;
color: var(--text-secondary);
padding: 4px 8px;
border-radius: 4px;
}
.hosted-agent-close:hover { background: var(--bg-hover); }
.hosted-agent-body {
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
max-height: 480px;
overflow-y: auto;
flex: 1;
min-height: 0;
}
.hosted-agent-prompt { min-height: 72px; resize: vertical; }
.hosted-agent-switch {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 0;
font-size: 13px;
cursor: pointer;
}
.hosted-agent-switch input { display: none; }
.hosted-agent-track {
width: 34px;
height: 18px;
border-radius: 9px;
background: var(--bg-tertiary);
position: relative;
transition: background 0.2s;
flex-shrink: 0;
}
.hosted-agent-track::after {
content: '';
position: absolute;
width: 14px;
height: 14px;
border-radius: 50%;
background: #fff;
top: 2px;
left: 2px;
transition: transform 0.2s;
}
.hosted-agent-switch input:checked + .hosted-agent-track { background: var(--accent); }
.hosted-agent-switch input:checked + .hosted-agent-track::after { transform: translateX(16px); }
.hosted-agent-row {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
padding: 2px 0;
}
.hosted-agent-tag { color: var(--text-tertiary); }
.hosted-agent-value { color: var(--text-secondary); font-weight: 500; }
.hosted-agent-advanced {
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 8px 10px;
}
.hosted-agent-advanced-title {
font-size: 11px;
color: var(--text-tertiary);
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.3px;
}
/* 滑块控件 */
.ha-slider-group { padding: 4px 0; }
.ha-slider-label { font-size: 12px; color: var(--text-secondary); margin-bottom: 4px; display: flex; align-items: center; justify-content: space-between; }
.ha-slider-val { font-weight: 700; color: var(--accent); font-size: 14px; }
.ha-slider { width: 100%; height: 6px; -webkit-appearance: none; appearance: none; background: var(--bg-tertiary); border-radius: 3px; outline: none; cursor: pointer; }
.ha-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 18px; height: 18px; border-radius: 50%; background: var(--accent); cursor: pointer; box-shadow: 0 1px 4px rgba(0,0,0,0.2); }
.ha-slider:disabled { opacity: 0.4; cursor: not-allowed; }
.ha-slider-ticks { display: flex; justify-content: space-between; font-size: 10px; color: var(--text-tertiary); margin-top: 2px; }
/* 定时器 */
.ha-timer-group { border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; }
.ha-timer-header { display: flex; align-items: center; justify-content: space-between; font-size: 13px; color: var(--text-secondary); }
.ha-toggle { display: inline-flex; cursor: pointer; }
.ha-toggle input { display: none; }
.ha-toggle-track { width: 34px; height: 18px; border-radius: 9px; background: var(--bg-tertiary); position: relative; transition: background 0.2s; }
.ha-toggle-track::after { content: ''; position: absolute; width: 14px; height: 14px; border-radius: 50%; background: #fff; top: 2px; left: 2px; transition: transform 0.2s; }
.ha-toggle input:checked + .ha-toggle-track { background: var(--accent); }
.ha-toggle input:checked + .ha-toggle-track::after { transform: translateX(16px); }
.ha-timer-body { margin-top: 8px; }
/* 倒计时 */
.ha-countdown { margin-top: 8px; }
.ha-countdown-bar { height: 6px; background: var(--bg-tertiary); border-radius: 3px; overflow: hidden; }
.ha-countdown-fill { height: 100%; background: linear-gradient(90deg, var(--accent), var(--success, #22c55e)); border-radius: 3px; transition: width 1s linear; }
.ha-countdown-text { font-size: 11px; color: var(--text-tertiary); margin-top: 2px; display: block; text-align: right; }
.hosted-agent-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.hosted-agent-actions {
display: flex;
gap: 6px;
padding: 10px 12px;
border-top: 1px solid var(--border);
flex-shrink: 0;
justify-content: center;
}
.hosted-agent-link { color: var(--accent); text-decoration: none; font-weight: 500; }
.hosted-agent-link:hover { text-decoration: underline; }
.hosted-agent-footer {
padding: 8px 12px;
border-top: 1px solid var(--border);
font-size: 11px;
color: var(--text-tertiary);
}
.msg-hosted {
background: rgba(100, 116, 139, 0.08);
border: 1px dashed rgba(148, 163, 184, 0.4);
border-radius: 8px;
padding: 6px 10px;
font-size: 12px;
line-height: 1.5;
}

View File

@@ -187,15 +187,21 @@
padding: var(--space-md) var(--space-lg);
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
backdrop-filter: blur(12px);
background: var(--bg-primary);
border: 1px solid var(--border);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
animation: slideIn 250ms ease;
max-width: 360px;
}
.toast.success { background: var(--success-muted); border: 1px solid rgba(34,197,94,0.3); color: var(--success); }
.toast.error { background: var(--error-muted); border: 1px solid rgba(239,68,68,0.3); color: var(--error); }
.toast.info { background: var(--info-muted); border: 1px solid rgba(59,130,246,0.3); color: var(--info); }
.toast.warning { background: var(--warning-muted); border: 1px solid rgba(245,158,11,0.3); color: var(--warning); }
.toast.success { color: var(--success); }
.toast.error { color: var(--error); }
.toast.info { color: var(--info); }
.toast.warning { color: var(--warning); }
[data-theme="dark"] .toast {
background: var(--bg-secondary);
}
@keyframes slideIn {
from { opacity: 0; transform: translateX(20px); }