chore: release v0.9.7

This commit is contained in:
晴天
2026-03-21 04:08:41 +08:00
parent 1a18e3c644
commit 6494cf6551
22 changed files with 716 additions and 96 deletions

View File

@@ -181,6 +181,8 @@ export const api = {
writeMcpConfig: (config) => { invalidate('read_mcp_config'); return invoke('write_mcp_config', { config }) },
reloadGateway: () => invoke('reload_gateway'),
restartGateway: () => invoke('restart_gateway'),
doctorCheck: () => invoke('doctor_check'),
doctorFix: () => invoke('doctor_fix'),
listOpenclawVersions: (source = 'chinese') => invoke('list_openclaw_versions', { source }),
upgradeOpenclaw: (source = 'chinese', version = null, method = 'auto') => invoke('upgrade_openclaw', { source, version, method }),
uninstallOpenclaw: (cleanConfig = false) => invoke('uninstall_openclaw', { cleanConfig }),
@@ -222,6 +224,8 @@ export const api = {
installChannelPlugin: (packageName, pluginId) => invoke('install_channel_plugin', { packageName, pluginId }),
// 面板配置 (clawpanel.json)
getOpenclawDir: () => invoke('get_openclaw_dir'),
relaunchApp: () => invoke('relaunch_app'),
readPanelConfig: () => invoke('read_panel_config'),
writePanelConfig: (config) => invoke('write_panel_config', { config }),
testProxy: (url) => invoke('test_proxy', { url: url || null }),

View File

@@ -162,8 +162,14 @@ async function showAddAgentDialog(page, state) {
try {
await api.addAgent(id, model, workspace || null)
// 身份信息更新(非关键,失败不阻塞)
if (name || emoji) {
await api.updateAgentIdentity(id, name || null, emoji || null)
try {
await api.updateAgentIdentity(id, name || null, emoji || null)
} catch (identityErr) {
console.warn('[Agent] 身份信息更新失败Agent 已创建):', identityErr)
toast('Agent 已创建,但名称设置失败,可稍后编辑', 'warning')
}
}
toast('Agent 已创建', 'success')

View File

@@ -371,7 +371,7 @@ async function openConfigDialog(pid, page, state) {
// 飞书插件版本检测:根据已安装的插件自动选择
if (pid === 'feishu' && !existing.pluginVersion) {
try {
const officialStatus = await api.getChannelPluginStatus('feishu-openclaw-plugin')
const officialStatus = await api.getChannelPluginStatus('openclaw-lark') || await api.getChannelPluginStatus('feishu-openclaw-plugin')
if (officialStatus?.installed) existing.pluginVersion = 'official'
else existing.pluginVersion = localStorage.getItem('clawpanel-feishu-plugin-version') || 'builtin'
} catch { existing.pluginVersion = 'builtin' }
@@ -597,8 +597,8 @@ async function openConfigDialog(pid, page, state) {
const pluginVersion = pluginVersionField?.value || 'builtin'
localStorage.setItem('clawpanel-feishu-plugin-version', pluginVersion)
if (pluginVersion === 'official') {
pluginPackage = '@larksuiteoapi/feishu-openclaw-plugin'
pluginId = 'feishu-openclaw-plugin'
pluginPackage = 'openclaw-lark'
pluginId = 'openclaw-lark'
}
}
const pluginStatus = await api.getChannelPluginStatus(pluginId)

View File

@@ -6,23 +6,46 @@ import { api, getRequestLogs, clearRequestLogs } from '../lib/tauri-api.js'
import { wsClient } from '../lib/ws-client.js'
import { isOpenclawReady, isGatewayRunning } from '../lib/app-state.js'
import { icon, statusIcon } from '../lib/icons.js'
import { toast } from '../components/toast.js'
import { navigate } from '../router.js'
export async function render() {
const page = document.createElement('div')
page.className = 'page'
page.innerHTML = `
<div class="page-header">
<div class="page-header" style="margin-bottom:var(--space-lg)">
<h1 class="page-title">系统诊断</h1>
<p class="page-desc">全面检测系统状态,快速定位问题</p>
<div style="display:flex;gap:8px">
<p class="page-desc" style="margin-bottom:1em">全面检测系统状态,快速定位问题</p>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="btn btn-primary btn-sm" id="btn-refresh">刷新状态</button>
<button class="btn btn-secondary btn-sm" id="btn-doctor-check">诊断配置</button>
<button class="btn btn-warning btn-sm" id="btn-doctor-fix">自动修复</button>
<button class="btn btn-secondary btn-sm" id="btn-test-ws">测试 WebSocket</button>
<button class="btn btn-secondary btn-sm" id="btn-network-log">网络日志</button>
<button class="btn btn-warning btn-sm" id="btn-fix-pairing">一键修复配对</button>
<button class="btn btn-secondary btn-sm" id="btn-fix-pairing">一键修复配对</button>
</div>
</div>
<div id="debug-content">
<div class="config-section" style="border-left:3px solid var(--border)">
<div style="display:flex;gap:var(--space-sm);align-items:center">
<div class="loading-placeholder" style="width:24px;height:24px;border-radius:50%"></div>
<div class="loading-placeholder" style="width:120px;height:20px;border-radius:4px"></div>
</div>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:var(--space-md)">
<div class="config-section"><div class="config-section-title" style="margin-bottom:8px">应用状态</div><div class="loading-placeholder" style="height:48px;border-radius:4px"></div></div>
<div class="config-section"><div class="config-section-title" style="margin-bottom:8px">WebSocket 连接</div><div class="loading-placeholder" style="height:48px;border-radius:4px"></div></div>
<div class="config-section"><div class="config-section-title" style="margin-bottom:8px">Node.js 环境</div><div class="loading-placeholder" style="height:48px;border-radius:4px"></div></div>
<div class="config-section"><div class="config-section-title" style="margin-bottom:8px">版本信息</div><div class="loading-placeholder" style="height:48px;border-radius:4px"></div></div>
</div>
</div>
<div id="doctor-output" style="display:none;margin-top:var(--space-md)">
<div class="config-section">
<div class="config-section-title">配置诊断输出</div>
<pre style="background:var(--bg-secondary);border-radius:var(--radius);padding:var(--space-sm);font-size:var(--font-size-xs);max-height:300px;overflow:auto;white-space:pre-wrap;word-break:break-all"></pre>
</div>
</div>
<div id="debug-content"></div>
<div id="ws-test-log" style="display:none;margin-top:16px;background:var(--bg-secondary);border-radius:6px;padding:12px">
<div style="font-weight:600;margin-bottom:8px;display:flex;justify-content:space-between;align-items:center">
<span>WebSocket 连接测试</span>
@@ -46,6 +69,8 @@ export async function render() {
page.querySelector('#btn-test-ws').addEventListener('click', () => testWebSocket(page))
page.querySelector('#btn-network-log').addEventListener('click', () => toggleNetworkLog(page))
page.querySelector('#btn-fix-pairing').addEventListener('click', () => fixPairing(page))
page.querySelector('#btn-doctor-check').addEventListener('click', () => handleDoctor(page, false))
page.querySelector('#btn-doctor-fix').addEventListener('click', () => handleDoctor(page, true))
loadDebugInfo(page)
return page
}
@@ -261,6 +286,69 @@ function renderDebugInfo(el, info) {
el.innerHTML = html
}
// 配置诊断 / 自动修复openclaw doctor
async function handleDoctor(page, fix) {
const btnCheck = page.querySelector('#btn-doctor-check')
const btnFix = page.querySelector('#btn-doctor-fix')
const outputDiv = page.querySelector('#doctor-output')
const section = outputDiv?.querySelector('.config-section')
const pre = outputDiv?.querySelector('pre')
if (!outputDiv || !pre) return
// 清除之前的提示
section?.querySelectorAll('.doctor-tip').forEach(el => el.remove())
if (btnCheck) btnCheck.disabled = true
if (btnFix) btnFix.disabled = true
if (fix && btnFix) btnFix.textContent = '修复中...'
if (!fix && btnCheck) btnCheck.textContent = '诊断中...'
outputDiv.style.display = 'block'
pre.textContent = fix ? '正在运行 openclaw doctor --fix ...' : '正在运行 openclaw doctor ...'
pre.style.color = 'var(--text-secondary)'
try {
const result = fix ? await api.doctorFix() : await api.doctorCheck()
let text = result.output || ''
if (result.errors) text += '\n' + result.errors
const fullText = text.trim()
pre.textContent = fullText || (result.success ? '✓ 未发现问题' : '诊断完成')
pre.style.color = result.success ? 'var(--success)' : 'var(--warning)'
if (fullText.includes('ERR_MODULE_NOT_FOUND') || fullText.includes('Cannot find module')) {
appendDoctorTip(section, 'OpenClaw 安装可能已损坏', '检测到模块文件缺失,建议前往 <a href="#" data-nav="about" style="color:var(--primary);text-decoration:underline;font-weight:500">关于页面</a> 切换版本或重新安装 OpenClaw CLI。')
toast('OpenClaw 安装损坏,建议前往「关于」页重新安装', 'warning')
} else if (fix && result.success) {
toast('配置修复完成', 'success')
} else if (fix) {
toast('修复完成,部分问题可能需手动处理', 'warning')
}
} catch (e) {
const errMsg = e?.message || String(e)
pre.textContent = '执行失败: ' + errMsg
pre.style.color = 'var(--error)'
if (errMsg.includes('ERR_MODULE_NOT_FOUND') || errMsg.includes('Cannot find module') || errMsg.includes('未找到')) {
appendDoctorTip(section, 'OpenClaw CLI 不可用', '请前往 <a href="#" data-nav="about" style="color:var(--primary);text-decoration:underline;font-weight:500">关于页面</a> 安装或重新安装 OpenClaw。')
}
toast('执行失败: ' + e, 'error')
} finally {
if (btnCheck) { btnCheck.disabled = false; btnCheck.textContent = '诊断配置' }
if (btnFix) { btnFix.disabled = false; btnFix.textContent = '自动修复' }
}
}
function appendDoctorTip(parent, title, body) {
if (!parent) return
const tip = document.createElement('div')
tip.className = 'doctor-tip'
tip.style.cssText = 'margin-top:var(--space-sm);padding:var(--space-sm);background:rgba(239,68,68,0.08);border-radius:var(--radius);font-size:var(--font-size-sm);color:var(--error);line-height:1.6'
tip.innerHTML = `<strong>⚠ ${title}</strong><br>${body}`
tip.querySelector('[data-nav="about"]')?.addEventListener('click', (e) => {
e.preventDefault()
navigate('/about')
})
parent.appendChild(tip)
}
function escapeHtml(str) {
if (!str) return ''
return String(str)

View File

@@ -72,6 +72,7 @@ const _toolEventData = new Map()
const _toolRunIndex = new Map()
const _toolEventSeen = new Set()
let _errorTimer = null, _lastErrorMsg = null
let _responseWatchdog = null, _postFinalCheck = null
let _attachments = []
let _hasEverConnected = false
let _availableModels = []
@@ -155,6 +156,7 @@ export async function render() {
<div class="chat-messages" id="chat-messages">
<div class="typing-indicator" id="typing-indicator" style="display:none">
<span></span><span></span><span></span>
<span class="typing-hint"></span>
</div>
</div>
<button class="chat-scroll-btn" id="chat-scroll-btn" style="display:none">↓</button>
@@ -1000,10 +1002,12 @@ async function doSend(text, attachments = []) {
})
showTyping(true)
_isSending = true
_startResponseWatchdog()
try {
await wsClient.chatSend(_sessionKey, text, attachments.length ? attachments : undefined)
} catch (err) {
showTyping(false)
_cancelResponseWatchdog()
appendSystemMessage('发送失败: ' + err.message)
} finally {
_isSending = false
@@ -1046,6 +1050,11 @@ function handleEvent(msg) {
if (!list.includes(toolCallId)) list.push(toolCallId)
_toolRunIndex.set(payload.runId, list)
}
// 工具执行反馈:更新 typing 提示文字
const toolName = payload.data?.name || payload.data?.toolName || ''
if (toolName && !_isStreaming) {
showTyping(true, `正在使用工具: ${toolName}`)
}
}
if (event === 'chat') handleChatEvent(payload)
@@ -1079,6 +1088,7 @@ function handleChatEvent(payload) {
}
if (state === 'delta') {
_cancelResponseWatchdog()
const c = extractChatContent(payload.message)
if (c?.images?.length) _currentAiImages = c.images
if (c?.videos?.length) _currentAiVideos = c.videos
@@ -1114,6 +1124,7 @@ function handleChatEvent(payload) {
}
if (state === 'final') {
_cancelResponseWatchdog()
const c = extractChatContent(payload.message)
const finalText = c?.text || ''
const finalImages = c?.images || []
@@ -1205,6 +1216,7 @@ function handleChatEvent(payload) {
}
}
resetStreamState()
_schedulePostFinalCheck()
processMessageQueue()
return
}
@@ -1464,6 +1476,45 @@ function doRender() {
}
}
// ── 响应看门狗:防止页面卡在等待状态 ──
function _startResponseWatchdog() {
_cancelResponseWatchdog()
_responseWatchdog = setTimeout(async () => {
_responseWatchdog = null
// 如果还在等待(未开始流式),强制刷新历史
if (!_isStreaming && _sessionKey && _messagesEl && _pageActive) {
console.log('[chat] 响应看门狗触发15s 无 delta刷新历史')
const oldHash = _lastHistoryHash
_lastHistoryHash = ''
await loadHistory()
// 如果历史有更新,关闭 typing 指示器
if (_lastHistoryHash && _lastHistoryHash !== oldHash) {
showTyping(false)
} else {
// 历史没更新,继续等待,再设一轮看门狗
_startResponseWatchdog()
}
}
}, 15000)
}
function _cancelResponseWatchdog() {
clearTimeout(_responseWatchdog)
_responseWatchdog = null
}
function _schedulePostFinalCheck() {
clearTimeout(_postFinalCheck)
_postFinalCheck = setTimeout(async () => {
_postFinalCheck = null
if (_sessionKey && _messagesEl && _pageActive && !_isStreaming && !_isSending) {
_lastHistoryHash = ''
await loadHistory()
}
}, 2000)
}
// ensureAiBubble 已被 createStreamBubble 替代
function resetStreamState() {
@@ -1986,8 +2037,13 @@ function clearMessages() {
_lastScrollTop = 0
}
function showTyping(show) {
if (_typingEl) _typingEl.style.display = show ? 'flex' : 'none'
function showTyping(show, hint) {
if (_typingEl) {
_typingEl.style.display = show ? 'flex' : 'none'
// 更新提示文字(如工具调用状态)
const hintEl = _typingEl.querySelector('.typing-hint')
if (hintEl) hintEl.textContent = hint || ''
}
if (show) scrollToBottom()
}
@@ -2430,6 +2486,9 @@ export function cleanup() {
if (_unsubReady) { _unsubReady(); _unsubReady = null }
if (_unsubStatus) { _unsubStatus(); _unsubStatus = null }
clearTimeout(_streamSafetyTimer)
_cancelResponseWatchdog()
clearTimeout(_postFinalCheck)
_postFinalCheck = null
if (_hostedAbort) { _hostedAbort.abort(); _hostedAbort = null }
_sessionKey = null
_page = null

View File

@@ -200,6 +200,8 @@ function renderProviders(page, state) {
return
}
if (!state._collapsed) state._collapsed = {}
listEl.innerHTML = keys.map(key => {
const p = providers[key]
const models = p.models || []
@@ -212,10 +214,12 @@ function renderProviders(page, state) {
: models
const sorted = sortModels(filtered, sortBy)
const hiddenCount = models.length - sorted.length
const collapsed = !!state._collapsed[key]
const chevron = collapsed ? '▸' : '▾'
return `
<div class="config-section" data-provider="${key}">
<div class="config-section-title" style="display:flex;justify-content:space-between;align-items:center">
<span>${key} <span style="font-size:var(--font-size-xs);color:var(--text-tertiary);font-weight:400">${getApiTypeLabel(p.api)} · ${models.length} 个模型</span></span>
<span style="cursor:pointer;user-select:none" data-action="toggle-provider"><span style="display:inline-block;width:16px;font-size:12px;color:var(--text-tertiary)">${chevron}</span>${key} <span style="font-size:var(--font-size-xs);color:var(--text-tertiary);font-weight:400">${getApiTypeLabel(p.api)} · ${models.length} 个模型</span></span>
<div style="display:flex;gap:8px">
<button class="btn btn-sm btn-secondary" data-action="edit-provider">编辑</button>
<button class="btn btn-sm btn-secondary" data-action="add-model">+ 模型</button>
@@ -223,6 +227,7 @@ function renderProviders(page, state) {
<button class="btn btn-sm btn-danger" data-action="delete-provider">删除</button>
</div>
</div>
<div class="provider-body" style="${collapsed ? 'display:none' : ''}">
${models.length >= 2 ? `
<div style="display:flex;gap:6px;margin-bottom:var(--space-sm);align-items:center">
<button class="btn btn-sm btn-secondary" data-action="batch-test">批量测试</button>
@@ -246,6 +251,7 @@ function renderProviders(page, state) {
${renderModelCards(key, sorted, primary, search)}
${hiddenCount > 0 ? `<div style="font-size:var(--font-size-xs);color:var(--text-tertiary);padding:4px 0">已隐藏 ${hiddenCount} 个不匹配的模型</div>` : ''}
</div>
</div>
</div>
`
}).join('')
@@ -353,11 +359,33 @@ function autoSave(state) {
_saveTimer = setTimeout(() => doAutoSave(state), 300)
}
/** 保存前规范化所有服务商的 baseUrl确保 Gateway 能正确调用 */
/** 已知的 API 类型错误→正确映射,自动修复用户手动编辑或旧版本配置 */
const API_TYPE_FIXES = {
'google-gemini': 'google-generative-ai',
'gemini': 'google-generative-ai',
'google': 'google-generative-ai',
'anthropic': 'anthropic-messages',
'openai': 'openai-completions',
'openai-chat': 'openai-completions',
}
const VALID_API_TYPES = new Set(API_TYPES.map(t => t.value))
/** 保存前规范化所有服务商的 baseUrl 和 API 类型,确保 Gateway 能正确调用 */
function normalizeProviderUrls(config) {
const providers = config?.models?.providers
if (!providers) return
for (const [, p] of Object.entries(providers)) {
// 修复 API 类型
if (p.api) {
const lower = p.api.toLowerCase().trim()
if (API_TYPE_FIXES[lower]) {
p.api = API_TYPE_FIXES[lower]
} else if (!VALID_API_TYPES.has(lower)) {
console.warn(`[models] 未知 API 类型「${p.api}」,自动修正为 openai-completions`)
p.api = 'openai-completions'
}
}
if (!p.baseUrl) continue
let url = p.baseUrl.replace(/\/+$/, '')
// 去掉尾部的已知端点路径(用户可能粘贴了完整 URL
@@ -368,7 +396,7 @@ function normalizeProviderUrls(config) {
const apiType = (p.api || 'openai-completions').toLowerCase()
if (apiType === 'anthropic-messages') {
if (!url.endsWith('/v1')) url += '/v1'
} else if (apiType !== 'google-gemini') {
} else if (apiType !== 'google-generative-ai') {
// Ollama 端口检测11434 默认需要加 /v1
if (/:11434$/.test(url) && !url.endsWith('/v1')) url += '/v1'
// 不再强制追加 /v1尊重用户填写的 URL火山引擎等第三方用 /v3 等路径)
@@ -554,6 +582,17 @@ function bindProviderButtons(listEl, page, state) {
})
})
// 折叠/展开服务商
listEl.querySelectorAll('[data-action="toggle-provider"]').forEach(span => {
span.onclick = () => {
const section = span.closest('[data-provider]')
if (!section) return
const key = section.dataset.provider
state._collapsed[key] = !state._collapsed[key]
renderProviders(page, state)
}
})
// 绑定按钮
listEl.querySelectorAll('button[data-action], input[data-action]').forEach(btn => {
const action = btn.dataset.action

View File

@@ -4,6 +4,7 @@
*/
import { api } from '../lib/tauri-api.js'
import { toast } from '../components/toast.js'
import { showConfirm } from '../components/modal.js'
const isTauri = !!window.__TAURI_INTERNALS__
@@ -42,6 +43,12 @@ export async function render() {
<div class="config-section-title">npm 源设置</div>
<div id="registry-bar"><div class="stat-card loading-placeholder" style="height:48px"></div></div>
</div>
<div class="config-section" id="openclaw-dir-section">
<div class="config-section-title">OpenClaw 安装路径</div>
<div id="openclaw-dir-bar"><div class="stat-card loading-placeholder" style="height:48px"></div></div>
</div>
`
bindEvents(page)
@@ -50,7 +57,7 @@ export async function render() {
}
async function loadAll(page) {
const tasks = [loadProxyConfig(page), loadModelProxyConfig(page)]
const tasks = [loadProxyConfig(page), loadModelProxyConfig(page), loadOpenclawDir(page)]
tasks.push(loadRegistry(page))
await Promise.all(tasks)
}
@@ -139,6 +146,72 @@ async function loadRegistry(page) {
}
}
// ===== OpenClaw 安装路径 =====
async function loadOpenclawDir(page) {
const bar = page.querySelector('#openclaw-dir-bar')
if (!bar) return
try {
const info = isTauri ? await api.getOpenclawDir() : { path: '~/.openclaw', isCustom: false, configExists: true }
const cfg = await api.readPanelConfig()
const customValue = cfg?.openclawDir || ''
const statusText = info.configExists
? '<span style="color:var(--success)">配置文件存在</span>'
: '<span style="color:var(--warning)">配置文件不存在</span>'
bar.innerHTML = `
<div style="margin-bottom:var(--space-xs)">
<span class="form-hint">当前路径:</span>
<strong style="font-size:var(--font-size-sm)">${escapeHtml(info.path)}</strong>
<span style="margin-left:var(--space-xs);font-size:var(--font-size-xs)">${statusText}</span>
${info.isCustom ? '<span class="clawhub-badge" style="margin-left:var(--space-xs);background:rgba(99,102,241,0.14);color:#6366f1;font-size:var(--font-size-xs)">自定义</span>' : ''}
</div>
<div style="display:flex;align-items:center;gap:var(--space-sm);flex-wrap:wrap">
<input class="form-input" data-name="openclaw-dir" placeholder="留空使用默认路径 ~/.openclaw" value="${escapeHtml(customValue)}" style="max-width:420px">
<button class="btn btn-primary btn-sm" data-action="save-openclaw-dir">保存</button>
${info.isCustom ? '<button class="btn btn-secondary btn-sm" data-action="reset-openclaw-dir">恢复默认</button>' : ''}
</div>
<div class="form-hint" style="margin-top:var(--space-xs)">
自定义 OpenClaw 配置目录路径。修改后需要重启面板生效。目标目录必须存在且包含 <code>openclaw.json</code>。
</div>
`
} catch (e) {
bar.innerHTML = `<div style="color:var(--error)">加载失败: ${escapeHtml(String(e))}</div>`
}
}
async function handleSaveOpenclawDir(page) {
const input = page.querySelector('[data-name="openclaw-dir"]')
const value = (input?.value || '').trim()
const cfg = await api.readPanelConfig()
if (value) {
cfg.openclawDir = value
} else {
delete cfg.openclawDir
}
await api.writePanelConfig(cfg)
await loadOpenclawDir(page)
await promptRestart(value ? '自定义路径已保存' : '已恢复默认路径')
}
async function handleResetOpenclawDir(page) {
const cfg = await api.readPanelConfig()
delete cfg.openclawDir
await api.writePanelConfig(cfg)
await loadOpenclawDir(page)
await promptRestart('已恢复默认路径')
}
async function promptRestart(msg) {
if (!isTauri) { toast(msg, 'success'); return }
const ok = await showConfirm(`${msg}\n\n需要重启面板才能生效,是否立即重启?`)
if (ok) {
toast('正在重启...', 'info')
try { await api.relaunchApp() } catch { toast('自动重启失败,请手动关闭后重新打开', 'warning') }
} else {
toast(`${msg},下次启动时生效`, 'success')
}
}
// ===== 事件绑定 =====
function bindEvents(page) {
@@ -164,6 +237,12 @@ function bindEvents(page) {
case 'save-registry':
await handleSaveRegistry(page)
break
case 'save-openclaw-dir':
await handleSaveOpenclawDir(page)
break
case 'reset-openclaw-dir':
await handleResetOpenclawDir(page)
break
}
} catch (e) {
toast(e.toString(), 'error')
@@ -171,6 +250,7 @@ function bindEvents(page) {
btn.disabled = false
}
})
}
function normalizeProxyUrl(value) {

View File

@@ -175,7 +175,7 @@ function renderSteps(page, { node, git, cliOk, config, version }) {
}
</div>
`
// 第四步:配置文件
// 第四步:配置文件 + 自定义路径
html += `
<div class="config-section" style="text-align:left;${cliOk ? '' : 'opacity:0.4;pointer-events:none'}">
<div class="config-section-title" style="display:flex;align-items:center;gap:4px">
@@ -188,6 +188,23 @@ function renderSteps(page, { node, git, cliOk, config, version }) {
</p>
<button class="btn btn-primary btn-sm" id="btn-init-config">一键初始化配置</button>`
}
<details style="margin-top:var(--space-sm);cursor:pointer" id="custom-dir-details">
<summary style="font-size:var(--font-size-xs);color:var(--text-secondary);font-weight:600;user-select:none">
自定义 OpenClaw 安装路径
</summary>
<div style="margin-top:var(--space-sm);padding:10px 12px;background:var(--bg-tertiary);border-radius:var(--radius-sm);font-size:var(--font-size-xs);line-height:1.6">
<p style="color:var(--text-secondary);margin-bottom:8px">
如果 OpenClaw 安装在非默认目录(如 <code>E:\\数据\\AI\\.openclaw</code>),可在此指定。留空则使用默认路径。
</p>
<div style="display:flex;gap:6px">
<input id="input-openclaw-dir" type="text" placeholder="例如 E:\\数据\\AI\\.openclaw"
style="flex:1;padding:4px 8px;border:1px solid var(--border-primary);border-radius:var(--radius-sm);background:var(--bg-secondary);color:var(--text-primary);font-size:11px;font-family:monospace">
<button class="btn btn-primary btn-sm" id="btn-save-openclaw-dir" style="font-size:11px;padding:3px 10px">保存</button>
<button class="btn btn-secondary btn-sm" id="btn-reset-openclaw-dir" style="font-size:11px;padding:3px 10px">恢复默认</button>
</div>
<div id="openclaw-dir-result" style="margin-top:6px;display:none"></div>
</div>
</details>
</div>
`
@@ -432,6 +449,62 @@ function bindEvents(page, nodeOk, detectState) {
}
})
// 自定义 OpenClaw 安装路径
const dirInput = page.querySelector('#input-openclaw-dir')
const dirResultEl = page.querySelector('#openclaw-dir-result')
// 预填当前自定义路径
if (dirInput) {
api.getOpenclawDir().then(info => {
if (info.isCustom) {
dirInput.value = info.path
// 已有自定义路径时自动展开
const details = page.querySelector('#custom-dir-details')
if (details) details.open = true
}
}).catch(() => {})
}
page.querySelector('#btn-save-openclaw-dir')?.addEventListener('click', async () => {
const value = dirInput?.value?.trim()
if (!value) { toast('请输入路径', 'warning'); return }
const btn = page.querySelector('#btn-save-openclaw-dir')
btn.disabled = true
if (dirResultEl) { dirResultEl.style.display = 'block'; dirResultEl.innerHTML = '<span style="color:var(--text-tertiary)">保存中...</span>' }
try {
const cfg = await api.readPanelConfig()
cfg.openclawDir = value
await api.writePanelConfig(cfg)
invalidate()
if (dirResultEl) dirResultEl.innerHTML = `<span style="color:var(--success)">✓ 路径已保存,正在重新检测...</span>`
toast('自定义路径已保存', 'success')
setTimeout(() => runDetect(page), 500)
} catch (e) {
if (dirResultEl) dirResultEl.innerHTML = `<span style="color:var(--error)">保存失败: ${e}</span>`
toast('保存失败: ' + e, 'error')
} finally {
btn.disabled = false
}
})
page.querySelector('#btn-reset-openclaw-dir')?.addEventListener('click', async () => {
const btn = page.querySelector('#btn-reset-openclaw-dir')
btn.disabled = true
try {
const cfg = await api.readPanelConfig()
delete cfg.openclawDir
await api.writePanelConfig(cfg)
invalidate()
if (dirInput) dirInput.value = ''
if (dirResultEl) { dirResultEl.style.display = 'block'; dirResultEl.innerHTML = `<span style="color:var(--success)">✓ 已恢复默认路径,正在重新检测...</span>` }
toast('已恢复默认路径', 'success')
setTimeout(() => runDetect(page), 500)
} catch (e) {
toast('恢复失败: ' + e, 'error')
} finally {
btn.disabled = false
}
})
// 一键初始化配置
page.querySelector('#btn-init-config')?.addEventListener('click', async () => {
const btn = page.querySelector('#btn-init-config')

View File

@@ -280,7 +280,13 @@ async function handleSourceSearch(page) {
const q = input.value.trim()
if (!q) { results.innerHTML = '<div class="clawhub-empty">输入关键词搜索社区 Skills</div>'; return }
const source = getInstallSource()
// SkillHub 未安装时友好提示
// SkillHub 未安装时友好提示(先实时检测一次,避免竞态误判)
if (source === 'skillhub' && !_skillhubInstalled) {
try {
const info = await api.skillsSkillHubCheck()
_skillhubInstalled = !!info.installed
} catch { /* ignore */ }
}
if (source === 'skillhub' && !_skillhubInstalled) {
results.innerHTML = `<div style="padding:var(--space-lg);text-align:center">
<div style="color:var(--warning);margin-bottom:8px">⚠️ 请先安装 SkillHub CLI</div>
@@ -361,6 +367,7 @@ async function handleSkillHubSetup(page) {
if (statusEl) statusEl.textContent = '正在安装 SkillHub CLI...'
try {
await api.skillsSkillHubSetup(true)
_skillhubInstalled = true
toast('SkillHub CLI 安装成功', 'success')
if (statusEl) statusEl.textContent = '✅ 已安装'
// 隐藏安装按钮

View File

@@ -373,6 +373,17 @@
.typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
.typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
.typing-hint {
width: auto !important;
height: auto !important;
background: none !important;
animation: none !important;
font-size: 12px;
color: var(--text-tertiary);
margin-left: 4px;
white-space: nowrap;
}
@keyframes typing-bounce {
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
30% { transform: translateY(-6px); opacity: 1; }