v0.4.0: Gateway 进程守护、配置自愈、双配置同步、流式超时、模型删除安全切换

This commit is contained in:
晴天
2026-03-05 20:44:47 +08:00
parent d27d5cc8af
commit 79cd15e1c4
30 changed files with 2257 additions and 295 deletions

View File

@@ -5,6 +5,7 @@
import { api } from '../lib/tauri-api.js'
import { toast } from '../components/toast.js'
import { showUpgradeModal } from '../components/modal.js'
import { setUpgrading } from '../lib/app-state.js'
export async function render() {
const page = document.createElement('div')
@@ -15,7 +16,7 @@ export async function render() {
<img src="/images/logo-brand.png" alt="ClawPanel" style="height:48px;width:auto">
<div>
<h1 class="page-title" style="margin:0">ClawPanel</h1>
<p class="page-desc" style="margin:0">OpenClaw 可视化管理面板</p>
<p class="page-desc" style="margin:0">OpenClaw 可视化管理面板 · <a href="https://claw.qt.cool" target="_blank" rel="noopener" style="color:var(--primary)">claw.qt.cool</a></p>
</div>
</div>
<div class="stat-cards" id="version-cards">
@@ -119,17 +120,25 @@ async function loadData(page) {
upgradeBtn.onclick = async () => {
const modal = showUpgradeModal()
let unlistenLog, unlistenProgress
setUpgrading(true)
try {
const { listen } = await import('@tauri-apps/api/event')
unlistenLog = await listen('upgrade-log', (e) => modal.appendLog(e.payload))
unlistenProgress = await listen('upgrade-progress', (e) => modal.setProgress(e.payload))
if (window.__TAURI_INTERNALS__) {
try {
const { listen } = await import('@tauri-apps/api/event')
unlistenLog = await listen('upgrade-log', (e) => modal.appendLog(e.payload))
unlistenProgress = await listen('upgrade-progress', (e) => modal.setProgress(e.payload))
} catch { /* Web 模式无 Tauri event */ }
} else {
modal.appendLog('Web 模式:升级过程日志不可用,请等待完成...')
}
const msg = await api.upgradeOpenclaw()
modal.setDone(msg)
modal.setDone(typeof msg === 'string' ? msg : (msg?.message || '升级完成'))
loadData(page)
} catch (e) {
modal.appendLog(String(e))
modal.setError('升级失败')
} finally {
setUpgrading(false)
unlistenLog?.()
unlistenProgress?.()
}
@@ -220,6 +229,7 @@ function renderProjects(page) {
}
const LINKS = [
{ label: 'Claw 项目官网', url: 'https://claw.qt.cool', primary: true },
{ label: 'cftunnel 官网', url: 'https://cftunnel.qt.cool' },
{ label: 'cftunnel 桌面客户端', url: 'https://github.com/qingchencloud/cftunnel-app/releases' },
{ label: 'OpenClaw 中文翻译', url: 'https://github.com/1186258278/OpenClawChineseTranslation' },
@@ -244,6 +254,6 @@ function renderContribute(page) {
function renderLinks(page) {
const el = page.querySelector('#links-list')
el.innerHTML = `<div style="display:flex;flex-wrap:wrap;gap:var(--space-sm)">
${LINKS.map(l => `<a class="btn btn-secondary btn-sm" href="${l.url}" target="_blank" rel="noopener">${l.label}</a>`).join('')}
${LINKS.map(l => `<a class="btn ${l.primary ? 'btn-primary' : 'btn-secondary'} btn-sm" href="${l.url}" target="_blank" rel="noopener">${l.label}</a>`).join('')}
</div>`
}

View File

@@ -40,13 +40,14 @@ const COMMANDS = [
let _sessionKey = null, _page = null, _messagesEl = null, _textarea = null
let _sendBtn = null, _statusDot = null, _typingEl = null, _scrollBtn = null
let _sessionListEl = null, _cmdPanelEl = null, _attachPreviewEl = null, _fileInputEl = null
let _currentAiBubble = null, _currentAiText = '', _currentAiImages = [], _currentRunId = null
let _isStreaming = false, _isSending = false, _messageQueue = []
let _currentAiBubble = null, _currentAiText = '', _currentAiImages = [], _currentAiVideos = [], _currentAiAudios = [], _currentAiFiles = [], _currentRunId = null
let _isStreaming = false, _isSending = false, _messageQueue = [], _streamStartTime = 0
let _lastRenderTime = 0, _renderPending = false, _lastHistoryHash = ''
let _streamSafetyTimer = null, _unsubEvent = null, _unsubReady = null, _unsubStatus = null
let _pageActive = false
let _errorTimer = null, _lastErrorMsg = null
let _attachments = []
let _hasEverConnected = false
export async function render() {
const page = document.createElement('div')
@@ -317,6 +318,7 @@ async function connectGateway() {
const overlay = document.getElementById('chat-connect-overlay')
const desc = document.getElementById('chat-connect-desc')
if (status === 'ready' || status === 'connected') {
_hasEverConnected = true
if (bar) bar.style.display = 'none'
if (overlay) overlay.style.display = 'none'
} else if (status === 'error') {
@@ -327,7 +329,12 @@ async function connectGateway() {
if (desc) desc.textContent = errorMsg || '连接 Gateway 失败'
}
} else if (status === 'reconnecting' || status === 'disconnected') {
if (bar) { bar.textContent = '连接已断开,正在重连...'; bar.style.display = 'flex' }
// 首次连接或多次重连失败时,显示引导遮罩而非底部小条
if (!_hasEverConnected) {
if (overlay) { overlay.style.display = 'flex'; if (desc) desc.textContent = '正在连接 Gateway...' }
} else {
if (bar) { bar.textContent = '连接已断开,正在重连...'; bar.style.display = 'flex' }
}
} else {
if (bar) bar.style.display = 'none'
}
@@ -380,7 +387,7 @@ async function connectGateway() {
// 未连接,发起新连接
const config = await api.readOpenclawConfig()
const gw = config?.gateway || {}
const host = `127.0.0.1:${gw.port || 18789}`
const host = window.__TAURI_INTERNALS__ ? `127.0.0.1:${gw.port || 18789}` : location.host
const token = gw.auth?.token || gw.authToken || ''
wsClient.connect(host, token)
} catch (e) {
@@ -592,7 +599,10 @@ function sendMessage() {
async function doSend(text, attachments = []) {
appendUserMessage(text, attachments)
saveMessage({ id: uuid(), sessionKey: _sessionKey, role: 'user', content: text, timestamp: Date.now() })
saveMessage({
id: uuid(), sessionKey: _sessionKey, role: 'user', content: text, timestamp: Date.now(),
attachments: attachments?.length ? attachments.map(a => ({ category: a.category || 'image', mimeType: a.mimeType || '', content: a.content || '', url: a.url || '' })) : undefined
})
showTyping(true)
_isSending = true
try {
@@ -635,15 +645,32 @@ function handleChatEvent(payload) {
if (state === 'delta') {
const c = extractChatContent(payload.message)
if (c?.images?.length) _currentAiImages = c.images
if (c?.videos?.length) _currentAiVideos = c.videos
if (c?.audios?.length) _currentAiAudios = c.audios
if (c?.files?.length) _currentAiFiles = c.files
if (c?.text && c.text.length > _currentAiText.length) {
showTyping(false)
if (!_currentAiBubble) {
_currentAiBubble = createStreamBubble()
_currentRunId = payload.runId
_isStreaming = true
_streamStartTime = Date.now()
updateSendState()
}
_currentAiText = c.text
// 每次收到 delta 重置安全超时90s 无新 delta 则强制结束)
clearTimeout(_streamSafetyTimer)
_streamSafetyTimer = setTimeout(() => {
if (_isStreaming) {
console.warn('[chat] 流式输出超时90s 无新数据),强制结束')
if (_currentAiBubble && _currentAiText) {
_currentAiBubble.innerHTML = renderMarkdown(_currentAiText)
}
appendSystemMessage('输出超时,已自动结束')
resetStreamState()
processMessageQueue()
}
}, 90000)
throttledRender()
}
return
@@ -653,8 +680,14 @@ function handleChatEvent(payload) {
const c = extractChatContent(payload.message)
const finalText = c?.text || ''
const finalImages = c?.images || []
const finalVideos = c?.videos || []
const finalAudios = c?.audios || []
const finalFiles = c?.files || []
if (finalImages.length) _currentAiImages = finalImages
const hasContent = finalText || _currentAiImages.length
if (finalVideos.length) _currentAiVideos = finalVideos
if (finalAudios.length) _currentAiAudios = finalAudios
if (finalFiles.length) _currentAiFiles = finalFiles
const hasContent = finalText || _currentAiImages.length || _currentAiVideos.length || _currentAiAudios.length || _currentAiFiles.length
// 忽略空 finalGateway 会为一条消息触发多个 run部分是空 final
if (!_currentAiBubble && !hasContent) return
showTyping(false)
@@ -666,9 +699,32 @@ function handleChatEvent(payload) {
if (_currentAiBubble) {
if (_currentAiText) _currentAiBubble.innerHTML = renderMarkdown(_currentAiText)
appendImagesToEl(_currentAiBubble, _currentAiImages)
appendVideosToEl(_currentAiBubble, _currentAiVideos)
appendAudiosToEl(_currentAiBubble, _currentAiAudios)
appendFilesToEl(_currentAiBubble, _currentAiFiles)
}
if (_currentAiText) {
saveMessage({ id: payload.runId || uuid(), sessionKey: _sessionKey, role: 'assistant', content: _currentAiText, timestamp: Date.now() })
// 添加时间戳 + 耗时
const wrapper = _currentAiBubble?.parentElement
if (wrapper) {
const time = document.createElement('div')
time.className = 'msg-time'
let timeStr = formatTime(new Date())
// 计算响应耗时
if (payload.durationMs) {
timeStr += ` · ${(payload.durationMs / 1000).toFixed(1)}s`
} else if (_streamStartTime) {
const dur = ((Date.now() - _streamStartTime) / 1000).toFixed(1)
timeStr += ` · ${dur}s`
}
time.textContent = timeStr
wrapper.appendChild(time)
}
if (_currentAiText || _currentAiImages.length) {
saveMessage({
id: payload.runId || uuid(), sessionKey: _sessionKey, role: 'assistant',
content: _currentAiText, timestamp: Date.now(),
attachments: _currentAiImages.map(i => ({ category: 'image', mimeType: i.mediaType || 'image/png', url: i.url, content: i.data })).filter(a => a.url || a.content)
})
}
resetStreamState()
processMessageQueue()
@@ -724,26 +780,75 @@ function handleChatEvent(payload) {
}
}
/** 从 Gateway message 对象提取文本和图片(参照 clawapp extractContent */
/** 从 Gateway message 对象提取文本和所有媒体(参照 clawapp extractContent */
function extractChatContent(message) {
if (!message || typeof message !== 'object') return null
const content = message.content
const images = []
if (typeof content === 'string') return { text: content, images }
if (typeof content === 'string') return { text: stripThinkingTags(content), images: [], videos: [], audios: [], files: [] }
if (Array.isArray(content)) {
const texts = []
const texts = [], images = [], videos = [], audios = [], files = []
for (const block of content) {
if (block.type === 'text' && typeof block.text === 'string') texts.push(block.text)
if (block.type === 'image') images.push(block)
if (block.type === 'image_url') images.push(block)
else if (block.type === 'image' && !block.omitted) {
if (block.data) images.push({ mediaType: block.mimeType || 'image/png', data: block.data })
else if (block.source?.type === 'base64' && block.source.data) images.push({ mediaType: block.source.media_type || 'image/png', data: block.source.data })
else if (block.url || block.source?.url) images.push({ url: block.url || block.source.url, mediaType: block.mimeType || 'image/png' })
}
else if (block.type === 'image_url' && block.image_url?.url) images.push({ url: block.image_url.url, mediaType: 'image/png' })
else if (block.type === 'video') {
if (block.data) videos.push({ mediaType: block.mimeType || 'video/mp4', data: block.data })
else if (block.url) videos.push({ url: block.url, mediaType: block.mimeType || 'video/mp4' })
}
else if (block.type === 'audio' || block.type === 'voice') {
if (block.data) audios.push({ mediaType: block.mimeType || 'audio/mpeg', data: block.data, duration: block.duration })
else if (block.url) audios.push({ url: block.url, mediaType: block.mimeType || 'audio/mpeg', duration: block.duration })
}
else if (block.type === 'file' || block.type === 'document') {
files.push({ url: block.url || '', name: block.fileName || block.name || '文件', mimeType: block.mimeType || '', size: block.size, data: block.data })
}
}
const text = texts.length ? texts.join('\n') : ''
return { text, images }
// 从 mediaUrl/mediaUrls 提取
const mediaUrls = message.mediaUrls || (message.mediaUrl ? [message.mediaUrl] : [])
for (const url of mediaUrls) {
if (!url) continue
if (/\.(mp4|webm|mov|mkv)(\?|$)/i.test(url)) videos.push({ url, mediaType: 'video/mp4' })
else if (/\.(mp3|wav|ogg|m4a|aac|flac)(\?|$)/i.test(url)) audios.push({ url, mediaType: 'audio/mpeg' })
else if (/\.(jpe?g|png|gif|webp|heic|svg)(\?|$)/i.test(url)) images.push({ url, mediaType: 'image/png' })
else files.push({ url, name: url.split('/').pop().split('?')[0] || '文件', mimeType: '' })
}
const text = texts.length ? stripThinkingTags(texts.join('\n')) : ''
return { text, images, videos, audios, files }
}
if (typeof message.text === 'string') return { text: message.text, images }
if (typeof message.text === 'string') return { text: stripThinkingTags(message.text), images: [], videos: [], audios: [], files: [] }
return null
}
function stripThinkingTags(text) {
return text
.replace(/<\s*think(?:ing)?\s*>[\s\S]*?<\s*\/\s*think(?:ing)?\s*>/gi, '')
.replace(/Conversation info \(untrusted metadata\):\s*```json[\s\S]*?```\s*/gi, '')
.replace(/\[Queued messages while agent was busy\]\s*---\s*Queued #\d+\s*/gi, '')
.trim()
}
function formatTime(date) {
const now = new Date()
const h = date.getHours().toString().padStart(2, '0')
const m = date.getMinutes().toString().padStart(2, '0')
const isToday = date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth() && date.getDate() === now.getDate()
if (isToday) return `${h}:${m}`
const mon = (date.getMonth() + 1).toString().padStart(2, '0')
const day = date.getDate().toString().padStart(2, '0')
return `${mon}-${day} ${h}:${m}`
}
function formatFileSize(bytes) {
if (!bytes || bytes <= 0) return ''
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
/** 创建流式 AI 气泡 */
function createStreamBubble() {
showTyping(false)
@@ -783,17 +888,24 @@ function doRender() {
function resetStreamState() {
clearTimeout(_streamSafetyTimer)
if (_currentAiBubble && (_currentAiText || _currentAiImages.length)) {
if (_currentAiBubble && (_currentAiText || _currentAiImages.length || _currentAiVideos.length || _currentAiAudios.length || _currentAiFiles.length)) {
_currentAiBubble.innerHTML = renderMarkdown(_currentAiText)
appendImagesToEl(_currentAiBubble, _currentAiImages)
appendVideosToEl(_currentAiBubble, _currentAiVideos)
appendAudiosToEl(_currentAiBubble, _currentAiAudios)
appendFilesToEl(_currentAiBubble, _currentAiFiles)
}
_renderPending = false
_lastRenderTime = 0
_currentAiBubble = null
_currentAiText = ''
_currentAiImages = []
_currentAiVideos = []
_currentAiAudios = []
_currentAiFiles = []
_currentRunId = null
_isStreaming = false
_streamStartTime = 0
_lastErrorMsg = null
_errorTimer = null
showTyping(false)
@@ -804,13 +916,19 @@ function resetStreamState() {
async function loadHistory() {
if (!_sessionKey) return
if (isStorageAvailable()) {
const hasExisting = _messagesEl?.querySelector('.msg')
if (!hasExisting && isStorageAvailable()) {
const local = await getLocalMessages(_sessionKey, 200)
if (local.length) {
clearMessages()
local.forEach(msg => {
if (msg.role === 'user') appendUserMessage(msg.content || '')
else appendAiMessage(msg.content || '')
if (!msg.content && !msg.attachments?.length) return
const msgTime = msg.timestamp ? new Date(msg.timestamp) : new Date()
if (msg.role === 'user') appendUserMessage(msg.content || '', msg.attachments || null, msgTime)
else if (msg.role === 'assistant') {
const images = (msg.attachments || []).filter(a => a.category === 'image').map(a => ({ mediaType: a.mimeType, data: a.content, url: a.url }))
appendAiMessage(msg.content || '', msgTime, images)
}
})
scrollToBottom()
}
@@ -824,23 +942,41 @@ async function loadHistory() {
}
const deduped = dedupeHistory(result.messages)
const hash = deduped.map(m => `${m.role}:${(m.text || '').length}`).join('|')
if (hash === _lastHistoryHash && _messagesEl.querySelector('.msg')) return
if (hash === _lastHistoryHash && hasExisting) return
_lastHistoryHash = hash
// 正在发送/流式输出时不全量重绘,避免覆盖本地乐观渲染
if (hasExisting && (_isSending || _isStreaming || _messageQueue.length > 0)) {
saveMessages(result.messages.map(m => {
const c = extractContent(m)
return { id: m.id || uuid(), sessionKey: _sessionKey, role: m.role, content: c?.text || '', timestamp: m.timestamp || Date.now() }
}))
return
}
clearMessages()
let hasOmittedImages = false
deduped.forEach(msg => {
if (!msg.text && !msg.images?.length && !msg.videos?.length && !msg.audios?.length && !msg.files?.length) return
const msgTime = msg.timestamp ? new Date(msg.timestamp) : new Date()
if (msg.role === 'user') {
const userImages = msg.images?.length ? msg.images.map(i => ({
const userAtts = msg.images?.length ? msg.images.map(i => ({
mimeType: i.mediaType || i.media_type || 'image/png',
content: i.data || i.source?.data || '',
category: 'image',
})).filter(a => a.content) : []
appendUserMessage(msg.text, userImages)
if (msg.images?.length && !userAtts.length) hasOmittedImages = true
appendUserMessage(msg.text, userAtts, msgTime)
} else if (msg.role === 'assistant') {
appendAiMessage(msg.text, msg.images)
appendAiMessage(msg.text, msgTime, msg.images, msg.videos, msg.audios, msg.files)
}
})
if (hasOmittedImages) {
appendSystemMessage('部分历史图片无法显示Gateway 不保留图片原始数据,仅当前会话内可见)')
}
saveMessages(result.messages.map(m => {
const c = extractContent(m)
return { id: m.id || uuid(), sessionKey: _sessionKey, role: m.role, content: c.text || '', timestamp: m.timestamp || Date.now() }
return { id: m.id || uuid(), sessionKey: _sessionKey, role: m.role, content: c?.text || '', timestamp: m.timestamp || Date.now() }
}))
scrollToBottom()
} catch (e) {
@@ -854,55 +990,109 @@ function dedupeHistory(messages) {
for (const msg of messages) {
if (msg.role === 'toolResult') continue
const c = extractContent(msg)
if (!c.text && !c.images.length) continue
if (!c.text && !c.images.length && !c.videos.length && !c.audios.length && !c.files.length) continue
const last = deduped[deduped.length - 1]
if (last && last.role === msg.role) {
if (msg.role === 'user' && last.text === c.text) continue
if (msg.role === 'assistant') {
// 同文本去重Gateway 重试产生的重复回复)
if (c.text && last.text === c.text) continue
// 不同文本则合并
last.text = [last.text, c.text].filter(Boolean).join('\n')
last.images = [...(last.images || []), ...c.images]
last.videos = [...(last.videos || []), ...c.videos]
last.audios = [...(last.audios || []), ...c.audios]
last.files = [...(last.files || []), ...c.files]
continue
}
}
deduped.push({ role: msg.role, text: c.text, images: c.images, timestamp: msg.timestamp })
deduped.push({ role: msg.role, text: c.text, images: c.images, videos: c.videos, audios: c.audios, files: c.files, timestamp: msg.timestamp })
}
return deduped
}
function extractContent(msg) {
const images = []
if (Array.isArray(msg.content)) {
const texts = []
const texts = [], images = [], videos = [], audios = [], files = []
for (const block of msg.content) {
if (block.type === 'text' && typeof block.text === 'string') texts.push(block.text)
if (block.type === 'image') images.push(block)
if (block.type === 'image_url') images.push(block)
else if (block.type === 'image' && !block.omitted) {
if (block.data) images.push({ mediaType: block.mimeType || 'image/png', data: block.data })
else if (block.source?.type === 'base64' && block.source.data) images.push({ mediaType: block.source.media_type || 'image/png', data: block.source.data })
else if (block.url || block.source?.url) images.push({ url: block.url || block.source.url, mediaType: block.mimeType || 'image/png' })
}
else if (block.type === 'image_url' && block.image_url?.url) images.push({ url: block.image_url.url, mediaType: 'image/png' })
else if (block.type === 'video') {
if (block.data) videos.push({ mediaType: block.mimeType || 'video/mp4', data: block.data })
else if (block.url) videos.push({ url: block.url, mediaType: block.mimeType || 'video/mp4' })
}
else if (block.type === 'audio' || block.type === 'voice') {
if (block.data) audios.push({ mediaType: block.mimeType || 'audio/mpeg', data: block.data, duration: block.duration })
else if (block.url) audios.push({ url: block.url, mediaType: block.mimeType || 'audio/mpeg', duration: block.duration })
}
else if (block.type === 'file' || block.type === 'document') {
files.push({ url: block.url || '', name: block.fileName || block.name || '文件', mimeType: block.mimeType || '', size: block.size, data: block.data })
}
}
return { text: texts.join('\n'), images }
const mediaUrls = msg.mediaUrls || (msg.mediaUrl ? [msg.mediaUrl] : [])
for (const url of mediaUrls) {
if (!url) continue
if (/\.(mp4|webm|mov|mkv)(\?|$)/i.test(url)) videos.push({ url, mediaType: 'video/mp4' })
else if (/\.(mp3|wav|ogg|m4a|aac|flac)(\?|$)/i.test(url)) audios.push({ url, mediaType: 'audio/mpeg' })
else if (/\.(jpe?g|png|gif|webp|heic|svg)(\?|$)/i.test(url)) images.push({ url, mediaType: 'image/png' })
else files.push({ url, name: url.split('/').pop().split('?')[0] || '文件', mimeType: '' })
}
return { text: stripThinkingTags(texts.join('\n')), images, videos, audios, files }
}
const text = typeof msg.text === 'string' ? msg.text : (typeof msg.content === 'string' ? msg.content : '')
return { text, images }
return { text: stripThinkingTags(text), images: [], videos: [], audios: [], files: [] }
}
// ── DOM 操作 ──
function appendUserMessage(text, attachments = []) {
function appendUserMessage(text, attachments = [], msgTime) {
const wrap = document.createElement('div')
wrap.className = 'msg msg-user'
const bubble = document.createElement('div')
bubble.className = 'msg-bubble'
if (attachments.length > 0) {
const imgContainer = document.createElement('div')
imgContainer.style.cssText = 'display:flex;gap:4px;margin-bottom:8px;flex-wrap:wrap'
if (attachments && attachments.length > 0) {
const mediaContainer = document.createElement('div')
mediaContainer.style.cssText = 'display:flex;gap:4px;margin-bottom:8px;flex-wrap:wrap'
attachments.forEach(att => {
const img = document.createElement('img')
img.src = `data:${att.mimeType};base64,${att.content}`
img.style.cssText = 'max-width:200px;max-height:200px;border-radius:4px;cursor:pointer'
img.onclick = () => showLightbox(img.src)
imgContainer.appendChild(img)
const cat = att.category || att.type || 'image'
const src = att.data ? `data:${att.mimeType || att.mediaType || 'image/png'};base64,${att.data}`
: att.content ? `data:${att.mimeType || 'image/png'};base64,${att.content}`
: att.url || ''
if (cat === 'image' && src) {
const img = document.createElement('img')
img.src = src
img.className = 'msg-img'
img.onclick = () => showLightbox(img.src)
mediaContainer.appendChild(img)
} else if (cat === 'video' && src) {
const video = document.createElement('video')
video.src = src
video.className = 'msg-video'
video.controls = true
video.preload = 'metadata'
video.playsInline = true
mediaContainer.appendChild(video)
} else if (cat === 'audio' && src) {
const audio = document.createElement('audio')
audio.src = src
audio.className = 'msg-audio'
audio.controls = true
audio.preload = 'metadata'
mediaContainer.appendChild(audio)
} else if (att.fileName || att.name) {
const card = document.createElement('div')
card.className = 'msg-file-card'
card.innerHTML = `<span class="msg-file-icon">📎</span><span class="msg-file-name">${att.fileName || att.name}</span>`
mediaContainer.appendChild(card)
}
})
bubble.appendChild(imgContainer)
if (mediaContainer.children.length) bubble.appendChild(mediaContainer)
}
if (text) {
@@ -911,19 +1101,35 @@ function appendUserMessage(text, attachments = []) {
bubble.appendChild(textNode)
}
const time = document.createElement('div')
time.className = 'msg-time'
time.textContent = formatTime(msgTime || new Date())
wrap.appendChild(bubble)
wrap.appendChild(time)
_messagesEl.insertBefore(wrap, _typingEl)
scrollToBottom()
}
function appendAiMessage(text, images) {
function appendAiMessage(text, msgTime, images, videos, audios, files) {
const wrap = document.createElement('div')
wrap.className = 'msg msg-ai'
const bubble = document.createElement('div')
bubble.className = 'msg-bubble'
bubble.innerHTML = renderMarkdown(text)
appendImagesToEl(bubble, images)
appendVideosToEl(bubble, videos)
appendAudiosToEl(bubble, audios)
appendFilesToEl(bubble, files)
// 图片点击灯箱
bubble.querySelectorAll('img').forEach(img => { if (!img.onclick) img.onclick = () => showLightbox(img.src) })
const time = document.createElement('div')
time.className = 'msg-time'
time.textContent = formatTime(msgTime || new Date())
wrap.appendChild(bubble)
wrap.appendChild(time)
_messagesEl.insertBefore(wrap, _typingEl)
scrollToBottom()
}
@@ -957,6 +1163,62 @@ function appendImagesToEl(el, images) {
if (container.children.length) el.appendChild(container)
}
/** 渲染视频到消息气泡 */
function appendVideosToEl(el, videos) {
if (!videos?.length) return
videos.forEach(vid => {
const videoEl = document.createElement('video')
videoEl.className = 'msg-video'
videoEl.controls = true
videoEl.preload = 'metadata'
videoEl.playsInline = true
if (vid.data) videoEl.src = `data:${vid.mediaType};base64,${vid.data}`
else if (vid.url) videoEl.src = vid.url
el.appendChild(videoEl)
})
}
/** 渲染音频到消息气泡 */
function appendAudiosToEl(el, audios) {
if (!audios?.length) return
audios.forEach(aud => {
const audioEl = document.createElement('audio')
audioEl.className = 'msg-audio'
audioEl.controls = true
audioEl.preload = 'metadata'
if (aud.data) audioEl.src = `data:${aud.mediaType};base64,${aud.data}`
else if (aud.url) audioEl.src = aud.url
el.appendChild(audioEl)
})
}
/** 渲染文件卡片到消息气泡 */
function appendFilesToEl(el, files) {
if (!files?.length) return
files.forEach(f => {
const card = document.createElement('div')
card.className = 'msg-file-card'
const ext = (f.name || '').split('.').pop().toLowerCase()
const iconMap = { pdf: '📄', doc: '📝', docx: '📝', txt: '📃', md: '📃', json: '📋', csv: '📊', zip: '📦', rar: '📦' }
const icon = iconMap[ext] || '📎'
const size = f.size ? formatFileSize(f.size) : ''
card.innerHTML = `<span class="msg-file-icon">${icon}</span><div class="msg-file-info"><span class="msg-file-name">${f.name || '文件'}</span>${size ? `<span class="msg-file-size">${size}</span>` : ''}</div>`
if (f.url) {
card.style.cursor = 'pointer'
card.onclick = () => window.open(f.url, '_blank')
} else if (f.data) {
card.style.cursor = 'pointer'
card.onclick = () => {
const a = document.createElement('a')
a.href = `data:${f.mimeType || 'application/octet-stream'};base64,${f.data}`
a.download = f.name || '文件'
a.click()
}
}
el.appendChild(card)
})
}
/** 图片灯箱查看 */
function showLightbox(src) {
const existing = document.querySelector('.chat-lightbox')
@@ -1036,6 +1298,9 @@ export function cleanup() {
_currentAiBubble = null
_currentAiText = ''
_currentAiImages = []
_currentAiVideos = []
_currentAiAudios = []
_currentAiFiles = []
_currentRunId = null
_isStreaming = false
_isSending = false

View File

@@ -3,6 +3,9 @@
*/
import { api } from '../lib/tauri-api.js'
import { toast } from '../components/toast.js'
import { onGatewayChange } from '../lib/app-state.js'
let _unsubGw = null
export async function render() {
const page = document.createElement('div')
@@ -35,9 +38,20 @@ export async function render() {
// 异步加载数据
loadDashboardData(page)
// 监听 Gateway 状态变化,自动刷新仪表盘
if (_unsubGw) _unsubGw()
_unsubGw = onGatewayChange(() => {
loadDashboardData(page)
})
return page
}
export function cleanup() {
if (_unsubGw) { _unsubGw(); _unsubGw = null }
}
async function loadDashboardData(page) {
// 分波加载:关键数据先渲染,次要数据后填充,减少白屏等待
const coreP = Promise.allSettled([
@@ -97,7 +111,7 @@ function renderStatCards(page, services, version, agents, config, tunnel) {
<span class="status-dot ${gw?.running ? 'running' : 'stopped'}"></span>
</div>
<div class="stat-card-value">${gw?.running ? '运行中' : '已停止'}</div>
<div class="stat-card-meta">${gw?.pid ? 'PID: ' + gw.pid : '未启动'}</div>
<div class="stat-card-meta">${gw?.pid ? 'PID: ' + gw.pid : (gw?.running ? '端口检测' : '未启动')}</div>
</div>
<div class="stat-card">
<div class="stat-card-header">
@@ -183,7 +197,7 @@ function renderOverview(page, services, clawapp, tunnel, mcpConfig, backups, con
Cloudflare 隧道
</div>
<div class="overview-value" style="color: ${tunnel?.running ? 'var(--success)' : (tunnel?.installed ? 'var(--warning)' : 'var(--text-tertiary)')}">
${tunnel?.running ? tunnel.tunnel_name : (tunnel?.installed ? '已停止' : '未安装')}
${tunnel?.running ? (tunnel.tunnel_name || '运行中') : (tunnel?.installed ? '已停止' : '未安装')}
</div>
</div>
<div class="overview-item">
@@ -257,17 +271,41 @@ function bindActions(page) {
btnRestart?.addEventListener('click', async () => {
btnRestart.disabled = true
btnRestart.classList.add('btn-loading')
btnRestart.textContent = '重启中...'
try {
await api.restartService('ai.openclaw.gateway')
toast('Gateway 已重启', 'success')
setTimeout(() => loadDashboardData(page), 500)
} catch (e) {
toast('重启失败: ' + e, 'error')
} finally {
btnRestart.disabled = false
btnRestart.classList.remove('btn-loading')
btnRestart.textContent = '重启 Gateway'
return
}
// 轮询等待实际重启完成
const t0 = Date.now()
while (Date.now() - t0 < 30000) {
try {
const s = await api.getServicesStatus()
const gw = s?.find?.(x => x.label === 'ai.openclaw.gateway') || s?.[0]
if (gw?.running) {
toast(`Gateway 已重启 (PID: ${gw.pid})`, 'success')
btnRestart.disabled = false
btnRestart.classList.remove('btn-loading')
btnRestart.textContent = '重启 Gateway'
loadDashboardData(page)
return
}
} catch {}
const sec = Math.floor((Date.now() - t0) / 1000)
btnRestart.textContent = `重启中... ${sec}s`
await new Promise(r => setTimeout(r, 1500))
}
toast('重启超时Gateway 可能仍在启动中', 'warning')
btnRestart.disabled = false
btnRestart.classList.remove('btn-loading')
btnRestart.textContent = '重启 Gateway'
loadDashboardData(page)
})
btnUpdate?.addEventListener('click', async () => {

View File

@@ -227,13 +227,15 @@ function bindEvents(page) {
async function handleCftunnelAction(page, action) {
const label = action === 'up' ? '启动' : '停止'
const btn = page.querySelector(`[data-action="cftunnel-${action === 'up' ? 'up' : 'down'}"]`)
if (btn) { btn.classList.add('btn-loading'); btn.disabled = true; btn.textContent = `${label}中...` }
try {
toast(`正在${label}隧道...`, 'info')
await api.cftunnelAction(action)
toast(`隧道已${label}`, 'success')
await loadCftunnel(page)
} catch (e) {
toast(`${label}失败: ${e}`, 'error')
if (btn) { btn.classList.remove('btn-loading'); btn.disabled = false; btn.textContent = `${label}隧道` }
}
}
@@ -284,18 +286,22 @@ async function handleInstallCftunnel(page) {
let unlistenLog, unlistenProgress
try {
const { listen } = await import('@tauri-apps/api/event')
unlistenLog = await listen('install-log', (e) => {
logBox.textContent += e.payload + '\n'
logBox.scrollTop = logBox.scrollHeight
})
unlistenProgress = await listen('install-progress', (e) => {
const progress = e.payload
progressFill.style.width = progress + '%'
progressText.textContent = `安装中... ${progress}%`
})
if (window.__TAURI_INTERNALS__) {
try {
const { listen } = await import('@tauri-apps/api/event')
unlistenLog = await listen('install-log', (e) => {
logBox.textContent += e.payload + '\n'
logBox.scrollTop = logBox.scrollHeight
})
unlistenProgress = await listen('install-progress', (e) => {
const progress = e.payload
progressFill.style.width = progress + '%'
progressText.textContent = `安装中... ${progress}%`
})
} catch { /* Web 模式无 Tauri event */ }
} else {
logBox.textContent += 'Web 模式:安装日志不可用,请等待完成...\n'
}
await api.installCftunnel()
@@ -338,18 +344,22 @@ async function handleInstallClawapp(page) {
let unlistenLog, unlistenProgress
try {
const { listen } = await import('@tauri-apps/api/event')
unlistenLog = await listen('install-log', (e) => {
logBox.textContent += e.payload + '\n'
logBox.scrollTop = logBox.scrollHeight
})
unlistenProgress = await listen('install-progress', (e) => {
const progress = e.payload
progressFill.style.width = progress + '%'
progressText.textContent = `安装中... ${progress}%`
})
if (window.__TAURI_INTERNALS__) {
try {
const { listen } = await import('@tauri-apps/api/event')
unlistenLog = await listen('install-log', (e) => {
logBox.textContent += e.payload + '\n'
logBox.scrollTop = logBox.scrollHeight
})
unlistenProgress = await listen('install-progress', (e) => {
const progress = e.payload
progressFill.style.width = progress + '%'
progressText.textContent = `安装中... ${progress}%`
})
} catch { /* Web 模式无 Tauri event */ }
} else {
logBox.textContent += 'Web 模式:安装日志不可用,请等待完成...\n'
}
await api.installClawapp()

View File

@@ -33,11 +33,13 @@ export async function render() {
page.querySelector('#btn-save-gw').onclick = async () => {
const btn = page.querySelector('#btn-save-gw')
btn.disabled = true
btn.classList.add('btn-loading')
btn.textContent = '保存中...'
try {
await saveConfig(page, state)
} finally {
btn.disabled = false
btn.classList.remove('btn-loading')
btn.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/><path d="M17 21v-8H7v8"/><path d="M7 3v5h8"/></svg> 保存并生效`
}
}

View File

@@ -75,6 +75,10 @@ export function cleanup() {
async function loadLog(page, logName) {
const el = page.querySelector('#log-content')
const refreshBtn = page.querySelector('#btn-refresh')
// 显示加载状态
el.innerHTML = '<div class="log-loading"><div class="service-spinner"></div><span style="color:var(--text-tertiary);margin-left:8px">加载日志中...</span></div>'
if (refreshBtn) { refreshBtn.classList.add('btn-loading'); refreshBtn.disabled = true }
try {
const content = await api.readLogTail(logName, 200)
if (!content || !content.trim()) {
@@ -89,6 +93,8 @@ async function loadLog(page, logName) {
} catch (e) {
el.innerHTML = '<div style="color:var(--error);padding:12px">加载日志失败: ' + e + '</div>'
toast('加载日志失败: ' + e, 'error')
} finally {
if (refreshBtn) { refreshBtn.classList.remove('btn-loading'); refreshBtn.disabled = false }
}
}

View File

@@ -292,9 +292,9 @@ function renderModelCards(providerKey, models, primary, search) {
const testTime = m.lastTestAt ? formatTestTime(m.lastTestAt) : ''
if (testTime) meta.push(testTime)
return `
<div class="model-card" data-model-id="${id}" data-full="${full}" draggable="true"
style="background:${bgColor};border:1px solid ${borderColor};padding:10px 14px;border-radius:var(--radius-md);margin-bottom:8px;display:flex;align-items:center;gap:10px;cursor:grab">
<span style="color:var(--text-tertiary);cursor:grab;user-select:none;font-size:16px;padding-right:4px">⋮⋮</span>
<div class="model-card" data-model-id="${id}" data-full="${full}"
style="background:${bgColor};border:1px solid ${borderColor};padding:10px 14px;border-radius:var(--radius-md);margin-bottom:8px;display:flex;align-items:center;gap:10px">
<span class="drag-handle" style="color:var(--text-tertiary);cursor:grab;user-select:none;font-size:16px;padding:4px;touch-action:none">⋮⋮</span>
<input type="checkbox" class="model-checkbox" data-model-id="${id}" style="flex-shrink:0;cursor:pointer">
<div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:8px">
@@ -363,28 +363,45 @@ function autoSave(state) {
_saveTimer = setTimeout(() => doAutoSave(state), 300)
}
// 仅保存配置,不重启 Gateway用于测试结果等元数据持久化
async function saveConfigOnly(state) {
try {
const primary = getCurrentPrimary(state.config)
if (primary) applyDefaultModel(state)
await api.writeOpenclawConfig(state.config)
} catch (e) {
toast('保存失败: ' + e, 'error')
}
}
async function doAutoSave(state) {
try {
const primary = getCurrentPrimary(state.config)
if (primary) applyDefaultModel(state)
await api.writeOpenclawConfig(state.config)
// 提示用户需要重启 Gateway
const restartBtn = document.createElement('button')
restartBtn.className = 'btn btn-sm btn-primary'
restartBtn.textContent = '立即重启'
restartBtn.style.marginLeft = '8px'
restartBtn.onclick = async () => {
try {
toast('正在重启 Gateway...', 'info')
await api.restartGateway()
toast('Gateway 重启成功', 'success')
} catch (e) {
toast('重启失败: ' + e.message, 'error')
// 重启 Gateway 使配置生效Gateway 不支持 SIGHUP 热重载)
toast('配置已保存,正在重启 Gateway...', 'info')
try {
await api.restartGateway()
toast('配置已生效Gateway 已重启', 'success')
} catch (e) {
// 重启失败时提供手动重试按钮
const restartBtn = document.createElement('button')
restartBtn.className = 'btn btn-sm btn-primary'
restartBtn.textContent = '重试'
restartBtn.style.marginLeft = '8px'
restartBtn.onclick = async () => {
try {
toast('正在重启 Gateway...', 'info')
await api.restartGateway()
toast('Gateway 重启成功', 'success')
} catch (e2) {
toast('重启失败: ' + e2.message, 'error')
}
}
toast('配置已保存,但 Gateway 重启失败: ' + e.message, 'warning', { action: restartBtn })
}
toast('配置已保存,需要重启 Gateway 生效', 'warning', { action: restartBtn })
} catch (e) {
toast('自动保存失败: ' + e, 'error')
}
@@ -426,54 +443,98 @@ function bindProviderButtons(listEl, page, state) {
}
})
// 绑定拖拽排序 (Drag & Drop)
// 绑定拖拽排序Pointer 事件实现,兼容 Tauri WebView2/WKWebView
listEl.querySelectorAll('.provider-models').forEach(container => {
let dragged = null
container.addEventListener('dragstart', e => {
dragged = e.target.closest('.model-card')
if (dragged) {
dragged.style.opacity = '0.5'
e.dataTransfer.effectAllowed = 'move'
}
})
container.addEventListener('dragend', e => {
if (dragged) {
dragged.style.opacity = '1'
dragged = null
}
})
container.addEventListener('dragover', e => {
let placeholder = null
let startY = 0
// 仅从拖拽手柄启动
container.addEventListener('pointerdown', e => {
const handle = e.target.closest('.drag-handle')
if (!handle) return
const card = handle.closest('.model-card')
if (!card) return
e.preventDefault()
const targetCard = e.target.closest('.model-card')
if (dragged && targetCard && dragged !== targetCard) {
const bounding = targetCard.getBoundingClientRect()
const offset = bounding.y + bounding.height / 2
if (e.clientY > offset) {
targetCard.after(dragged)
} else {
targetCard.before(dragged)
dragged = card
startY = e.clientY
// 创建占位符
placeholder = document.createElement('div')
placeholder.style.cssText = `height:${card.offsetHeight}px;border:2px dashed var(--border);border-radius:var(--radius-md);margin-bottom:8px;background:var(--bg-secondary)`
card.after(placeholder)
// 浮动拖拽元素
const rect = card.getBoundingClientRect()
card.style.position = 'fixed'
card.style.left = rect.left + 'px'
card.style.top = rect.top + 'px'
card.style.width = rect.width + 'px'
card.style.zIndex = '9999'
card.style.opacity = '0.85'
card.style.boxShadow = '0 8px 24px rgba(0,0,0,0.2)'
card.style.pointerEvents = 'none'
card.setPointerCapture(e.pointerId)
})
container.addEventListener('pointermove', e => {
if (!dragged || !placeholder) return
e.preventDefault()
// 移动浮动元素
const dy = e.clientY - startY
const origTop = parseFloat(dragged.style.top)
dragged.style.top = (origTop + dy) + 'px'
startY = e.clientY
// 查找目标位置
const siblings = [...container.querySelectorAll('.model-card:not([style*="position: fixed"])')].filter(c => c !== dragged)
for (const sibling of siblings) {
const rect = sibling.getBoundingClientRect()
const midY = rect.top + rect.height / 2
if (e.clientY < midY) {
sibling.before(placeholder)
return
}
}
// 放到最后
if (siblings.length) siblings[siblings.length - 1].after(placeholder)
})
container.addEventListener('drop', e => {
e.preventDefault()
if (!dragged) return
container.addEventListener('pointerup', e => {
if (!dragged || !placeholder) return
// 恢复样式
dragged.style.position = ''
dragged.style.left = ''
dragged.style.top = ''
dragged.style.width = ''
dragged.style.zIndex = ''
dragged.style.opacity = ''
dragged.style.boxShadow = ''
dragged.style.pointerEvents = ''
// 把卡片放到占位符位置
placeholder.before(dragged)
placeholder.remove()
// 保存新顺序
const section = container.closest('[data-provider]')
if (!section) return
const providerKey = section.dataset.provider
const provider = state.config.models.providers[providerKey]
if (!provider) return
if (section) {
const providerKey = section.dataset.provider
const provider = state.config.models.providers[providerKey]
if (provider) {
const newOrderIds = [...container.querySelectorAll('.model-card')].map(c => c.dataset.modelId)
pushUndo(state)
const oldModels = [...provider.models]
provider.models = newOrderIds.map(id => oldModels.find(m => (typeof m === 'string' ? m : m.id) === id))
autoSave(state)
}
}
// 获取新的顺序
const newOrderIds = [...container.querySelectorAll('.model-card')].map(c => c.dataset.modelId)
pushUndo(state)
const oldModels = [...provider.models]
provider.models = newOrderIds.map(id => oldModels.find(m => (typeof m === 'string' ? m : m.id) === id))
// 更新状态不重新渲染以保持列表稳定
autoSave(state)
dragged = null
placeholder = null
})
})
@@ -584,7 +645,28 @@ function setPrimary(state, full) {
}
// 应用默认模型primary + 其余自动成为备选
// 确保 primary 指向的模型仍然存在,不存在则自动切到第一个可用模型
function ensureValidPrimary(state) {
const primary = getCurrentPrimary(state.config)
const allModels = collectAllModels(state.config)
if (allModels.length === 0) {
// 所有模型都没了,清空 primary
if (state.config.agents?.defaults?.model) {
state.config.agents.defaults.model.primary = ''
}
return
}
const exists = allModels.some(m => m.full === primary)
if (!exists) {
// primary 指向已删除的模型,自动切到第一个
const newPrimary = allModels[0].full
setPrimary(state, newPrimary)
toast(`主模型已自动切换为 ${newPrimary}`, 'info')
}
}
function applyDefaultModel(state) {
ensureValidPrimary(state)
const primary = getCurrentPrimary(state.config)
const allModels = collectAllModels(state.config)
const fallbacks = allModels.filter(m => m.full !== primary).map(m => m.full)
@@ -597,6 +679,16 @@ function applyDefaultModel(state) {
modelsMap[primary] = {}
for (const fb of fallbacks) modelsMap[fb] = {}
defaults.models = modelsMap
// 同步到各 agent 的模型覆盖配置,避免 agent 级别的旧值覆盖全局默认
const list = state.config.agents?.list
if (Array.isArray(list)) {
for (const agent of list) {
if (agent.model && typeof agent.model === 'object' && agent.model.primary) {
agent.model.primary = primary
}
}
}
}
// 顶部按钮事件
@@ -1141,7 +1233,7 @@ async function testModel(btn, state, providerKey, idx) {
renderProviders(page, state)
renderDefaultBar(page, state)
}
// 持久化测试结果
autoSave(state)
// 持久化测试结果(仅保存,不重启 Gateway
saveConfigOnly(state)
}
}

View File

@@ -5,6 +5,7 @@
import { api } from '../lib/tauri-api.js'
import { toast } from '../components/toast.js'
import { showConfirm, showUpgradeModal } from '../components/modal.js'
import { isMacPlatform, setUpgrading, setUserStopped, resetAutoRestart } from '../lib/app-state.js'
// HTML 转义,防止 XSS
function escapeHtml(str) {
@@ -168,10 +169,9 @@ function renderServices(container, services) {
: gw.running
? `<button class="btn btn-secondary btn-sm" data-action="restart" data-label="${gw.label}">重启</button>
<button class="btn btn-danger btn-sm" data-action="stop" data-label="${gw.label}">停止</button>
<button class="btn btn-danger btn-sm" data-action="uninstall-gateway">卸载</button>`
${isMacPlatform() ? '<button class="btn btn-danger btn-sm" data-action="uninstall-gateway">卸载</button>' : ''}`
: `<button class="btn btn-primary btn-sm" data-action="start" data-label="${gw.label}">启动</button>
<button class="btn btn-primary btn-sm" data-action="install-gateway">安装</button>
<button class="btn btn-danger btn-sm" data-action="uninstall-gateway">卸载</button>`
${isMacPlatform() ? '<button class="btn btn-primary btn-sm" data-action="install-gateway">安装</button><button class="btn btn-danger btn-sm" data-action="uninstall-gateway">卸载</button>' : ''}`
}
</div>
</div>`
@@ -285,12 +285,94 @@ function bindEvents(page) {
// ===== 服务操作 =====
const ACTION_LABELS = { start: '启动', stop: '停止', restart: '重启' }
const POLL_INTERVAL = 1500 // 轮询间隔 ms
const POLL_TIMEOUT = 30000 // 最长等待 30s
async function handleServiceAction(action, label, page) {
const fn = { start: api.startService, stop: api.stopService, restart: api.restartService }[action]
toast(`正在${ACTION_LABELS[action]} ${label}...`, 'info')
await fn(label)
toast(`${ACTION_LABELS[action]} ${label} 成功`, 'success')
const actionLabel = ACTION_LABELS[action]
const expectRunning = action !== 'stop'
// 通知守护模块:用户主动操作
if (action === 'stop') setUserStopped(true)
if (action === 'start') resetAutoRestart()
// 找到触发按钮所在的 service-card替换按钮区域为加载状态
const card = page.querySelector(`.service-card[data-label="${label}"]`)
const actionsEl = card?.querySelector('.service-actions')
const origHtml = actionsEl?.innerHTML || ''
let cancelled = false
if (actionsEl) {
actionsEl.innerHTML = `
<div class="service-loading">
<div class="service-spinner"></div>
<span class="service-loading-text">正在${actionLabel}...</span>
<button class="btn btn-sm btn-ghost service-cancel-btn" style="display:none">取消等待</button>
</div>`
const cancelBtn = actionsEl.querySelector('.service-cancel-btn')
if (cancelBtn) {
cancelBtn.addEventListener('click', () => { cancelled = true })
}
}
// 更新状态点为加载中
const dot = card?.querySelector('.status-dot')
if (dot) { dot.className = 'status-dot loading' }
try {
await fn(label)
} catch (e) {
toast(`${actionLabel}命令失败: ${e.message || e}`, 'error')
if (actionsEl) actionsEl.innerHTML = origHtml
if (dot) dot.className = 'status-dot stopped'
return
}
// 轮询等待实际状态变化
const startTime = Date.now()
let showedCancel = false
const loadingText = actionsEl?.querySelector('.service-loading-text')
const cancelBtn = actionsEl?.querySelector('.service-cancel-btn')
while (!cancelled) {
const elapsed = Date.now() - startTime
// 5 秒后显示取消按钮
if (!showedCancel && elapsed > 5000 && cancelBtn) {
cancelBtn.style.display = ''
showedCancel = true
}
// 更新等待时间
if (loadingText) {
const sec = Math.floor(elapsed / 1000)
loadingText.textContent = `正在${actionLabel}... ${sec}s`
}
// 超时
if (elapsed > POLL_TIMEOUT) {
toast(`${actionLabel}超时Gateway 可能仍在启动中`, 'warning')
break
}
// 检查实际状态
try {
const services = await api.getServicesStatus()
const svc = services?.find?.(s => s.label === label) || services?.[0]
if (svc && svc.running === expectRunning) {
toast(`${label}${actionLabel}${svc.pid ? ' (PID: ' + svc.pid + ')' : ''}`, 'success')
await loadServices(page)
return
}
} catch {}
await new Promise(r => setTimeout(r, POLL_INTERVAL))
}
if (cancelled) {
toast('已取消等待,可稍后刷新查看状态', 'info')
}
await loadServices(page)
}
@@ -323,17 +405,26 @@ async function handleDeleteBackup(name, page) {
async function doUpgradeWithModal(source, page) {
const modal = showUpgradeModal()
let unlistenLog, unlistenProgress
setUpgrading(true)
try {
const { listen } = await import('@tauri-apps/api/event')
unlistenLog = await listen('upgrade-log', (e) => modal.appendLog(e.payload))
unlistenProgress = await listen('upgrade-progress', (e) => modal.setProgress(e.payload))
// Tauri 环境下监听实时日志Web 模式跳过
if (window.__TAURI_INTERNALS__) {
try {
const { listen } = await import('@tauri-apps/api/event')
unlistenLog = await listen('upgrade-log', (e) => modal.appendLog(e.payload))
unlistenProgress = await listen('upgrade-progress', (e) => modal.setProgress(e.payload))
} catch { /* Web 模式无 Tauri event */ }
} else {
modal.appendLog('Web 模式:升级过程日志不可用,请等待完成...')
}
const msg = await api.upgradeOpenclaw(source)
modal.setDone(msg)
modal.setDone(typeof msg === 'string' ? msg : (msg?.message || '升级完成'))
await loadVersion(page)
} catch (e) {
modal.appendLog(String(e))
modal.setError('升级失败')
} finally {
setUpgrading(false)
unlistenLog?.()
unlistenProgress?.()
}
@@ -356,19 +447,33 @@ async function handleSwitchSource(target, page) {
// ===== Gateway 安装/卸载 =====
async function handleInstallGateway(btn, page) {
btn.classList.add('btn-loading')
btn.textContent = '安装中...'
await api.installGateway()
toast('Gateway 服务已安装', 'success')
await loadServices(page)
try {
await api.installGateway()
toast('Gateway 服务已安装', 'success')
await loadServices(page)
} catch (e) {
toast('安装失败: ' + e, 'error')
btn.classList.remove('btn-loading')
btn.textContent = '安装'
}
}
async function handleUninstallGateway(btn, page) {
const yes = await showConfirm('确定要卸载 Gateway 服务吗?\n这会停止服务并移除 LaunchAgent。')
if (!yes) return
btn.classList.add('btn-loading')
btn.textContent = '卸载中...'
await api.uninstallGateway()
toast('Gateway 服务已卸载', 'success')
await loadServices(page)
try {
await api.uninstallGateway()
toast('Gateway 服务已卸载', 'success')
await loadServices(page)
} catch (e) {
toast('卸载失败: ' + e, 'error')
btn.classList.remove('btn-loading')
btn.textContent = '卸载'
}
}
async function handleSaveRegistry(btn, page) {

View File

@@ -5,6 +5,7 @@
import { api } from '../lib/tauri-api.js'
import { showUpgradeModal } from '../components/modal.js'
import { toast } from '../components/toast.js'
import { setUpgrading } from '../lib/app-state.js'
export async function render() {
const page = document.createElement('div')
@@ -177,10 +178,17 @@ function bindEvents(page, nodeOk) {
const modal = showUpgradeModal()
let unlistenLog, unlistenProgress
setUpgrading(true)
try {
const { listen } = await import('@tauri-apps/api/event')
unlistenLog = await listen('upgrade-log', (e) => modal.appendLog(e.payload))
unlistenProgress = await listen('upgrade-progress', (e) => modal.setProgress(e.payload))
if (window.__TAURI_INTERNALS__) {
try {
const { listen } = await import('@tauri-apps/api/event')
unlistenLog = await listen('upgrade-log', (e) => modal.appendLog(e.payload))
unlistenProgress = await listen('upgrade-progress', (e) => modal.setProgress(e.payload))
} catch { /* Web 模式无 Tauri event */ }
} else {
modal.appendLog('Web 模式:安装日志不可用,请等待完成...')
}
// 先设置镜像源
if (registry) {
@@ -206,6 +214,7 @@ function bindEvents(page, nodeOk) {
modal.appendLog(String(e))
modal.setError('安装失败')
} finally {
setUpgrading(false)
unlistenLog?.()
unlistenProgress?.()
}