mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-06 20:02:49 +08:00
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:
34
dev.ps1
34
dev.ps1
@@ -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
34
src-tauri/Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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;))*?"|'(?:[^&]|&(?!#x27;))*?'|`[^`]*`)/g,
|
||||
.replace(/("(?:[^&]|&(?!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, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
// 预加载 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, '\')
|
||||
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 : '#'
|
||||
|
||||
@@ -156,6 +156,13 @@ export async function checkBackendHealth() {
|
||||
}
|
||||
}
|
||||
|
||||
// 配置保存后防抖重载 Gateway(3 秒内多次写入只触发一次重载)
|
||||
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'),
|
||||
|
||||
@@ -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="关闭">×</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
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user