+
⋮⋮
@@ -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)
}
}
diff --git a/src/pages/services.js b/src/pages/services.js
index 1ab7c64..6bb56c7 100644
--- a/src/pages/services.js
+++ b/src/pages/services.js
@@ -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
? `
- `
+ ${isMacPlatform() ? '' : ''}`
: `
-
- `
+ ${isMacPlatform() ? '' : ''}`
}
`
@@ -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 = `
+
+
+
正在${actionLabel}...
+
+
`
+ 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) {
diff --git a/src/pages/setup.js b/src/pages/setup.js
index 1b39838..1b2b3b1 100644
--- a/src/pages/setup.js
+++ b/src/pages/setup.js
@@ -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?.()
}
diff --git a/src/style/chat.css b/src/style/chat.css
index ae62409..ff880e9 100644
--- a/src/style/chat.css
+++ b/src/style/chat.css
@@ -687,6 +687,78 @@
backdrop-filter: blur(4px);
}
+/* 消息时间戳 */
+.msg-time {
+ font-size: 11px;
+ color: var(--text-tertiary);
+ margin-top: 4px;
+ padding: 0 4px;
+}
+.msg-user .msg-time { text-align: right; }
+.msg-ai .msg-time { text-align: left; }
+
+/* 消息内图片 */
+.msg-img {
+ max-width: 200px;
+ max-height: 200px;
+ border-radius: 6px;
+ cursor: pointer;
+ object-fit: cover;
+}
+
+/* 消息内视频 */
+.msg-video {
+ max-width: 320px;
+ max-height: 240px;
+ border-radius: 6px;
+ margin-top: 8px;
+}
+
+/* 消息内音频 */
+.msg-audio {
+ margin-top: 8px;
+ max-width: 280px;
+ height: 36px;
+}
+
+/* 文件卡片 */
+.msg-file-card {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 12px;
+ margin-top: 8px;
+ background: var(--bg-tertiary);
+ border: 1px solid var(--border-primary);
+ border-radius: var(--radius-md);
+ font-size: var(--font-size-sm);
+ transition: background 0.15s;
+}
+.msg-file-card:hover {
+ background: var(--bg-hover);
+}
+.msg-file-icon {
+ font-size: 18px;
+ flex-shrink: 0;
+}
+.msg-file-info {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+}
+.msg-file-name {
+ font-weight: 500;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ max-width: 200px;
+}
+.msg-file-size {
+ font-size: 11px;
+ color: var(--text-tertiary);
+}
+
.chat-lightbox-img {
max-width: 90%;
max-height: 90%;
diff --git a/src/style/components.css b/src/style/components.css
index 34334b3..c40ec7c 100644
--- a/src/style/components.css
+++ b/src/style/components.css
@@ -1,3 +1,25 @@
+/* 按钮加载状态 — 任意按钮加上 .btn-loading 即可获得内联 spinner */
+.btn.btn-loading {
+ pointer-events: none;
+ opacity: 0.75;
+}
+.btn.btn-loading::before {
+ content: '';
+ display: inline-block;
+ width: 14px;
+ height: 14px;
+ border: 2px solid currentColor;
+ border-top-color: transparent;
+ border-radius: 50%;
+ animation: btn-spin 0.7s linear infinite;
+ margin-right: 6px;
+ vertical-align: -2px;
+ flex-shrink: 0;
+}
+@keyframes btn-spin {
+ to { transform: rotate(360deg); }
+}
+
/* 骨架屏 */
.skeleton {
background: linear-gradient(90deg, var(--bg-secondary) 25%, var(--bg-tertiary, var(--bg-card-hover)) 50%, var(--bg-secondary) 75%);
diff --git a/src/style/pages.css b/src/style/pages.css
index 8da1e78..763b05b 100644
--- a/src/style/pages.css
+++ b/src/style/pages.css
@@ -96,6 +96,63 @@
.service-actions {
display: flex;
gap: var(--space-sm);
+ align-items: center;
+}
+
+/* 服务操作加载状态 */
+.service-loading {
+ display: flex;
+ align-items: center;
+ gap: var(--space-sm);
+}
+
+.service-spinner {
+ width: 18px;
+ height: 18px;
+ border: 2px solid var(--border-primary);
+ border-top-color: var(--primary);
+ border-radius: 50%;
+ animation: service-spin 0.8s linear infinite;
+}
+
+@keyframes service-spin {
+ to { transform: rotate(360deg); }
+}
+
+.service-loading-text {
+ font-size: var(--font-size-sm);
+ color: var(--text-secondary);
+ font-variant-numeric: tabular-nums;
+ min-width: 100px;
+}
+
+.service-cancel-btn {
+ font-size: var(--font-size-xs) !important;
+ color: var(--text-tertiary) !important;
+ padding: 2px 8px !important;
+ transition: color var(--transition-fast);
+}
+.service-cancel-btn:hover {
+ color: var(--error) !important;
+}
+
+/* 状态点:加载中脉冲动画 */
+.status-dot.loading {
+ background: var(--warning, #f59e0b);
+ animation: dot-pulse 1s ease-in-out infinite;
+}
+
+@keyframes dot-pulse {
+ 0%, 100% { opacity: 1; transform: scale(1); }
+ 50% { opacity: 0.4; transform: scale(0.8); }
+}
+
+/* 日志加载状态 */
+.log-loading {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: var(--space-xl) 0;
}
/* 日志工具栏 */
diff --git a/vite.config.js b/vite.config.js
index 2700e86..5b8b47f 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -1,10 +1,31 @@
import { defineConfig } from 'vite'
+import { devApiPlugin } from './scripts/dev-api.js'
+import fs from 'fs'
+import path from 'path'
+import { homedir } from 'os'
+
+// 读取 Gateway 端口(启动时读取一次)
+let gatewayPort = 18789
+try {
+ const cfg = JSON.parse(fs.readFileSync(path.join(homedir(), '.openclaw', 'openclaw.json'), 'utf8'))
+ gatewayPort = cfg?.gateway?.port || 18789
+} catch {}
export default defineConfig({
+ plugins: [devApiPlugin()],
clearScreen: false,
server: {
port: 1420,
strictPort: true,
+ proxy: {
+ '/ws': {
+ target: `ws://127.0.0.1:${gatewayPort}`,
+ ws: true,
+ configure: (proxy) => {
+ proxy.on('error', () => {})
+ },
+ },
+ },
},
envPrefix: ['VITE_', 'TAURI_'],
build: {