mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-06-03 06:40:10 +08:00
chore: release v0.9.7
This commit is contained in:
@@ -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 }),
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 = '✅ 已安装'
|
||||
// 隐藏安装按钮
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user