mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-31 13:20:10 +08:00
v0.4.0: Gateway 进程守护、配置自愈、双配置同步、流式超时、模型删除安全切换
This commit is contained in:
@@ -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>`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// 忽略空 final(Gateway 会为一条消息触发多个 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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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> 保存并生效`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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?.()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user