fix: 修复多项关键 Bug,与 openclaw 上游协议对齐

- main.js: wsClient.connect 传参格式错误(完整 ws:// URL → host:port)
- ws-client.js: request() 等待重连时不处理 onReady 握手失败
- gateway.js: bind 写入非法值 'all',改为 openclaw 合法值 'lan'
- device.rs: connect payload 从 v2 升级到 v3,补充 platform/deviceFamily
- config.rs: macOS reload_gateway 在 async fn 中用同步 Command 阻塞 tokio
- service.rs: Windows check_service_status 端口硬编码 18789,改为读配置
- extensions.rs: parse_cftunnel_status 全角冒号解析失败,添加 split_after_colon
- tauri-api.js: cachedInvoke miss 时 logRequest 被记录两次
- tauri-api.js: mock 补充 list_agents / restart_gateway
- chat.js: 附件对象冗余 data 字段(双倍内存)+ 缩进修复
- services.js: 服务操作缺少操作中 toast 反馈
This commit is contained in:
晴天
2026-03-04 12:16:58 +08:00
parent 05771ffa63
commit dab61ccd24
24 changed files with 882 additions and 209 deletions

View File

@@ -12,11 +12,23 @@ function ensureContainer() {
return _container
}
export function toast(message, type = 'info', duration = 3000) {
export function toast(message, type = 'info', options = {}) {
const duration = options.duration || 3000
const action = options.action // 可选的操作按钮DOM 元素)
const container = ensureContainer()
const el = document.createElement('div')
el.className = `toast ${type}`
el.textContent = message
const textSpan = document.createElement('span')
textSpan.textContent = message
el.appendChild(textSpan)
// 如果有操作按钮,添加到 toast 中
if (action instanceof HTMLElement) {
el.appendChild(action)
}
container.appendChild(el)
setTimeout(() => {

View File

@@ -49,10 +49,7 @@ function cachedInvoke(cmd, args = {}, ttl = CACHE_TTL) {
logRequest(cmd, args, 0, true)
return Promise.resolve(cached.val)
}
const start = Date.now()
return invoke(cmd, args).then(val => {
const duration = Date.now() - start
logRequest(cmd, args, duration, false)
_cache.set(key, { val, ts: Date.now() })
return val
})
@@ -65,6 +62,9 @@ function invalidate(...cmds) {
}
}
// 导出 invalidate 供外部使用
export { invalidate }
async function invoke(cmd, args = {}) {
const start = Date.now()
if (_invokeReady) {
@@ -164,6 +164,10 @@ function mockInvoke(cmd, args) {
stop_service: () => true,
restart_service: () => true,
reload_gateway: () => 'Gateway 已重载',
restart_gateway: () => 'Gateway 已重启',
list_agents: () => [
{ id: 'main', isDefault: true, identityName: null, model: null, workspace: null },
],
upgrade_openclaw: () => '升级成功,当前版本: 2026.2.26-zh.3 (mock)',
install_gateway: () => 'Gateway 服务已安装 (mock)',
uninstall_gateway: () => 'Gateway 服务已卸载 (mock)',
@@ -211,6 +215,7 @@ export const api = {
readMcpConfig: () => cachedInvoke('read_mcp_config'),
writeMcpConfig: (config) => { invalidate('read_mcp_config'); return invoke('write_mcp_config', { config }) },
reloadGateway: () => invoke('reload_gateway'),
restartGateway: () => invoke('restart_gateway'),
upgradeOpenclaw: (source = 'chinese') => invoke('upgrade_openclaw', { source }),
installGateway: () => invoke('install_gateway'),
uninstallGateway: () => invoke('uninstall_gateway'),
@@ -224,6 +229,7 @@ export const api = {
addAgent: (name, model, workspace) => { invalidate('list_agents'); return invoke('add_agent', { name, model, workspace: workspace || null }) },
deleteAgent: (id) => { invalidate('list_agents'); return invoke('delete_agent', { id }) },
updateAgentIdentity: (id, name, emoji) => { invalidate('list_agents'); return invoke('update_agent_identity', { id, name, emoji }) },
updateAgentModel: (id, model) => { invalidate('list_agents'); return invoke('update_agent_model', { id, model }) },
backupAgent: (id) => invoke('backup_agent', { id }),
// 日志(短缓存)
@@ -255,6 +261,7 @@ export const api = {
getCftunnelLogs: (lines = 20) => cachedInvoke('get_cftunnel_logs', { lines }, 5000),
getClawappStatus: () => cachedInvoke('get_clawapp_status', {}, 5000),
installCftunnel: () => invoke('install_cftunnel'),
installClawapp: () => invoke('install_clawapp'),
// 设备密钥 + Gateway 握手
createConnectFrame: (nonce, gatewayToken) => invoke('create_connect_frame', { nonce, gatewayToken }),

View File

@@ -326,8 +326,9 @@ export class WsClient {
if (!this._ws || this._ws.readyState !== WebSocket.OPEN || !this._gatewayReady) {
if (!this._intentionalClose && (this._reconnectAttempts > 0 || !this._gatewayReady)) {
const waitTimeout = setTimeout(() => { unsub(); reject(new Error('等待重连超时')) }, 15000)
const unsub = this.onReady(() => {
const unsub = this.onReady((hello, sessionKey, err) => {
clearTimeout(waitTimeout); unsub()
if (err?.error) { reject(new Error(err.message || 'Gateway 握手失败')); return }
this.request(method, params).then(resolve, reject)
})
return
@@ -341,8 +342,14 @@ export class WsClient {
})
}
chatSend(sessionKey, message) {
return this.request('chat.send', { sessionKey, message, deliver: false, idempotencyKey: uuid() })
chatSend(sessionKey, message, attachments) {
const params = { sessionKey, message, deliver: false, idempotencyKey: uuid() }
if (attachments && attachments.length > 0) {
params.attachments = attachments
console.log('[ws] 发送附件:', attachments.length, '个')
console.log('[ws] 附件详情:', attachments.map(a => ({ type: a.type, mime: a.mimeType, name: a.fileName, size: a.content?.length })))
}
return this.request('chat.send', params)
}
chatHistory(sessionKey, limit = 200) {

View File

@@ -83,7 +83,7 @@ async function autoConnectWebSocket() {
return
}
wsClient.connect(`ws://127.0.0.1:${port}/ws`, token)
wsClient.connect(`127.0.0.1:${port}`, token)
console.log('[main] WebSocket 连接已启动')
} catch (e) {
console.error('[main] 自动连接 WebSocket 失败:', e)

View File

@@ -145,7 +145,7 @@ const PROJECTS = [
{
name: 'OpenClaw',
desc: 'AI Agent 框架,支持多模型协作、工具调用、记忆管理',
url: 'https://github.com/openclaw-labs/openclaw',
url: 'https://github.com/openclaw/openclaw',
},
{
name: 'ClawApp',

View File

@@ -2,7 +2,7 @@
* Agent 管理页面
* Agent 增删改查 + 身份编辑
*/
import { api } from '../lib/tauri-api.js'
import { api, invalidate } from '../lib/tauri-api.js'
import { toast } from '../components/toast.js'
import { showModal, showConfirm } from '../components/modal.js'
@@ -21,7 +21,7 @@ export async function render() {
</div>
</div>
<div class="page-content">
<div id="agents-list" class="loading-text">加载中...</div>
<div id="agents-list"></div>
</div>
`
@@ -39,6 +39,12 @@ async function loadAgents(page, state) {
try {
state.agents = await api.listAgents()
renderAgents(page, state)
// 只在第一次加载时绑定事件(避免重复绑定)
if (!state.eventsAttached) {
attachAgentEvents(page, state)
state.eventsAttached = true
}
} catch (e) {
container.innerHTML = '<div style="color:var(--error);padding:20px">加载失败: ' + e + '</div>'
toast('加载 Agent 列表失败: ' + e, 'error')
@@ -85,8 +91,10 @@ function renderAgents(page, state) {
</div>
`
}).join('')
}
// 事件委托
function attachAgentEvents(page, state) {
const container = page.querySelector('#agents-list')
container.addEventListener('click', async (e) => {
const btn = e.target.closest('[data-action]')
if (!btn) return
@@ -143,6 +151,9 @@ async function showAddAgentDialog(page, state) {
await api.updateAgentIdentity(id, name || null, emoji || null)
}
toast('Agent 已创建', 'success')
// 强制清除缓存并重新加载
invalidate('list_agents')
await loadAgents(page, state)
} catch (e) {
toast('创建失败: ' + e, 'error')
@@ -157,7 +168,7 @@ async function showEditAgentDialog(page, state, id) {
const name = agent.identityName ? agent.identityName.split(',')[0].trim() : ''
// 获取模型列表用于下拉选择
// 获取模型列表
let models = []
try {
const config = await api.readOpenclawConfig()
@@ -168,23 +179,29 @@ async function showEditAgentDialog(page, state, id) {
if (mid) models.push({ value: `${pk}/${mid}`, label: `${pk}/${mid}` })
}
}
} catch { /* 忽略 */ }
console.log('[Agent编辑] 获取到模型列表:', models.length, '个')
} catch (e) {
console.error('[Agent编辑] 获取模型列表失败:', e)
}
const fields = [
{ name: 'name', label: '名称', value: name, placeholder: '例如:翻译助手' },
{ name: 'emoji', label: 'Emoji', value: '', placeholder: '例如:🌐' },
{ name: 'emoji', label: 'Emoji', value: agent.identityEmoji || '', placeholder: '例如:🌐' },
]
// 有模型列表时提供下拉选择
if (models.length) {
fields.push({
const modelField = {
name: 'model', label: '模型', type: 'select',
value: agent.model || models[0]?.value || '',
options: models,
})
}
fields.push(modelField)
console.log('[Agent编辑] 当前模型:', agent.model)
console.log('[Agent编辑] 模型选项:', models)
} else {
console.warn('[Agent编辑] 模型列表为空,不显示模型选择器')
}
// 工作区只读展示
fields.push({
name: 'workspace', label: '工作区',
value: agent.workspace || '未设置',
@@ -196,16 +213,30 @@ async function showEditAgentDialog(page, state, id) {
title: `编辑 Agent — ${id}`,
fields,
onConfirm: async (result) => {
console.log('[Agent编辑] 保存数据:', result)
const newName = (result.name || '').trim()
const emoji = (result.emoji || '').trim()
const model = (result.model || '').trim()
try {
if (newName || emoji) {
console.log('[Agent编辑] 更新身份信息...')
await api.updateAgentIdentity(id, newName || null, emoji || null)
}
if (model && model !== agent.model) {
console.log('[Agent编辑] 更新模型:', agent.model, '->', model)
await api.updateAgentModel(id, model)
}
// 手动更新 state 并重新渲染,确保立即生效
if (newName) agent.identityName = newName
if (emoji) agent.identityEmoji = emoji
if (model) agent.model = model
renderAgents(page, state)
toast('已更新', 'success')
await loadAgents(page, state)
} catch (e) {
console.error('[Agent编辑] 保存失败:', e)
toast('更新失败: ' + e, 'error')
}
}

View File

@@ -51,7 +51,6 @@ export async function render() {
async function loadDebugInfo(page) {
const el = page.querySelector('#debug-content')
el.innerHTML = '<div class="loading-text">检测中...</div>'
const info = {
timestamp: new Date().toLocaleString('zh-CN'),

View File

@@ -39,12 +39,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
let _sessionListEl = null, _cmdPanelEl = null, _attachPreviewEl = null, _fileInputEl = null
let _currentAiBubble = null, _currentAiText = '', _currentRunId = null
let _isStreaming = false, _isSending = false, _messageQueue = []
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 = []
export async function render() {
const page = document.createElement('div')
@@ -87,7 +89,12 @@ export async function render() {
</div>
<button class="chat-scroll-btn" id="chat-scroll-btn" style="display:none">↓</button>
<div class="chat-cmd-panel" id="chat-cmd-panel" style="display:none"></div>
<div class="chat-attachments-preview" id="chat-attachments-preview" style="display:none"></div>
<div class="chat-input-area">
<input type="file" id="chat-file-input" accept="image/*" multiple style="display:none">
<button class="chat-attach-btn" id="chat-attach-btn" title="上传图片">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18"><path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"/></svg>
</button>
<div class="chat-input-wrapper">
<textarea id="chat-input" rows="1" placeholder="输入消息Enter 发送,/ 打开指令"></textarea>
</div>
@@ -107,6 +114,8 @@ export async function render() {
_scrollBtn = page.querySelector('#chat-scroll-btn')
_sessionListEl = page.querySelector('#chat-session-list')
_cmdPanelEl = page.querySelector('#chat-cmd-panel')
_attachPreviewEl = page.querySelector('#chat-attachments-preview')
_fileInputEl = page.querySelector('#chat-file-input')
bindEvents(page)
// 非阻塞:先返回 DOM后台连接 Gateway
@@ -140,9 +149,13 @@ function bindEvents(page) {
page.querySelector('#chat-sidebar').classList.toggle('open')
})
page.querySelector('#btn-new-session').addEventListener('click', () => showNewSessionDialog())
page.querySelector('#btn-cmd').addEventListener('click', () => toggleCmdPanel())
page.querySelector('#btn-cmd').addEventListener('click', () => toggleCmdPanel())
page.querySelector('#btn-reset-session').addEventListener('click', () => resetCurrentSession())
// 文件上传
page.querySelector('#chat-attach-btn').addEventListener('click', () => _fileInputEl.click())
_fileInputEl.addEventListener('change', handleFileSelect)
_messagesEl.addEventListener('scroll', () => {
const { scrollTop, scrollHeight, clientHeight } = _messagesEl
_scrollBtn.style.display = (scrollHeight - scrollTop - clientHeight < 80) ? 'none' : 'flex'
@@ -151,6 +164,73 @@ page.querySelector('#btn-cmd').addEventListener('click', () => toggleCmdPanel())
_messagesEl.addEventListener('click', () => hideCmdPanel())
}
// ── 文件上传 ──
async function handleFileSelect(e) {
const files = Array.from(e.target.files || [])
if (!files.length) return
for (const file of files) {
if (!file.type.startsWith('image/')) {
toast('仅支持图片文件', 'warning')
continue
}
if (file.size > 5 * 1024 * 1024) {
toast(`${file.name} 超过 5MB 限制`, 'warning')
continue
}
try {
const base64 = await fileToBase64(file)
_attachments.push({
type: 'image',
mimeType: file.type,
fileName: file.name,
content: base64,
})
renderAttachments()
} catch (e) {
toast(`读取 ${file.name} 失败`, 'error')
}
}
_fileInputEl.value = ''
}
function fileToBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
const dataUrl = reader.result
const base64 = dataUrl.split(',')[1]
resolve(base64)
}
reader.onerror = reject
reader.readAsDataURL(file)
})
}
function renderAttachments() {
if (!_attachments.length) {
_attachPreviewEl.style.display = 'none'
return
}
_attachPreviewEl.style.display = 'flex'
_attachPreviewEl.innerHTML = _attachments.map((att, idx) => `
<div class="chat-attachment-item">
<img src="data:${att.mimeType};base64,${att.content}" alt="${att.fileName}">
<button class="chat-attachment-del" data-idx="${idx}">×</button>
</div>
`).join('')
_attachPreviewEl.querySelectorAll('.chat-attachment-del').forEach(btn => {
btn.addEventListener('click', () => {
const idx = parseInt(btn.dataset.idx)
_attachments.splice(idx, 1)
renderAttachments()
})
})
}
// ── Gateway 连接 ──
async function connectGateway() {
@@ -175,14 +255,16 @@ async function connectGateway() {
_unsubReady = wsClient.onReady((hello, sessionKey, err) => {
if (!_pageActive) return
if (err?.error) { toast(err.message || '连接失败', 'error'); return }
showTyping(false) // Gateway 就绪后关闭加载动画
// 重连后恢复:保留当前 sessionKey不重复加载历史
if (!_sessionKey) {
const saved = localStorage.getItem(STORAGE_SESSION_KEY)
_sessionKey = saved || sessionKey
updateSessionTitle()
loadHistory()
refreshSessionList()
}
// 始终刷新会话列表(无论是否有 sessionKey
refreshSessionList()
})
_unsubEvent = wsClient.onEvent((msg) => {
@@ -195,6 +277,7 @@ async function connectGateway() {
const saved = localStorage.getItem(STORAGE_SESSION_KEY)
_sessionKey = saved || wsClient.sessionKey
updateStatusDot('ready')
showTyping(false) // 确保关闭加载动画
updateSessionTitle()
loadHistory()
refreshSessionList()
@@ -278,23 +361,18 @@ function switchSession(newKey) {
async function showNewSessionDialog() {
const defaultAgent = wsClient.snapshot?.sessionDefaults?.defaultAgentId || 'main'
// 获取 agent 列表
let agents = []
try {
agents = await api.listAgents()
} catch { agents = [{ id: 'main', identityName: '默认', isDefault: true }] }
const agentOptions = agents.map(a => ({
value: a.id,
label: `${a.id}${a.isDefault ? ' (默认)' : ''}${a.identityName ? ' — ' + a.identityName.split(',')[0] : ''}`
}))
agentOptions.push({ value: '__new__', label: '+ 新建 Agent' })
// 先用默认选项立即显示弹窗
const initialOptions = [
{ value: 'main', label: 'main (默认)' },
{ value: '__new__', label: '+ 新建 Agent' }
]
showModal({
title: '新建会话',
fields: [
{ name: 'name', label: '会话名称', value: '', placeholder: '例如:翻译助手' },
{ name: 'agent', label: '智能体', type: 'select', value: defaultAgent, options: agentOptions },
{ name: 'agent', label: '智能体', type: 'select', value: defaultAgent, options: initialOptions },
],
onConfirm: (result) => {
const name = (result.name || '').trim()
@@ -309,6 +387,27 @@ async function showNewSessionDialog() {
toast('会话已创建', 'success')
}
})
// 异步加载完整 Agent 列表并更新下拉框
try {
const agents = await api.listAgents()
const agentOptions = agents.map(a => ({
value: a.id,
label: `${a.id}${a.isDefault ? ' (默认)' : ''}${a.identityName ? ' — ' + a.identityName.split(',')[0] : ''}`
}))
agentOptions.push({ value: '__new__', label: '+ 新建 Agent' })
// 更新弹窗中的下拉框选项
const selectEl = document.querySelector('.modal-overlay [data-name="agent"]')
if (selectEl) {
const currentValue = selectEl.value
selectEl.innerHTML = agentOptions.map(o =>
`<option value="${o.value}" ${o.value === currentValue ? 'selected' : ''}>${o.label}</option>`
).join('')
}
} catch (e) {
console.warn('[chat] 加载 Agent 列表失败:', e)
}
}
async function deleteSession(key) {
@@ -389,22 +488,25 @@ function toggleCmdPanel() {
function sendMessage() {
const text = _textarea.value.trim()
if (!text) return
if (!text && !_attachments.length) return
hideCmdPanel()
_textarea.value = ''
_textarea.style.height = 'auto'
updateSendState()
if (_isSending || _isStreaming) { _messageQueue.push(text); return }
doSend(text)
const attachments = [..._attachments]
_attachments = []
renderAttachments()
if (_isSending || _isStreaming) { _messageQueue.push({ text, attachments }); return }
doSend(text, attachments)
}
async function doSend(text) {
appendUserMessage(text)
async function doSend(text, attachments = []) {
appendUserMessage(text, attachments)
saveMessage({ id: uuid(), sessionKey: _sessionKey, role: 'user', content: text, timestamp: Date.now() })
showTyping(true)
_isSending = true
try {
await wsClient.chatSend(_sessionKey, text)
await wsClient.chatSend(_sessionKey, text, attachments.length ? attachments : undefined)
} catch (err) {
showTyping(false)
appendSystemMessage('发送失败: ' + err.message)
@@ -416,7 +518,9 @@ async function doSend(text) {
function processMessageQueue() {
if (_messageQueue.length === 0 || _isSending || _isStreaming) return
doSend(_messageQueue.shift())
const msg = _messageQueue.shift()
if (typeof msg === 'string') doSend(msg, [])
else doSend(msg.text, msg.attachments || [])
}
function stopGeneration() {
@@ -442,7 +546,12 @@ function handleChatEvent(payload) {
const c = extractChatContent(payload.message)
if (c?.text && c.text.length > _currentAiText.length) {
showTyping(false)
if (!_currentAiBubble) { _currentAiBubble = createStreamBubble(); _currentRunId = payload.runId }
if (!_currentAiBubble) {
_currentAiBubble = createStreamBubble()
_currentRunId = payload.runId
_isStreaming = true
updateSendState()
}
_currentAiText = c.text
throttledRender()
}
@@ -484,10 +593,22 @@ function handleChatEvent(payload) {
if (state === 'error') {
const errMsg = payload.errorMessage || payload.error?.message || '未知错误'
if (_isStreaming) {
console.warn('[chat] 流式中临时错误,等待重试:', errMsg)
// 防抖:如果是相同错误且在 2 秒内,忽略(避免重复显示)
const now = Date.now()
if (_lastErrorMsg === errMsg && _errorTimer && (now - _errorTimer < 2000)) {
console.warn('[chat] 忽略重复错误:', errMsg)
return
}
_lastErrorMsg = errMsg
_errorTimer = now
// 如果正在流式输出,说明消息已经部分成功,不显示错误
if (_isStreaming || _currentAiBubble) {
console.warn('[chat] 流式中收到错误,但消息已部分成功,忽略错误提示:', errMsg)
return
}
showTyping(false)
appendSystemMessage('错误: ' + errMsg)
resetStreamState()
@@ -561,6 +682,8 @@ function resetStreamState() {
_currentAiText = ''
_currentRunId = null
_isStreaming = false
_lastErrorMsg = null
_errorTimer = null
showTyping(false)
updateSendState()
}
@@ -637,12 +760,30 @@ function extractContent(msg) {
// ── DOM 操作 ──
function appendUserMessage(text) {
function appendUserMessage(text, attachments = []) {
const wrap = document.createElement('div')
wrap.className = 'msg msg-user'
const bubble = document.createElement('div')
bubble.className = 'msg-bubble'
bubble.textContent = text
if (attachments.length > 0) {
const imgContainer = document.createElement('div')
imgContainer.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'
imgContainer.appendChild(img)
})
bubble.appendChild(imgContainer)
}
if (text) {
const textNode = document.createElement('div')
textNode.textContent = text
bubble.appendChild(textNode)
}
wrap.appendChild(bubble)
_messagesEl.insertBefore(wrap, _typingEl)
scrollToBottom()

View File

@@ -29,7 +29,7 @@ export async function render() {
</div>
<div class="config-section">
<div class="config-section-title">最近日志</div>
<div class="log-viewer" id="recent-logs" style="max-height:300px"><div class="loading-text">加载中...</div></div>
<div class="log-viewer" id="recent-logs" style="max-height:300px"></div>
</div>
`

View File

@@ -27,12 +27,12 @@ export async function render() {
<div id="cftunnel-card" class="config-section">
<div class="config-section-title">cftunnel 内网穿透</div>
<div class="form-hint" style="margin-bottom:var(--space-md)">通过 Cloudflare Tunnel 将本地服务暴露到公网,无需公网 IP 和端口映射。</div>
<div id="cftunnel-content" class="loading-text">加载中...</div>
<div id="cftunnel-content"></div>
</div>
<div id="clawapp-card" class="config-section">
<div class="config-section-title">ClawApp 移动客户端</div>
<div class="form-hint" style="margin-bottom:var(--space-md)">基于 LobeChat 的 AI 对话客户端,通过 Gateway 连接模型服务。支持本地和外网访问。</div>
<div id="clawapp-content" class="loading-text">加载中...</div>
<div id="clawapp-content"></div>
</div>
`
@@ -52,7 +52,6 @@ async function loadAll(page) {
async function loadCftunnel(page) {
const el = page.querySelector('#cftunnel-content')
el.innerHTML = '<div class="loading-text">加载中...</div>'
try {
const status = await api.getCftunnelStatus()
renderCftunnel(el, status)
@@ -146,7 +145,6 @@ function renderRoutes(routes) {
async function loadClawapp(page) {
const el = page.querySelector('#clawapp-content')
el.innerHTML = '<div class="loading-text">加载中...</div>'
try {
const status = await api.getClawappStatus()
renderClawapp(el, status)
@@ -156,6 +154,18 @@ async function loadClawapp(page) {
}
function renderClawapp(el, s) {
if (!s.installed) {
el.innerHTML = `
<div style="color:var(--text-tertiary);margin-bottom:var(--space-md)">ClawApp 未安装</div>
<div style="display:flex;gap:var(--space-sm);align-items:center">
<button class="btn btn-primary btn-sm" data-action="install-clawapp">一键安装</button>
<a class="btn btn-secondary btn-sm" href="https://github.com/qingchencloud/clawapp" target="_blank" rel="noopener">查看文档</a>
</div>
<div id="install-clawapp-progress-area"></div>
`
return
}
const running = s.running
el.innerHTML = `
<div class="stat-cards" style="margin-bottom:var(--space-md)">
@@ -208,6 +218,9 @@ function bindEvents(page) {
case 'install-cftunnel':
await handleInstallCftunnel(page)
break
case 'install-clawapp':
await handleInstallClawapp(page)
break
}
})
}
@@ -302,3 +315,56 @@ async function handleInstallCftunnel(page) {
unlistenProgress?.()
}
}
async function handleInstallClawapp(page) {
const area = page.querySelector('#install-clawapp-progress-area')
if (!area) return
area.innerHTML = `
<div style="margin-top:var(--space-lg)">
<div class="upgrade-progress-wrap">
<div class="upgrade-progress-bar">
<div class="upgrade-progress-fill" id="install-clawapp-progress-fill" style="width:0%"></div>
</div>
<div class="upgrade-progress-text" id="install-clawapp-progress-text">准备安装...</div>
</div>
<div class="upgrade-log-box" id="install-clawapp-log-box"></div>
</div>
`
const progressFill = area.querySelector('#install-clawapp-progress-fill')
const progressText = area.querySelector('#install-clawapp-progress-text')
const logBox = area.querySelector('#install-clawapp-log-box')
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}%`
})
await api.installClawapp()
progressFill.classList.add('done')
progressText.textContent = '✅ 安装完成'
toast('ClawApp 安装成功', 'success')
setTimeout(() => loadClawapp(page), 3000)
} catch (e) {
progressFill.classList.add('error')
progressText.textContent = '❌ 安装失败'
logBox.textContent += '\n错误: ' + e
toast('安装失败: ' + e, 'error')
} finally {
unlistenLog?.()
unlistenProgress?.()
}
}

View File

@@ -13,7 +13,7 @@ export async function render() {
<h1 class="page-title">Gateway 配置</h1>
<p class="page-desc">Gateway 是 AI 模型的统一入口,所有应用通过它来调用模型服务</p>
</div>
<div id="gateway-config" class="loading-text">加载中...</div>
<div id="gateway-config"></div>
<div class="gw-save-bar">
<button class="btn btn-primary" id="btn-save-gw">
<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>
@@ -42,7 +42,6 @@ export async function render() {
async function loadConfig(page, state) {
const el = page.querySelector('#gateway-config')
el.innerHTML = '<div class="loading-text">加载中...</div>'
try {
state.config = await api.readOpenclawConfig()
renderConfig(page, state)
@@ -76,8 +75,8 @@ function renderConfig(page, state) {
谁能访问
</div>
<div class="gw-option-cards">
<label class="gw-option-card ${gw.bind === 'all' ? '' : 'selected'}" data-bind="loopback">
<input type="radio" name="gw-bind" value="loopback" ${gw.bind === 'all' ? '' : 'checked'} hidden>
<label class="gw-option-card ${(gw.bind === 'lan' || gw.bind === 'all') ? '' : 'selected'}" data-bind="loopback">
<input type="radio" name="gw-bind" value="loopback" ${(gw.bind === 'lan' || gw.bind === 'all') ? '' : 'checked'} hidden>
<div class="gw-option-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
</div>
@@ -86,8 +85,8 @@ function renderConfig(page, state) {
<div class="gw-option-desc">只有这台电脑上的应用能访问,最安全</div>
</div>
</label>
<label class="gw-option-card ${gw.bind === 'all' ? 'selected' : ''}" data-bind="all">
<input type="radio" name="gw-bind" value="all" ${gw.bind === 'all' ? 'checked' : ''} hidden>
<label class="gw-option-card ${(gw.bind === 'lan' || gw.bind === 'all') ? 'selected' : ''}" data-bind="lan">
<input type="radio" name="gw-bind" value="lan" ${(gw.bind === 'lan' || gw.bind === 'all') ? 'checked' : ''} hidden>
<div class="gw-option-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="1" y="6" width="7" height="10" rx="1"/><rect x="9" y="3" width="6" height="14" rx="1"/><rect x="16" y="6" width="7" height="10" rx="1"/><line x1="8" y1="12" x2="9" y2="12"/><line x1="15" y1="12" x2="16" y2="12"/></svg>
</div>

View File

@@ -33,7 +33,7 @@ export async function render() {
<input type="checkbox" id="log-autoscroll" checked> 自动滚动
</label>
</div>
<div class="log-viewer" id="log-content" style="height:calc(100vh - 280px)"><div class="loading-text">加载中...</div></div>
<div class="log-viewer" id="log-content" style="height:calc(100vh - 280px)"></div>
`
let currentTab = 'gateway'
@@ -75,7 +75,6 @@ export function cleanup() {
async function loadLog(page, logName) {
const el = page.querySelector('#log-content')
el.innerHTML = '<div class="loading-text">加载中...</div>'
try {
const content = await api.readLogTail(logName, 200)
if (!content || !content.trim()) {
@@ -95,7 +94,6 @@ async function loadLog(page, logName) {
async function searchLog(page, logName, query) {
const el = page.querySelector('#log-content')
el.innerHTML = '<div class="loading-text">搜索中...</div>'
try {
const results = await api.searchLog(logName, query)
if (!results || !results.length) {

View File

@@ -36,7 +36,7 @@ export async function render() {
<div style="padding:0 var(--space-sm) var(--space-sm)">
<button class="btn btn-sm btn-secondary" id="btn-export-zip" style="width:100%">打包下载全部</button>
</div>
<div id="file-tree" class="loading-text">加载中...</div>
<div id="file-tree"></div>
</div>
<div class="memory-editor">
<div class="editor-toolbar">
@@ -54,15 +54,18 @@ export async function render() {
const state = { category: 'memory', currentPath: null, agentId: 'main' }
// 非阻塞加载 agent 列表,然后填充下拉框
// 先用默认选项填充下拉框,立即显示页面
const agentSelect = page.querySelector('#agent-select')
agentSelect.innerHTML = '<option value="main">main</option>'
// 异步加载 agent 列表并更新下拉框
api.listAgents().then(agents => {
const select = page.querySelector('#agent-select')
if (!select) return
if (!agentSelect) return
const options = agents.map(a => {
const label = a.identityName ? a.identityName.split(',')[0].trim() : a.id
return `<option value="${a.id}">${a.id}${a.id !== label ? ' — ' + label : ''}</option>`
}).join('')
select.innerHTML = options
agentSelect.innerHTML = options
}).catch(() => {})
// Agent 切换
@@ -141,7 +144,6 @@ export async function render() {
async function loadFiles(page, state) {
const tree = page.querySelector('#file-tree')
tree.innerHTML = '<div style="color:var(--text-tertiary);padding:12px">加载中...</div>'
try {
const files = await api.listMemoryFiles(state.category, state.agentId)

View File

@@ -64,7 +64,7 @@ export async function render() {
<div style="margin-bottom:var(--space-md)">
<input class="form-input" id="model-search" placeholder="搜索模型(按 ID 或名称过滤)" style="max-width:360px">
</div>
<div id="providers-list" class="loading-text">加载中...</div>
<div id="providers-list"></div>
`
const state = { config: null, search: '', undoStack: [] }
@@ -83,7 +83,6 @@ export async function render() {
async function loadConfig(page, state) {
const listEl = page.querySelector('#providers-list')
listEl.innerHTML = '<div class="loading-text">加载中...</div>'
try {
state.config = await api.readOpenclawConfig()
renderDefaultBar(page, state)
@@ -360,8 +359,23 @@ async function doAutoSave(state) {
const primary = getCurrentPrimary(state.config)
if (primary) applyDefaultModel(state)
await api.writeOpenclawConfig(state.config)
// Gateway 会自动检测配置变化并热重载,无需手动 kickstart
toast('已自动保存', 'success')
// 提示用户需要重启 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')
}
}
toast('配置已保存,需要重启 Gateway 生效', 'warning', { action: restartBtn })
} catch (e) {
toast('自动保存失败: ' + e, 'error')
}

View File

@@ -26,10 +26,10 @@ export async function render() {
<p class="page-desc">管理 OpenClaw 服务、检查更新、配置备份</p>
</div>
<div id="version-bar"></div>
<div id="services-list" class="loading-text">加载中...</div>
<div id="services-list"></div>
<div class="config-section" id="registry-section">
<div class="config-section-title">npm 源设置</div>
<div id="registry-bar" class="loading-text">加载中...</div>
<div id="registry-bar"></div>
</div>
<div class="config-section" id="backup-section">
<div class="config-section-title">配置备份</div>
@@ -37,7 +37,7 @@ export async function render() {
<div id="backup-actions" style="margin-bottom:var(--space-md)">
<button class="btn btn-primary btn-sm" data-action="create-backup">创建备份</button>
</div>
<div id="backup-list" class="loading-text">加载中...</div>
<div id="backup-list"></div>
</div>
`
@@ -101,7 +101,6 @@ const REGISTRIES = [
async function loadRegistry(page) {
const bar = page.querySelector('#registry-bar')
bar.innerHTML = '<div class="loading-text">加载中...</div>'
try {
const current = await api.getNpmRegistry()
const isPreset = REGISTRIES.some(r => r.value === current)
@@ -131,7 +130,6 @@ async function loadRegistry(page) {
async function loadServices(page) {
const container = page.querySelector('#services-list')
container.innerHTML = '<div class="loading-text">加载中...</div>'
try {
const services = await api.getServicesStatus()
renderServices(container, services)
@@ -200,7 +198,6 @@ function renderServices(container, services) {
async function loadBackups(page) {
const list = page.querySelector('#backup-list')
list.innerHTML = '<div class="loading-text">加载中...</div>'
try {
const backups = await api.listBackups()
renderBackups(list, backups)
@@ -291,6 +288,7 @@ const ACTION_LABELS = { start: '启动', stop: '停止', restart: '重启' }
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')
await loadServices(page)

View File

@@ -548,3 +548,66 @@
.cmd-desc {
color: var(--text-secondary);
}
/* 文件上传 */
.chat-attach-btn {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 8px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.chat-attach-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.chat-attachments-preview {
display: flex;
gap: 8px;
padding: 8px;
flex-wrap: wrap;
}
.chat-attachment-item {
position: relative;
width: 80px;
height: 80px;
border-radius: 6px;
overflow: hidden;
border: 1px solid var(--border);
}
.chat-attachment-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
.chat-attachment-del {
position: absolute;
top: 2px;
right: 2px;
background: rgba(0,0,0,0.6);
color: white;
border: none;
border-radius: 50%;
width: 20px;
height: 20px;
cursor: pointer;
font-size: 14px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
}
.chat-attachment-del:hover {
background: rgba(255,0,0,0.8);
}