mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-06-09 01:30:40 +08:00
feat: improve gateway compatibility and complete i18n cleanup
This commit is contained in:
697
src/pages/agent-detail.js
Normal file
697
src/pages/agent-detail.js
Normal file
@@ -0,0 +1,697 @@
|
||||
/**
|
||||
* Agent 详情页
|
||||
* 概览 / 文件 / 渠道 三个 Tab
|
||||
*/
|
||||
import { api, invalidate } from '../lib/tauri-api.js'
|
||||
import { toast } from '../components/toast.js'
|
||||
import { showConfirm } from '../components/modal.js'
|
||||
import { CHANNEL_LABELS } from '../lib/channel-labels.js'
|
||||
import { t } from '../lib/i18n.js'
|
||||
|
||||
function esc(str) {
|
||||
if (!str) return ''
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
}
|
||||
|
||||
export async function render() {
|
||||
const params = new URLSearchParams(location.hash.split('?')[1] || '')
|
||||
const agentId = params.get('id') || 'main'
|
||||
|
||||
const page = document.createElement('div')
|
||||
page.className = 'page agent-detail-page'
|
||||
|
||||
page.innerHTML = `
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<a class="agent-back-link" href="#/agents">${t('agentDetail.back')}</a>
|
||||
<h1 class="page-title" id="agent-detail-title">Agent: ${esc(agentId)}</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-bar" id="agent-tabs">
|
||||
<div class="tab active" data-tab="overview">${t('agentDetail.tabOverview')}</div>
|
||||
<div class="tab" data-tab="files">${t('agentDetail.tabFiles')}</div>
|
||||
<div class="tab" data-tab="channels">${t('agentDetail.tabChannels')}</div>
|
||||
<div class="tab" data-tab="tools">${t('agentDetail.tabTools')}</div>
|
||||
<div class="tab" data-tab="skills">${t('agentDetail.tabSkills')}</div>
|
||||
</div>
|
||||
<div class="page-content">
|
||||
<div id="agent-tab-content"></div>
|
||||
</div>
|
||||
`
|
||||
|
||||
const state = { agentId, detail: null, files: null, models: [], skillsCatalog: [] }
|
||||
|
||||
// Tab 切换
|
||||
page.querySelector('#agent-tabs').addEventListener('click', (e) => {
|
||||
const tab = e.target.closest('.tab')
|
||||
if (!tab) return
|
||||
page.querySelectorAll('#agent-tabs .tab').forEach(t => t.classList.remove('active'))
|
||||
tab.classList.add('active')
|
||||
switchTab(page, state, tab.dataset.tab)
|
||||
})
|
||||
|
||||
// 首次加载
|
||||
loadDetail(page, state)
|
||||
|
||||
return page
|
||||
}
|
||||
|
||||
async function loadDetail(page, state) {
|
||||
const content = page.querySelector('#agent-tab-content')
|
||||
content.innerHTML = '<div class="skeleton" style="width:100%;height:200px;border-radius:8px"></div>'
|
||||
try {
|
||||
const [detail, config, skillsResp] = await Promise.all([
|
||||
api.getAgentDetail(state.agentId),
|
||||
api.readOpenclawConfig().catch(() => null),
|
||||
api.skillsList().catch(() => ({ skills: [] })),
|
||||
])
|
||||
state.detail = detail
|
||||
// 解析可用模型
|
||||
state.models = parseModelList(config)
|
||||
state.skillsCatalog = Array.isArray(skillsResp?.skills) ? skillsResp.skills : []
|
||||
// 更新标题
|
||||
const title = page.querySelector('#agent-detail-title')
|
||||
const name = detail.identity?.name || detail.name || detail.id
|
||||
const emoji = detail.identity?.emoji || ''
|
||||
title.textContent = `${emoji} ${name}`.trim()
|
||||
if (detail.isDefault) {
|
||||
title.insertAdjacentHTML('beforeend', ` <span class="badge badge-success">${t('agentDetail.defaultAgent')}</span>`)
|
||||
}
|
||||
switchTab(page, state, 'overview')
|
||||
} catch (e) {
|
||||
content.innerHTML = `<div style="color:var(--error);padding:20px">${t('agentDetail.loadFailed')}: ${esc(String(e))}</div>`
|
||||
}
|
||||
}
|
||||
|
||||
function parseModelList(config) {
|
||||
const models = []
|
||||
const providers = config?.models?.providers || {}
|
||||
for (const [pk, pv] of Object.entries(providers)) {
|
||||
for (const m of (pv.models || [])) {
|
||||
const id = typeof m === 'string' ? m : m.id
|
||||
if (id) models.push(`${pk}/${id}`)
|
||||
}
|
||||
}
|
||||
return models
|
||||
}
|
||||
|
||||
function switchTab(page, state, tab) {
|
||||
const content = page.querySelector('#agent-tab-content')
|
||||
if (tab === 'overview') renderOverview(content, state)
|
||||
else if (tab === 'files') renderFiles(content, state)
|
||||
else if (tab === 'channels') renderChannels(content, state)
|
||||
else if (tab === 'tools') renderTools(content, state)
|
||||
else if (tab === 'skills') renderSkills(content, state)
|
||||
}
|
||||
|
||||
// ==================== 概览 Tab ====================
|
||||
|
||||
function renderOverview(container, state) {
|
||||
const d = state.detail
|
||||
if (!d) { container.innerHTML = ''; return }
|
||||
|
||||
// 解析模型配置
|
||||
let primaryModel = ''
|
||||
let fallbacks = []
|
||||
if (d.model) {
|
||||
if (typeof d.model === 'string') {
|
||||
primaryModel = d.model
|
||||
} else if (typeof d.model === 'object') {
|
||||
primaryModel = d.model.primary || ''
|
||||
fallbacks = Array.isArray(d.model.fallbacks) ? [...d.model.fallbacks] : []
|
||||
}
|
||||
}
|
||||
|
||||
const thinkingLevels = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'adaptive']
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="agent-overview">
|
||||
<section class="agent-section">
|
||||
<h3 class="agent-section-title">${t('agentDetail.basicInfo')}</h3>
|
||||
<div class="agent-form-grid">
|
||||
<div class="form-group">
|
||||
<label class="form-label">${t('agentDetail.agentId')}</label>
|
||||
<input class="form-input" value="${esc(d.id)}" readonly style="opacity:0.6;cursor:not-allowed">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">${t('agentDetail.name')}</label>
|
||||
<input class="form-input" id="ov-name" value="${esc(d.identity?.name || d.name || '')}" placeholder="${t('agentDetail.notSet')}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">${t('agentDetail.emoji')}</label>
|
||||
<input class="form-input" id="ov-emoji" value="${esc(d.identity?.emoji || '')}" placeholder="🤖" style="max-width:80px">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">${t('agentDetail.workspace')}</label>
|
||||
<input class="form-input" value="${esc(d.workspace || t('agentDetail.notSet'))}" readonly style="opacity:0.6;cursor:not-allowed;font-family:var(--font-mono);font-size:var(--font-size-xs)">
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="agent-section">
|
||||
<h3 class="agent-section-title">${t('agentDetail.modelConfig')}</h3>
|
||||
<div class="agent-form-grid">
|
||||
<div class="form-group" style="grid-column:1/-1">
|
||||
<label class="form-label">${t('agentDetail.primaryModel')}</label>
|
||||
${renderModelSelect('ov-primary-model', primaryModel, state.models)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" style="margin-top:12px">
|
||||
<label class="form-label">${t('agentDetail.fallbackModels')}</label>
|
||||
<div id="ov-fallbacks">${renderFallbackList(fallbacks, state.models)}</div>
|
||||
<button class="btn btn-sm btn-secondary" id="btn-add-fallback" style="margin-top:8px">${t('agentDetail.addFallback')}</button>
|
||||
</div>
|
||||
<div class="form-group" style="margin-top:12px">
|
||||
<label class="form-label">${t('agentDetail.thinkingLevel')}</label>
|
||||
<select class="form-input" id="ov-thinking" style="max-width:200px">
|
||||
<option value="">${t('agentDetail.notSet')}</option>
|
||||
${thinkingLevels.map(lv => `<option value="${lv}" ${d.thinkingDefault === lv ? 'selected' : ''}>${t('agentDetail.thinking' + lv.charAt(0).toUpperCase() + lv.slice(1))}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="agent-save-bar">
|
||||
<button class="btn btn-primary" id="btn-save-overview">${t('agentDetail.saveOverview')}</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// 添加备选模型
|
||||
container.querySelector('#btn-add-fallback').addEventListener('click', () => {
|
||||
const list = container.querySelector('#ov-fallbacks')
|
||||
const idx = list.querySelectorAll('.fallback-row').length
|
||||
list.insertAdjacentHTML('beforeend', renderFallbackRow('', state.models, idx))
|
||||
})
|
||||
|
||||
// 移除备选模型(事件代理)
|
||||
container.querySelector('#ov-fallbacks').addEventListener('click', (e) => {
|
||||
if (e.target.closest('.btn-remove-fallback')) {
|
||||
e.target.closest('.fallback-row').remove()
|
||||
}
|
||||
})
|
||||
|
||||
// 保存
|
||||
container.querySelector('#btn-save-overview').addEventListener('click', () => saveOverview(container, state))
|
||||
}
|
||||
|
||||
function renderModelSelect(id, selected, models) {
|
||||
if (!models.length) {
|
||||
return `<input class="form-input" id="${id}" value="${esc(selected)}" placeholder="provider/model">`
|
||||
}
|
||||
// 如果当前值不在列表中,添加到选项
|
||||
const opts = [...models]
|
||||
if (selected && !opts.includes(selected)) opts.unshift(selected)
|
||||
return `
|
||||
<select class="form-input" id="${id}">
|
||||
<option value="">${t('agentDetail.notSet')}</option>
|
||||
${opts.map(m => `<option value="${esc(m)}" ${m === selected ? 'selected' : ''}>${esc(m)}</option>`).join('')}
|
||||
</select>
|
||||
`
|
||||
}
|
||||
|
||||
function renderFallbackList(fallbacks, models) {
|
||||
if (!fallbacks.length) {
|
||||
return `<div class="agent-hint">${t('agentDetail.noFallback')}</div>`
|
||||
}
|
||||
return fallbacks.map((fb, i) => renderFallbackRow(fb, models, i)).join('')
|
||||
}
|
||||
|
||||
function renderFallbackRow(value, models, idx) {
|
||||
const opts = [...models]
|
||||
if (value && !opts.includes(value)) opts.unshift(value)
|
||||
return `
|
||||
<div class="fallback-row" style="display:flex;gap:8px;align-items:center;margin-top:6px">
|
||||
<select class="form-input fallback-select" style="flex:1">
|
||||
<option value="">${t('agentDetail.notSet')}</option>
|
||||
${opts.map(m => `<option value="${esc(m)}" ${m === value ? 'selected' : ''}>${esc(m)}</option>`).join('')}
|
||||
</select>
|
||||
<button class="btn btn-sm btn-danger btn-remove-fallback">${t('agentDetail.removeFallback')}</button>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
async function saveOverview(container, state) {
|
||||
const btn = container.querySelector('#btn-save-overview')
|
||||
btn.disabled = true
|
||||
btn.textContent = t('agentDetail.saving')
|
||||
|
||||
try {
|
||||
const name = container.querySelector('#ov-name')?.value?.trim() || ''
|
||||
const emoji = container.querySelector('#ov-emoji')?.value?.trim() || ''
|
||||
const primaryEl = container.querySelector('#ov-primary-model')
|
||||
const primary = primaryEl?.value?.trim() || ''
|
||||
const thinkingDefault = container.querySelector('#ov-thinking')?.value || ''
|
||||
|
||||
// 收集备选模型
|
||||
const fallbacks = []
|
||||
container.querySelectorAll('.fallback-select').forEach(sel => {
|
||||
const v = sel.value.trim()
|
||||
if (v) fallbacks.push(v)
|
||||
})
|
||||
|
||||
// 构建模型配置
|
||||
let model = primary || undefined
|
||||
if (primary && fallbacks.length > 0) {
|
||||
model = { primary, fallbacks }
|
||||
}
|
||||
|
||||
await api.updateAgentConfig(state.agentId, {
|
||||
identity: { name: name || undefined, emoji: emoji || undefined },
|
||||
model,
|
||||
thinkingDefault: thinkingDefault || undefined,
|
||||
})
|
||||
|
||||
// 更新本地缓存
|
||||
invalidate('list_agents', 'get_agent_detail')
|
||||
state.detail = await api.getAgentDetail(state.agentId)
|
||||
|
||||
toast(t('agentDetail.saveSuccess'), 'success')
|
||||
} catch (e) {
|
||||
toast(t('agentDetail.saveFailed') + ': ' + e, 'error')
|
||||
} finally {
|
||||
btn.disabled = false
|
||||
btn.textContent = t('agentDetail.saveOverview')
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 工具 Tab ====================
|
||||
|
||||
function renderTools(container, state) {
|
||||
const tools = state.detail?.tools || {}
|
||||
const profile = tools.profile || ''
|
||||
const allow = Array.isArray(tools.allow) ? tools.allow.join(', ') : ''
|
||||
const alsoAllow = Array.isArray(tools.alsoAllow) ? tools.alsoAllow.join(', ') : ''
|
||||
const deny = Array.isArray(tools.deny) ? tools.deny.join(', ') : ''
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="agent-overview">
|
||||
<section class="agent-section">
|
||||
<h3 class="agent-section-title">${t('agentDetail.toolsTitle')}</h3>
|
||||
<p class="agent-section-desc">${t('agentDetail.toolsDesc')}</p>
|
||||
<div class="agent-form-grid">
|
||||
<div class="form-group">
|
||||
<label class="form-label">${t('agentDetail.toolProfile')}</label>
|
||||
<select class="form-input" id="tools-profile">
|
||||
<option value="">${t('agentDetail.notSet')}</option>
|
||||
<option value="minimal" ${profile === 'minimal' ? 'selected' : ''}>minimal</option>
|
||||
<option value="coding" ${profile === 'coding' ? 'selected' : ''}>coding</option>
|
||||
<option value="messaging" ${profile === 'messaging' ? 'selected' : ''}>messaging</option>
|
||||
<option value="full" ${profile === 'full' ? 'selected' : ''}>full</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" style="margin-top:12px">
|
||||
<label class="form-label">${t('agentDetail.toolAllow')}</label>
|
||||
<textarea class="form-input agent-multiline-input" id="tools-allow" placeholder="read_file, write_file, exec">${esc(allow)}</textarea>
|
||||
<div class="form-hint">${t('agentDetail.toolAllowHint')}</div>
|
||||
</div>
|
||||
<div class="form-group" style="margin-top:12px">
|
||||
<label class="form-label">${t('agentDetail.toolAlsoAllow')}</label>
|
||||
<textarea class="form-input agent-multiline-input" id="tools-also-allow" placeholder="grep_search, apply_patch">${esc(alsoAllow)}</textarea>
|
||||
<div class="form-hint">${t('agentDetail.toolAlsoAllowHint')}</div>
|
||||
</div>
|
||||
<div class="form-group" style="margin-top:12px">
|
||||
<label class="form-label">${t('agentDetail.toolDeny')}</label>
|
||||
<textarea class="form-input agent-multiline-input" id="tools-deny" placeholder="delete_file">${esc(deny)}</textarea>
|
||||
<div class="form-hint">${t('agentDetail.toolDenyHint')}</div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="agent-save-bar">
|
||||
<button class="btn btn-primary" id="btn-save-tools">${t('agentDetail.saveTools')}</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
container.querySelector('#btn-save-tools').addEventListener('click', () => saveTools(container, state))
|
||||
}
|
||||
|
||||
async function saveTools(container, state) {
|
||||
const btn = container.querySelector('#btn-save-tools')
|
||||
btn.disabled = true
|
||||
btn.textContent = t('agentDetail.saving')
|
||||
try {
|
||||
const tools = {
|
||||
profile: container.querySelector('#tools-profile')?.value || undefined,
|
||||
allow: splitCsv(container.querySelector('#tools-allow')?.value),
|
||||
alsoAllow: splitCsv(container.querySelector('#tools-also-allow')?.value),
|
||||
deny: splitCsv(container.querySelector('#tools-deny')?.value),
|
||||
}
|
||||
await api.updateAgentConfig(state.agentId, { tools: compactObject(tools) })
|
||||
invalidate('get_agent_detail')
|
||||
state.detail = await api.getAgentDetail(state.agentId)
|
||||
toast(t('agentDetail.toolsSaved'), 'success')
|
||||
} catch (e) {
|
||||
toast(t('agentDetail.saveFailed') + ': ' + e, 'error')
|
||||
} finally {
|
||||
btn.disabled = false
|
||||
btn.textContent = t('agentDetail.saveTools')
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 技能 Tab ====================
|
||||
|
||||
function renderSkills(container, state) {
|
||||
const selected = new Set(Array.isArray(state.detail?.skills) ? state.detail.skills : [])
|
||||
const skills = state.skillsCatalog || []
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="agent-overview">
|
||||
<section class="agent-section">
|
||||
<h3 class="agent-section-title">${t('agentDetail.skillsTitle')}</h3>
|
||||
<p class="agent-section-desc">${t('agentDetail.skillsDesc')}</p>
|
||||
<div class="agent-skills-list">
|
||||
${skills.length ? skills.map(skill => renderSkillCard(skill, selected.has(skill.name))).join('') : `<div class="agent-hint">${t('agentDetail.noSkills')}</div>`}
|
||||
</div>
|
||||
</section>
|
||||
<div class="agent-save-bar">
|
||||
<button class="btn btn-primary" id="btn-save-skills">${t('agentDetail.saveSkills')}</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
container.querySelector('#btn-save-skills').addEventListener('click', () => saveSkills(container, state))
|
||||
}
|
||||
|
||||
function renderSkillCard(skill, checked) {
|
||||
const emoji = skill.emoji || '🧩'
|
||||
const desc = skill.description || ''
|
||||
const eligible = skill.eligible !== false
|
||||
const disabled = skill.disabled === true
|
||||
return `
|
||||
<label class="agent-skill-card ${!eligible || disabled ? 'is-muted' : ''}">
|
||||
<input type="checkbox" class="agent-skill-checkbox" data-skill-name="${esc(skill.name)}" ${checked ? 'checked' : ''} ${disabled ? 'disabled' : ''}>
|
||||
<div class="agent-skill-main">
|
||||
<div class="agent-skill-head">
|
||||
<span class="agent-skill-name">${emoji} ${esc(skill.name)}</span>
|
||||
${disabled ? `<span class="agent-skill-badge">${t('agentDetail.skillDisabled')}</span>` : ''}
|
||||
${!eligible && !disabled ? `<span class="agent-skill-badge">${t('agentDetail.skillUnavailable')}</span>` : ''}
|
||||
</div>
|
||||
<div class="agent-skill-desc">${esc(desc)}</div>
|
||||
</div>
|
||||
</label>
|
||||
`
|
||||
}
|
||||
|
||||
async function saveSkills(container, state) {
|
||||
const btn = container.querySelector('#btn-save-skills')
|
||||
btn.disabled = true
|
||||
btn.textContent = t('agentDetail.saving')
|
||||
try {
|
||||
const selected = []
|
||||
container.querySelectorAll('.agent-skill-checkbox:checked').forEach((el) => selected.push(el.dataset.skillName))
|
||||
await api.updateAgentConfig(state.agentId, { skills: selected })
|
||||
invalidate('get_agent_detail')
|
||||
state.detail = await api.getAgentDetail(state.agentId)
|
||||
toast(t('agentDetail.skillsSaved'), 'success')
|
||||
} catch (e) {
|
||||
toast(t('agentDetail.saveFailed') + ': ' + e, 'error')
|
||||
} finally {
|
||||
btn.disabled = false
|
||||
btn.textContent = t('agentDetail.saveSkills')
|
||||
}
|
||||
}
|
||||
|
||||
function splitCsv(raw) {
|
||||
if (!raw) return undefined
|
||||
const values = String(raw)
|
||||
.split(/[\n,]/)
|
||||
.map(item => item.trim())
|
||||
.filter(Boolean)
|
||||
return values.length ? values : undefined
|
||||
}
|
||||
|
||||
function compactObject(obj) {
|
||||
const next = {}
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (value !== undefined && value !== null && value !== '') next[key] = value
|
||||
}
|
||||
return Object.keys(next).length ? next : undefined
|
||||
}
|
||||
|
||||
// ==================== 文件 Tab ====================
|
||||
|
||||
async function renderFiles(container, state) {
|
||||
container.innerHTML = `
|
||||
<div class="agent-files-section">
|
||||
<h3 class="agent-section-title">${t('agentDetail.filesTitle')}</h3>
|
||||
<p class="agent-section-desc">${t('agentDetail.filesDesc')}</p>
|
||||
<div id="agent-files-list"><div class="skeleton" style="width:100%;height:120px;border-radius:8px"></div></div>
|
||||
</div>
|
||||
`
|
||||
try {
|
||||
const files = await api.listAgentFiles(state.agentId)
|
||||
state.files = files
|
||||
renderFileList(container, state)
|
||||
} catch (e) {
|
||||
container.querySelector('#agent-files-list').innerHTML =
|
||||
`<div style="color:var(--error)">${t('agentDetail.loadFailed')}: ${esc(String(e))}</div>`
|
||||
}
|
||||
}
|
||||
|
||||
function renderFileList(container, state) {
|
||||
const list = container.querySelector('#agent-files-list')
|
||||
const files = state.files || []
|
||||
if (!files.length) {
|
||||
list.innerHTML = `<div style="color:var(--text-tertiary)">${t('agentDetail.noFiles')}</div>`
|
||||
return
|
||||
}
|
||||
|
||||
list.innerHTML = files.map(f => {
|
||||
const statusClass = f.exists ? 'file-exists' : 'file-missing'
|
||||
const statusText = f.exists ? t('agentDetail.fileExists') : t('agentDetail.fileMissing')
|
||||
const sizeText = f.exists ? formatSize(f.size) : '-'
|
||||
const timeText = f.exists && f.mtime ? new Date(f.mtime).toLocaleString('zh-CN') : '-'
|
||||
const actionBtn = f.exists
|
||||
? `<button class="btn btn-sm btn-secondary" data-action="edit-file" data-name="${esc(f.name)}">${t('agentDetail.fileEdit')}</button>`
|
||||
: `<button class="btn btn-sm btn-primary" data-action="create-file" data-name="${esc(f.name)}">${t('agentDetail.fileCreate')}</button>`
|
||||
|
||||
return `
|
||||
<div class="agent-file-card">
|
||||
<div class="agent-file-header">
|
||||
<div class="agent-file-info">
|
||||
<span class="agent-file-name">${esc(f.name)}</span>
|
||||
<span class="agent-file-status ${statusClass}">${statusText}</span>
|
||||
</div>
|
||||
<div class="agent-file-actions">${actionBtn}</div>
|
||||
</div>
|
||||
<div class="agent-file-desc">${esc(f.desc)}</div>
|
||||
${f.exists ? `<div class="agent-file-meta">${t('agentDetail.fileSize')}: ${sizeText} · ${t('agentDetail.fileUpdated')}: ${timeText}</div>` : ''}
|
||||
</div>
|
||||
`
|
||||
}).join('')
|
||||
|
||||
// 事件代理
|
||||
list.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('[data-action]')
|
||||
if (!btn) return
|
||||
const name = btn.dataset.name
|
||||
if (btn.dataset.action === 'edit-file') openFileEditor(container, state, name)
|
||||
else if (btn.dataset.action === 'create-file') openFileEditor(container, state, name, true)
|
||||
})
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
}
|
||||
|
||||
async function openFileEditor(container, state, name, isNew = false) {
|
||||
let content = ''
|
||||
if (!isNew) {
|
||||
try {
|
||||
const res = await api.readAgentFile(state.agentId, name)
|
||||
content = res.content || ''
|
||||
} catch (e) {
|
||||
toast(t('agentDetail.loadFailed') + ': ' + e, 'error')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 用弹窗编辑器
|
||||
const overlay = document.createElement('div')
|
||||
overlay.className = 'modal-overlay'
|
||||
overlay.innerHTML = `
|
||||
<div class="modal agent-file-editor-modal">
|
||||
<div class="modal-title">${t('agentDetail.editFileTitle', { name })}</div>
|
||||
<textarea class="agent-file-editor" id="file-editor-textarea" spellcheck="false">${esc(content)}</textarea>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary btn-sm" data-action="cancel">${t('common.cancel')}</button>
|
||||
<button class="btn btn-primary btn-sm" data-action="save">${t('agentDetail.saveOverview')}</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
document.body.appendChild(overlay)
|
||||
|
||||
const textarea = overlay.querySelector('#file-editor-textarea')
|
||||
textarea.focus()
|
||||
|
||||
// Tab 键支持
|
||||
textarea.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
textarea.value = textarea.value.substring(0, start) + ' ' + textarea.value.substring(end)
|
||||
textarea.selectionStart = textarea.selectionEnd = start + 2
|
||||
}
|
||||
})
|
||||
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) overlay.remove()
|
||||
})
|
||||
overlay.querySelector('[data-action="cancel"]').onclick = () => overlay.remove()
|
||||
overlay.querySelector('[data-action="save"]').onclick = async () => {
|
||||
try {
|
||||
await api.writeAgentFile(state.agentId, name, textarea.value)
|
||||
toast(isNew ? t('agentDetail.fileCreated') : t('agentDetail.fileSaved'), 'success')
|
||||
overlay.remove()
|
||||
// 刷新文件列表
|
||||
renderFiles(container, state)
|
||||
} catch (e) {
|
||||
toast(t('agentDetail.fileSaveFailed') + ': ' + e, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl+S 快捷保存
|
||||
overlay.addEventListener('keydown', (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
e.preventDefault()
|
||||
overlay.querySelector('[data-action="save"]').click()
|
||||
}
|
||||
if (e.key === 'Escape') overlay.remove()
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== 渠道 Tab ====================
|
||||
|
||||
async function renderChannels(container, state) {
|
||||
const bindings = state.detail?.bindings || []
|
||||
|
||||
// 获取已配置的渠道
|
||||
let platforms = []
|
||||
try { platforms = await api.listConfiguredPlatforms() } catch {}
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="agent-channels-section">
|
||||
<div class="agent-section-header">
|
||||
<div>
|
||||
<h3 class="agent-section-title">${t('agentDetail.channelsTitle')}</h3>
|
||||
<p class="agent-section-desc">${t('agentDetail.channelsDesc')}</p>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-primary" id="btn-add-binding">${t('agentDetail.addBinding')}</button>
|
||||
</div>
|
||||
<div id="agent-bindings-list"></div>
|
||||
</div>
|
||||
`
|
||||
|
||||
renderBindingsList(container, state, bindings)
|
||||
|
||||
container.querySelector('#btn-add-binding').addEventListener('click', () => {
|
||||
showAddBindingDialog(container, state, platforms)
|
||||
})
|
||||
}
|
||||
|
||||
function renderBindingsList(container, state, bindings) {
|
||||
const list = container.querySelector('#agent-bindings-list')
|
||||
if (!bindings.length) {
|
||||
list.innerHTML = `<div class="agent-hint">${t('agentDetail.noBindings')}</div>`
|
||||
return
|
||||
}
|
||||
|
||||
list.innerHTML = bindings.map((b, i) => {
|
||||
const channel = b.match?.channel || ''
|
||||
const label = CHANNEL_LABELS[channel] || channel
|
||||
const accountId = b.match?.accountId || ''
|
||||
const typeLabel = b.type === 'acp' ? 'ACP' : 'Route'
|
||||
return `
|
||||
<div class="agent-binding-card">
|
||||
<div class="agent-binding-info">
|
||||
<span class="agent-binding-channel">${esc(label)}</span>
|
||||
${accountId ? `<span class="agent-binding-account">${esc(accountId)}</span>` : ''}
|
||||
<span class="badge" style="background:var(--info-muted);color:var(--info)">${typeLabel}</span>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-danger" data-action="remove-binding" data-channel="${esc(channel)}" data-account="${esc(accountId)}" data-index="${i}">${t('agentDetail.removeBinding')}</button>
|
||||
</div>
|
||||
`
|
||||
}).join('')
|
||||
|
||||
list.addEventListener('click', async (e) => {
|
||||
const btn = e.target.closest('[data-action="remove-binding"]')
|
||||
if (!btn) return
|
||||
const channel = btn.dataset.channel
|
||||
const account = btn.dataset.account || null
|
||||
const binding = bindings[Number(btn.dataset.index)]
|
||||
const yes = await showConfirm(t('agentDetail.removeBindingConfirm', { channel: CHANNEL_LABELS[channel] || channel }))
|
||||
if (!yes) return
|
||||
try {
|
||||
await api.deleteAgentBinding(state.agentId, channel, account, binding?.match || null)
|
||||
toast(t('agentDetail.bindingRemoved'), 'success')
|
||||
// 刷新
|
||||
invalidate('get_agent_detail')
|
||||
state.detail = await api.getAgentDetail(state.agentId)
|
||||
renderBindingsList(container, state, state.detail.bindings || [])
|
||||
} catch (e) {
|
||||
toast(t('agentDetail.bindingFailed') + ': ' + e, 'error')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function showAddBindingDialog(container, state, platforms) {
|
||||
const overlay = document.createElement('div')
|
||||
overlay.className = 'modal-overlay'
|
||||
|
||||
// 构建渠道选项:已配置的渠道 + 所有已知渠道
|
||||
const channels = new Set()
|
||||
for (const p of platforms) {
|
||||
if (p.platform || p.id) channels.add(p.platform || p.id)
|
||||
}
|
||||
// 确保常用渠道在列表中
|
||||
for (const key of Object.keys(CHANNEL_LABELS)) channels.add(key)
|
||||
|
||||
const channelOptions = [...channels].map(ch =>
|
||||
`<option value="${esc(ch)}">${esc(CHANNEL_LABELS[ch] || ch)}</option>`
|
||||
).join('')
|
||||
|
||||
overlay.innerHTML = `
|
||||
<div class="modal" style="max-width:400px">
|
||||
<div class="modal-title">${t('agentDetail.addBinding')}</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">${t('agentDetail.selectChannel')}</label>
|
||||
<select class="form-input" id="bind-channel">${channelOptions}</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">${t('agentDetail.accountOptional')}</label>
|
||||
<input class="form-input" id="bind-account" placeholder="${t('agentDetail.accountOptionalPlaceholder')}">
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary btn-sm" data-action="cancel">${t('common.cancel')}</button>
|
||||
<button class="btn btn-primary btn-sm" data-action="confirm">${t('common.confirm')}</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
document.body.appendChild(overlay)
|
||||
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove() })
|
||||
overlay.querySelector('[data-action="cancel"]').onclick = () => overlay.remove()
|
||||
overlay.querySelector('[data-action="confirm"]').onclick = async () => {
|
||||
const channel = overlay.querySelector('#bind-channel').value
|
||||
const account = overlay.querySelector('#bind-account').value.trim() || null
|
||||
if (!channel) return
|
||||
try {
|
||||
await api.saveAgentBinding(state.agentId, channel, account)
|
||||
toast(t('agentDetail.bindingAdded'), 'success')
|
||||
overlay.remove()
|
||||
invalidate('get_agent_detail')
|
||||
state.detail = await api.getAgentDetail(state.agentId)
|
||||
renderBindingsList(container, state, state.detail.bindings || [])
|
||||
} catch (e) {
|
||||
toast(t('agentDetail.bindingFailed') + ': ' + e, 'error')
|
||||
overlay.remove()
|
||||
}
|
||||
}
|
||||
overlay.querySelector('[data-action="confirm"]').addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') overlay.querySelector('[data-action="confirm"]').click()
|
||||
if (e.key === 'Escape') overlay.remove()
|
||||
})
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export async function render() {
|
||||
<div>
|
||||
<h1 class="page-title">${t('agents.title')}</h1>
|
||||
<p class="page-desc">${t('agents.desc')}</p>
|
||||
<p class="page-subhint">${t('agents.detailHint')}</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<button class="btn btn-primary" id="btn-add-agent">${t('agents.addAgent')}</button>
|
||||
@@ -107,6 +108,7 @@ function renderAgents(page, state) {
|
||||
${isDefault ? `<span class="badge badge-success">${t('agents.default')}</span>` : ''}
|
||||
</div>
|
||||
<div class="agent-card-actions">
|
||||
<button class="btn btn-sm btn-primary" data-action="detail" data-id="${a.id}">${t('agents.detail')}</button>
|
||||
<button class="btn btn-sm btn-secondary" data-action="backup" data-id="${a.id}">${t('agents.backup')}</button>
|
||||
<button class="btn btn-sm btn-secondary" data-action="edit" data-id="${a.id}">${t('agents.edit')}</button>
|
||||
${!isDefault ? `<button class="btn btn-sm btn-danger" data-action="delete" data-id="${a.id}">${t('agents.delete')}</button>` : ''}
|
||||
@@ -139,13 +141,21 @@ 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
|
||||
const action = btn.dataset.action
|
||||
const id = btn.dataset.id
|
||||
|
||||
if (action === 'edit') showEditAgentDialog(page, state, id)
|
||||
else if (action === 'delete') await deleteAgent(page, state, id)
|
||||
else if (action === 'backup') await backupAgent(id)
|
||||
if (btn) {
|
||||
const action = btn.dataset.action
|
||||
const id = btn.dataset.id
|
||||
if (action === 'detail') location.hash = `#/agent-detail?id=${encodeURIComponent(id)}`
|
||||
else if (action === 'edit') showEditAgentDialog(page, state, id)
|
||||
else if (action === 'delete') await deleteAgent(page, state, id)
|
||||
else if (action === 'backup') await backupAgent(id)
|
||||
return
|
||||
}
|
||||
// 点击卡片空白区域 → 进入详情页
|
||||
const card = e.target.closest('.agent-card')
|
||||
if (card) {
|
||||
const id = card.dataset.id
|
||||
if (id) location.hash = `#/agent-detail?id=${encodeURIComponent(id)}`
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -996,7 +996,7 @@ async function loadOpenClawSoul(agentId = 'default') {
|
||||
try {
|
||||
const sysInfo = await api.assistantSystemInfo()
|
||||
const home = sysInfo.match(/主目录[::]\s*(.+)/)?.[1]?.trim() || sysInfo.match(/Home[::]\s*(.+)/)?.[1]?.trim() || ''
|
||||
if (!home) throw new Error('无法获取主目录')
|
||||
if (!home) throw new Error(t('assistant.errHomeUnavailable'))
|
||||
// default/main 使用 ~/.openclaw/workspace,其他使用 agents/{id}/workspace
|
||||
let ws
|
||||
if (agentId === 'default' || agentId === 'main') {
|
||||
@@ -1006,7 +1006,7 @@ async function loadOpenClawSoul(agentId = 'default') {
|
||||
}
|
||||
let wsExists = false
|
||||
try { await api.assistantListDir(ws); wsExists = true } catch {}
|
||||
if (!wsExists) throw new Error('Agent workspace 不存在: ' + agentId)
|
||||
if (!wsExists) throw new Error(t('assistant.errWorkspaceMissing', { agentId }))
|
||||
|
||||
const readSafe = async (p) => { try { return await api.assistantReadFile(p) } catch { return null } }
|
||||
|
||||
@@ -1265,7 +1265,7 @@ function renderImagePreview() {
|
||||
container.innerHTML = _pendingImages.map(img => `
|
||||
<div class="ast-img-thumb" data-img-id="${img.id}">
|
||||
<img src="${img.dataUrl}" alt="${escHtml(img.name)}"/>
|
||||
<button class="ast-img-thumb-del" data-img-del="${img.id}" title="移除">${delSvg}</button>
|
||||
<button class="ast-img-thumb-del" data-img-del="${img.id}" title="${t('common.delete')}">${delSvg}</button>
|
||||
</div>
|
||||
`).join('')
|
||||
}
|
||||
@@ -2217,7 +2217,7 @@ async function callAIWithTools(messages, onStatus, onToolProgress) {
|
||||
const choice = data.choices?.[0]
|
||||
const assistantMsg = choice?.message
|
||||
|
||||
if (!assistantMsg) throw new Error('AI 未返回有效响应')
|
||||
if (!assistantMsg) throw new Error(t('assistant.errInvalidResponse'))
|
||||
|
||||
if (assistantMsg.tool_calls && assistantMsg.tool_calls.length > 0) {
|
||||
currentMessages.push(assistantMsg)
|
||||
|
||||
@@ -838,7 +838,7 @@ function renderAgentBindings(page, state) {
|
||||
const yes = await showConfirm(t('channels.confirmRemoveBinding', { agent: aid, summary: formatBindingMatchSummary(binding) }))
|
||||
if (!yes) return
|
||||
try {
|
||||
await api.deleteAgentBinding(aid, ch, acct)
|
||||
await api.deleteAgentBinding(aid, ch, acct, match)
|
||||
toast(t('channels.bindingRemoved'), 'success')
|
||||
await loadPlatforms(page, state)
|
||||
} catch (e) {
|
||||
@@ -1326,11 +1326,11 @@ async function openConfigDialog(pid, page, state, accountId) {
|
||||
const parts = []
|
||||
const installBtn = modal.querySelector('[data-channel-action="install"]')
|
||||
if (s.installed && s.compatible === false) {
|
||||
parts.push(`<span style="color:var(--error);font-weight:600">⚠ ${t('channels.pluginIncompatible') || '插件版本不兼容'}</span>`)
|
||||
parts.push(`<span style="color:var(--error);font-weight:600">⚠ ${t('channels.pluginIncompatible')}</span>`)
|
||||
parts.push(`${t('channels.version')} <strong>${s.installedVersion || '?'}</strong>`)
|
||||
parts.push(`<br><span style="color:var(--error);font-size:var(--font-size-xs)">${s.compatError || '请点击「一键安装插件」重新安装兼容版本'}</span>`)
|
||||
parts.push(`<br><span style="color:var(--error);font-size:var(--font-size-xs)">${s.compatError || t('channels.pluginCompatErrorHint')}</span>`)
|
||||
if (installBtn) {
|
||||
installBtn.textContent = t('channels.reinstallCompatible') || '重新安装兼容版本'
|
||||
installBtn.textContent = t('channels.reinstallCompatible')
|
||||
installBtn.style.background = 'var(--error)'
|
||||
}
|
||||
} else if (s.installed) {
|
||||
@@ -1386,7 +1386,7 @@ async function openConfigDialog(pid, page, state, accountId) {
|
||||
const hint = document.createElement('div')
|
||||
hint.style.cssText = 'color:var(--text-tertiary);font-style:italic'
|
||||
hint.id = 'action-loading-hint'
|
||||
hint.textContent = t('channels.downloadingPlugin') || '正在下载,请稍候(首次安装可能需要几分钟)...'
|
||||
hint.textContent = t('channels.downloadingPlugin')
|
||||
logBox.appendChild(hint)
|
||||
}
|
||||
const _qrBuf = []
|
||||
@@ -1451,7 +1451,7 @@ async function openConfigDialog(pid, page, state, accountId) {
|
||||
wrap.innerHTML = `
|
||||
<div style="font-size:var(--font-size-sm);font-weight:600;color:#000;margin-bottom:8px">${t('channels.weixinScanQr')}</div>
|
||||
<img src="https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(qrUrl)}" alt="WeChat QR" style="width:200px;height:200px;image-rendering:pixelated;border-radius:4px;margin:0 auto;display:block" loading="eager">
|
||||
<div style="margin-top:8px"><a href="${escapeAttr(qrUrl)}" target="_blank" rel="noopener" style="color:var(--accent);font-size:var(--font-size-xs);word-break:break-all">${t('channels.weixinOpenInBrowser') || '或点击此链接在浏览器中打开'}</a></div>
|
||||
<div style="margin-top:8px"><a href="${escapeAttr(qrUrl)}" target="_blank" rel="noopener" style="color:var(--accent);font-size:var(--font-size-xs);word-break:break-all">${t('channels.weixinOpenInBrowser')}</a></div>
|
||||
`
|
||||
logBox.appendChild(wrap)
|
||||
} else if (msg.trim()) {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
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 { isForeignGatewayError, showGatewayConflictGuidance } from '../lib/gateway-ownership.js'
|
||||
import { icon, statusIcon } from '../lib/icons.js'
|
||||
import { toast } from '../components/toast.js'
|
||||
import { navigate } from '../router.js'
|
||||
@@ -76,6 +77,12 @@ export async function render() {
|
||||
return page
|
||||
}
|
||||
|
||||
async function openGatewayConflict(error = null) {
|
||||
const services = await api.getServicesStatus().catch(() => [])
|
||||
const gw = services?.find?.(s => s.label === 'ai.openclaw.gateway') || services?.[0] || null
|
||||
await showGatewayConflictGuidance({ error, service: gw })
|
||||
}
|
||||
|
||||
async function loadDebugInfo(page) {
|
||||
const el = page.querySelector('#debug-content')
|
||||
|
||||
@@ -589,13 +596,27 @@ async function fixPairing(page) {
|
||||
|
||||
// 2. 停止 Gateway(确保旧进程完全退出,新进程能重新读取配置)
|
||||
addLog(`${icon('zap', 14)} ${t('chatDebug.fixStoppingGw')}`)
|
||||
try { await api.stopService('ai.openclaw.gateway') } catch {}
|
||||
try {
|
||||
await api.stopService('ai.openclaw.gateway')
|
||||
} catch (e) {
|
||||
if (isForeignGatewayError(e)) {
|
||||
await openGatewayConflict(e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
addLog(`${icon('clock', 14)} ${t('chatDebug.fixWaitExit')}`)
|
||||
await new Promise(resolve => setTimeout(resolve, 3000))
|
||||
|
||||
// 3. 启动 Gateway(重新加载 openclaw.json 配置)
|
||||
addLog(`${icon('zap', 14)} ${t('chatDebug.fixStartingGw')}`)
|
||||
await api.startService('ai.openclaw.gateway')
|
||||
try {
|
||||
await api.startService('ai.openclaw.gateway')
|
||||
} catch (e) {
|
||||
if (isForeignGatewayError(e)) {
|
||||
await openGatewayConflict(e)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
addLog(`${statusIcon('ok', 14)} ${t('chatDebug.fixGwStartSent')}`)
|
||||
|
||||
// 4. 等待 Gateway 就绪
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { api } from '../lib/tauri-api.js'
|
||||
import { toast } from '../components/toast.js'
|
||||
import { getActiveInstance, onGatewayChange } from '../lib/app-state.js'
|
||||
import { isForeignGatewayError, isForeignGatewayService, maybeShowForeignGatewayBindingPrompt, showGatewayConflictGuidance } from '../lib/gateway-ownership.js'
|
||||
import { navigate } from '../router.js'
|
||||
import { t } from '../lib/i18n.js'
|
||||
|
||||
@@ -64,6 +65,31 @@ export function cleanup() {
|
||||
if (_unsubGw) { _unsubGw(); _unsubGw = null }
|
||||
}
|
||||
|
||||
function openclawInstallationIdentity(installation) {
|
||||
const rawPath = String(installation?.path || '').trim()
|
||||
if (!rawPath) return ''
|
||||
const isWin = navigator.platform?.startsWith('Win') || navigator.userAgent?.includes('Windows')
|
||||
if (!isWin) return rawPath
|
||||
return rawPath
|
||||
.replace(/\//g, '\\')
|
||||
.replace(/\\openclaw(?:\.exe|\.ps1)?$/i, '\\openclaw.cmd')
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
function dedupeOpenclawInstallations(list = []) {
|
||||
const map = new Map()
|
||||
const preferCmd = inst => /openclaw\.cmd$/i.test(String(inst?.path || ''))
|
||||
for (const installation of Array.isArray(list) ? list : []) {
|
||||
const key = openclawInstallationIdentity(installation)
|
||||
if (!key) continue
|
||||
const existing = map.get(key)
|
||||
if (!existing || (!existing.active && installation.active) || (!preferCmd(existing) && preferCmd(installation))) {
|
||||
map.set(key, installation)
|
||||
}
|
||||
}
|
||||
return [...map.values()]
|
||||
}
|
||||
|
||||
let _dashboardInitialized = false
|
||||
let _dashboardVersionCache = null
|
||||
let _dashboardStatusSummaryCache = null
|
||||
@@ -97,9 +123,7 @@ async function loadDashboardData(page, fullRefresh = false) {
|
||||
api.listAgents(),
|
||||
api.readMcpConfig(),
|
||||
api.listBackups(),
|
||||
// getStatusSummary 是最重的调用(spawn openclaw status --json),只在首次加载时调用
|
||||
(!_dashboardInitialized || fullRefresh || !_dashboardStatusSummaryCache) ? api.getStatusSummary() : Promise.resolve(_dashboardStatusSummaryCache),
|
||||
]), 15000).catch(() => [{ status: 'rejected' }, { status: 'rejected' }, { status: 'rejected' }, { status: 'rejected' }])
|
||||
]), 15000).catch(() => [{ status: 'rejected' }, { status: 'rejected' }, { status: 'rejected' }])
|
||||
const logsP = api.readLogTail('gateway', 20).catch(() => '')
|
||||
|
||||
// 第一波:服务状态 + 配置 + 版本 → 立即渲染统计卡片
|
||||
@@ -109,6 +133,11 @@ async function loadDashboardData(page, fullRefresh = false) {
|
||||
? (_dashboardVersionCache = versionRes.value)
|
||||
: (_dashboardVersionCache || {})
|
||||
const config = configRes.status === 'fulfilled' ? configRes.value : null
|
||||
const gw = services.find(s => s.label === 'ai.openclaw.gateway')
|
||||
const shouldLoadStatusSummary = gw?.running === true
|
||||
if (!shouldLoadStatusSummary) {
|
||||
_dashboardStatusSummaryCache = null
|
||||
}
|
||||
if (servicesRes.status === 'rejected') toast(t('dashboard.servicesLoadFail'), 'error')
|
||||
if (versionRes.status === 'rejected') toast(t('dashboard.versionLoadFail'), 'error')
|
||||
|
||||
@@ -138,15 +167,29 @@ async function loadDashboardData(page, fullRefresh = false) {
|
||||
}
|
||||
|
||||
renderStatCards(page, services, version, [], config)
|
||||
if (gw) {
|
||||
maybeShowForeignGatewayBindingPrompt({
|
||||
service: gw,
|
||||
onRefresh: () => loadDashboardData(page, true),
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// 第二波:Agent、MCP、备份 → 更新卡片 + 渲染总览
|
||||
const [agentsRes, mcpRes, backupsRes, statusRes] = await secondaryP
|
||||
const [agentsRes, mcpRes, backupsRes] = await secondaryP
|
||||
const agents = agentsRes.status === 'fulfilled' ? agentsRes.value : []
|
||||
const mcpConfig = mcpRes.status === 'fulfilled' ? mcpRes.value : null
|
||||
const backups = backupsRes.status === 'fulfilled' ? backupsRes.value : []
|
||||
const statusSummary = (statusRes.status === 'fulfilled' && statusRes.value)
|
||||
? (_dashboardStatusSummaryCache = statusRes.value)
|
||||
: _dashboardStatusSummaryCache
|
||||
let statusSummary = null
|
||||
if (shouldLoadStatusSummary) {
|
||||
try {
|
||||
statusSummary = (!_dashboardInitialized || fullRefresh || !_dashboardStatusSummaryCache)
|
||||
? await withTimeout(api.getStatusSummary(), 15000)
|
||||
: _dashboardStatusSummaryCache
|
||||
_dashboardStatusSummaryCache = statusSummary
|
||||
} catch {
|
||||
statusSummary = _dashboardStatusSummaryCache
|
||||
}
|
||||
}
|
||||
|
||||
renderStatCards(page, services, version, agents, config)
|
||||
renderOverview(page, services, mcpConfig, backups, config, agents, statusSummary)
|
||||
@@ -158,9 +201,21 @@ async function loadDashboardData(page, fullRefresh = false) {
|
||||
_dashboardInitialized = true
|
||||
}
|
||||
|
||||
async function openGatewayConflict(page, error = null, reason = null) {
|
||||
const services = await api.getServicesStatus().catch(() => [])
|
||||
const gw = services?.find?.(s => s.label === 'ai.openclaw.gateway') || services?.[0] || null
|
||||
await showGatewayConflictGuidance({
|
||||
error,
|
||||
service: gw,
|
||||
reason,
|
||||
onRefresh: async () => loadDashboardData(page, true),
|
||||
})
|
||||
}
|
||||
|
||||
function renderStatCards(page, services, version, agents, config) {
|
||||
const cardsEl = page.querySelector('#stat-cards')
|
||||
const gw = services.find(s => s.label === 'ai.openclaw.gateway')
|
||||
const foreignGateway = isForeignGatewayService(gw)
|
||||
const runningCount = services.filter(s => s.running).length
|
||||
const versionMeta = version.recommended
|
||||
? `${version.ahead_of_recommended ? t('dashboard.versionAhead', { version: version.recommended }) : version.is_recommended ? t('dashboard.versionStable', { version: version.recommended }) : t('dashboard.versionRecommend', { version: version.recommended })}${version.latest_update_available && version.latest ? ' · ' + t('dashboard.versionLatest', { version: version.latest }) : ''}`
|
||||
@@ -168,7 +223,7 @@ function renderStatCards(page, services, version, agents, config) {
|
||||
|
||||
// CLI 路径信息
|
||||
const cliSourceLabel = { standalone: t('dashboard.cliSourceStandalone'), 'npm-zh': t('dashboard.cliSourceNpmZh'), 'npm-official': t('dashboard.cliSourceNpmOfficial'), 'npm-global': t('dashboard.cliSourceNpmGlobal') }[version.cli_source] || t('dashboard.cliSourceUnknown')
|
||||
const installCount = version.all_installations?.length || 0
|
||||
const installCount = dedupeOpenclawInstallations(version.all_installations).length
|
||||
const multiInstall = installCount > 1
|
||||
|
||||
const defaultAgent = agents.find(a => a.id === 'main')?.name || 'main'
|
||||
@@ -181,8 +236,15 @@ function renderStatCards(page, services, version, agents, config) {
|
||||
<span class="stat-card-label">${t('dashboard.gateway')}</span>
|
||||
<span class="status-dot ${gw?.running ? 'running' : 'stopped'}"></span>
|
||||
</div>
|
||||
<div class="stat-card-value">${gw?.running ? t('common.running') : t('common.stopped')}</div>
|
||||
<div class="stat-card-meta">${gw?.pid ? 'PID: ' + gw.pid : (gw?.running ? t('dashboard.portDetect') : t('dashboard.notStarted'))}</div>
|
||||
<div class="stat-card-value">${foreignGateway ? t('dashboard.externalInstance') : gw?.running ? t('common.running') : t('common.stopped')}</div>
|
||||
<div class="stat-card-meta">${foreignGateway ? t('dashboard.externalGatewayDetected', { pid: gw?.pid ? ' · PID ' + gw.pid : '' }) : gw?.pid ? 'PID: ' + gw.pid : (gw?.running ? t('dashboard.portDetect') : t('dashboard.notStarted'))}</div>
|
||||
${foreignGateway
|
||||
? `<div class="stat-card-meta" style="margin-top:8px;color:var(--warning);line-height:1.6">${t('dashboard.foreignGatewayHint')}</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:10px">
|
||||
<button class="btn btn-secondary btn-xs" data-action="resolve-foreign-gateway">${t('dashboard.viewGuidance')}</button>
|
||||
<button class="btn btn-primary btn-xs" data-action="open-settings">${t('dashboard.goSettings')}</button>
|
||||
</div>`
|
||||
: ''}
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-header">
|
||||
@@ -191,6 +253,13 @@ function renderStatCards(page, services, version, agents, config) {
|
||||
<div class="stat-card-value">${version.current || t('common.unknown')}</div>
|
||||
<div class="stat-card-meta">${versionMeta}</div>
|
||||
${version.cli_path ? `<div class="stat-card-meta" style="margin-top:2px;font-size:11px;opacity:0.7" title="${escapeHtml(version.cli_path)}">${cliSourceLabel}${multiInstall ? ' · <span style="color:var(--warning)">' + t('dashboard.installCount', { count: installCount }) + '</span>' : ''}</div>` : ''}
|
||||
${multiInstall
|
||||
? `<div class="stat-card-meta" style="margin-top:8px;color:var(--warning);line-height:1.6">${t('dashboard.multiInstallCardHint')}</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:10px">
|
||||
<button class="btn btn-secondary btn-xs" data-action="resolve-multi-install">${t('dashboard.viewGuidance')}</button>
|
||||
<button class="btn btn-primary btn-xs" data-action="open-settings">${t('dashboard.goSettings')}</button>
|
||||
</div>`
|
||||
: ''}
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-header">
|
||||
@@ -227,6 +296,7 @@ function renderStatCards(page, services, version, agents, config) {
|
||||
function renderOverview(page, services, mcpConfig, backups, config, agents, statusSummary) {
|
||||
const containerEl = page.querySelector('#dashboard-overview-container')
|
||||
const gw = services.find(s => s.label === 'ai.openclaw.gateway')
|
||||
const foreignGateway = isForeignGatewayService(gw)
|
||||
const mcpCount = mcpConfig?.mcpServers ? Object.keys(mcpConfig.mcpServers).length : 0
|
||||
|
||||
const formatDate = (timestamp) => {
|
||||
@@ -243,6 +313,9 @@ function renderOverview(page, services, mcpConfig, backups, config, agents, stat
|
||||
const lastUpdate = config?.meta?.lastTouchedVersion || t('common.unknown')
|
||||
const runtimeVer = statusSummary?.runtimeVersion || null
|
||||
const sessions = statusSummary?.sessions || null
|
||||
const runtimeMeta = runtimeVer
|
||||
? (statusSummary?.source === 'file-read' ? t('dashboard.runtimeMetaFileRead') : t('dashboard.runtimeMetaLive'))
|
||||
: t('dashboard.runtimeMetaConfig')
|
||||
|
||||
const gwPort = config?.gateway?.port || 18789
|
||||
const primaryModel = config?.agents?.defaults?.model?.primary || t('dashboard.notSet')
|
||||
@@ -251,16 +324,18 @@ function renderOverview(page, services, mcpConfig, backups, config, agents, stat
|
||||
<div class="dashboard-overview">
|
||||
<div class="overview-grid">
|
||||
<div class="overview-card" data-nav="/gateway">
|
||||
<div class="overview-card-icon" style="color:${gw?.running ? 'var(--success)' : 'var(--error)'}">
|
||||
<div class="overview-card-icon" style="color:${foreignGateway ? 'var(--warning)' : gw?.running ? 'var(--success)' : 'var(--error)'}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><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>
|
||||
<div class="overview-card-body">
|
||||
<div class="overview-card-title">Gateway</div>
|
||||
<div class="overview-card-value" style="color:${gw?.running ? 'var(--success)' : 'var(--error)'}">${gw?.running ? t('common.running') : t('common.stopped')}</div>
|
||||
<div class="overview-card-meta">${t('dashboard.port')} ${gwPort} ${gw?.pid ? '· PID ' + gw.pid : ''}</div>
|
||||
<div class="overview-card-value" style="color:${foreignGateway ? 'var(--warning)' : gw?.running ? 'var(--success)' : 'var(--error)'}">${foreignGateway ? t('dashboard.externalInstance') : gw?.running ? t('common.running') : t('common.stopped')}</div>
|
||||
<div class="overview-card-meta">${foreignGateway ? `${t('dashboard.port')} ${gwPort}${gw?.pid ? ' · PID ' + gw.pid : ''} · ${t('dashboard.viewOnlyStatus')}` : `${t('dashboard.port')} ${gwPort} ${gw?.pid ? '· PID ' + gw.pid : ''}`}</div>
|
||||
</div>
|
||||
<div class="overview-card-actions">
|
||||
${gw?.running
|
||||
${foreignGateway
|
||||
? '<button class="btn btn-secondary btn-xs" data-action="resolve-foreign-gateway">' + t('dashboard.viewGuidance') + '</button><button class="btn btn-primary btn-xs" data-action="open-settings">' + t('dashboard.goSettings') + '</button>'
|
||||
: gw?.running
|
||||
? '<button class="btn btn-danger btn-xs" data-action="stop-gw">' + t('dashboard.stopBtn') + '</button><button class="btn btn-secondary btn-xs" data-action="restart-gw">' + t('dashboard.restartBtn') + '</button>'
|
||||
: '<button class="btn btn-primary btn-xs" data-action="start-gw">' + t('dashboard.startBtn') + '</button>'
|
||||
}
|
||||
@@ -318,7 +393,7 @@ function renderOverview(page, services, mcpConfig, backups, config, agents, stat
|
||||
<div class="overview-card-body">
|
||||
<div class="overview-card-title">${t('dashboard.runtimeVersion')}</div>
|
||||
<div class="overview-card-value" style="font-size:var(--font-size-sm)">${runtimeVer || lastUpdate}</div>
|
||||
<div class="overview-card-meta">${runtimeVer ? 'OpenClaw Runtime' : 'openclaw.json'}</div>
|
||||
<div class="overview-card-meta">${runtimeMeta}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -420,13 +495,31 @@ function bindActions(page) {
|
||||
if (!actionBtn) return
|
||||
const action = actionBtn.dataset.action
|
||||
|
||||
if (action === 'open-settings') {
|
||||
navigate('/settings')
|
||||
return
|
||||
}
|
||||
|
||||
if (action === 'resolve-foreign-gateway') {
|
||||
await openGatewayConflict(page, null, 'foreign-gateway')
|
||||
return
|
||||
}
|
||||
|
||||
if (action === 'resolve-multi-install') {
|
||||
await openGatewayConflict(page, null, 'multiple-installations')
|
||||
return
|
||||
}
|
||||
|
||||
if (action === 'start-gw') {
|
||||
actionBtn.disabled = true; actionBtn.textContent = t('dashboard.starting')
|
||||
try {
|
||||
await api.startService('ai.openclaw.gateway')
|
||||
toast(t('dashboard.gwStartSent'), 'success')
|
||||
setTimeout(() => loadDashboardData(page), 2000)
|
||||
} catch (err) { toast(t('dashboard.startFail') + ': ' + err, 'error') }
|
||||
} catch (err) {
|
||||
if (isForeignGatewayError(err)) await openGatewayConflict(page, err)
|
||||
else toast(t('dashboard.startFail') + ': ' + err, 'error')
|
||||
}
|
||||
finally { actionBtn.disabled = false; actionBtn.textContent = t('dashboard.startBtn') }
|
||||
}
|
||||
if (action === 'stop-gw') {
|
||||
@@ -435,7 +528,10 @@ function bindActions(page) {
|
||||
await api.stopService('ai.openclaw.gateway')
|
||||
toast(t('dashboard.gwStopped'), 'success')
|
||||
setTimeout(() => loadDashboardData(page), 1500)
|
||||
} catch (err) { toast(t('dashboard.stopFail') + ': ' + err, 'error') }
|
||||
} catch (err) {
|
||||
if (isForeignGatewayError(err)) await openGatewayConflict(page, err)
|
||||
else toast(t('dashboard.stopFail') + ': ' + err, 'error')
|
||||
}
|
||||
finally { actionBtn.disabled = false; actionBtn.textContent = t('dashboard.stopBtn') }
|
||||
}
|
||||
if (action === 'restart-gw') {
|
||||
@@ -444,7 +540,10 @@ function bindActions(page) {
|
||||
await api.restartService('ai.openclaw.gateway')
|
||||
toast(t('dashboard.gwRestartSent'), 'success')
|
||||
setTimeout(() => loadDashboardData(page), 3000)
|
||||
} catch (err) { toast(t('dashboard.restartFail') + ': ' + err, 'error') }
|
||||
} catch (err) {
|
||||
if (isForeignGatewayError(err)) await openGatewayConflict(page, err)
|
||||
else toast(t('dashboard.restartFail') + ': ' + err, 'error')
|
||||
}
|
||||
finally { actionBtn.disabled = false; actionBtn.textContent = t('dashboard.restartBtn') }
|
||||
}
|
||||
})
|
||||
@@ -456,7 +555,8 @@ function bindActions(page) {
|
||||
try {
|
||||
await api.restartService('ai.openclaw.gateway')
|
||||
} catch (e) {
|
||||
toast(t('dashboard.restartFail') + ': ' + e, 'error')
|
||||
if (isForeignGatewayError(e)) await openGatewayConflict(page, e)
|
||||
else toast(t('dashboard.restartFail') + ': ' + e, 'error')
|
||||
btnRestart.disabled = false
|
||||
btnRestart.classList.remove('btn-loading')
|
||||
btnRestart.textContent = t('dashboard.restartGw')
|
||||
|
||||
@@ -236,7 +236,7 @@ async function saveFile(page, state) {
|
||||
if (!state.currentPath) return
|
||||
const content = page.querySelector('#file-editor').value
|
||||
try {
|
||||
await api.writeMemoryFile(state.currentPath, content, null, state.agentId)
|
||||
await api.writeMemoryFile(state.currentPath, content, state.category, state.agentId)
|
||||
toast(t('memory.fileSaved'), 'success')
|
||||
} catch (e) {
|
||||
toast(t('memory.saveFailed') + ': ' + e, 'error')
|
||||
|
||||
@@ -50,7 +50,7 @@ async function apiCall(cmd, args = {}) {
|
||||
await api.writePanelConfig(cfg)
|
||||
return { success: true }
|
||||
}
|
||||
throw new Error('未知命令: ' + cmd)
|
||||
throw new Error(`${t('common.unknownCommand')}: ${cmd}`)
|
||||
}
|
||||
// Web 模式
|
||||
const resp = await fetch(`/__api/${cmd}`, {
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
*/
|
||||
import { api } from '../lib/tauri-api.js'
|
||||
import { toast } from '../components/toast.js'
|
||||
import { showConfirm, showUpgradeModal } from '../components/modal.js'
|
||||
import { showConfirm, showModal, showUpgradeModal } from '../components/modal.js'
|
||||
import { isMacPlatform, isInDocker, setUpgrading, setUserStopped, resetAutoRestart } from '../lib/app-state.js'
|
||||
import { isForeignGatewayError, isForeignGatewayService, maybeShowForeignGatewayBindingPrompt, showGatewayConflictGuidance } from '../lib/gateway-ownership.js'
|
||||
import { diagnoseInstallError } from '../lib/error-diagnosis.js'
|
||||
import { icon, statusIcon } from '../lib/icons.js'
|
||||
import { t } from '../lib/i18n.js'
|
||||
@@ -31,6 +32,11 @@ export async function render() {
|
||||
</div>
|
||||
<div id="version-bar"><div class="stat-card loading-placeholder" style="height:80px;margin-bottom:var(--space-lg)"></div></div>
|
||||
<div id="services-list"><div class="stat-card loading-placeholder" style="height:64px"></div></div>
|
||||
<div class="config-section" id="docker-manager-section">
|
||||
<div class="config-section-title">${t('services.dockerManager')}</div>
|
||||
<div class="form-hint" style="margin-bottom:var(--space-sm)">${t('services.dockerManagerHint')}</div>
|
||||
<div id="docker-manager-bar"><div class="stat-card loading-placeholder" style="height:96px"></div></div>
|
||||
</div>
|
||||
<div class="config-section" id="config-editor-section" style="display:none">
|
||||
<div class="config-section-title">${t('services.configEditor')}</div>
|
||||
<div class="form-hint" style="margin-bottom:var(--space-sm)">${t('services.configEditorHint')}</div>
|
||||
@@ -58,7 +64,7 @@ export async function render() {
|
||||
}
|
||||
|
||||
async function loadAll(page) {
|
||||
const tasks = [loadVersion(page), loadServices(page), loadBackups(page), loadConfigEditor(page)]
|
||||
const tasks = [loadVersion(page), loadServices(page), loadDockerManager(page), loadBackups(page), loadConfigEditor(page)]
|
||||
await Promise.all(tasks)
|
||||
}
|
||||
|
||||
@@ -71,7 +77,10 @@ let lastVersionInfo = null
|
||||
async function loadVersion(page) {
|
||||
const bar = page.querySelector('#version-bar')
|
||||
try {
|
||||
const info = await api.getVersionInfo()
|
||||
const [info, panelConfig] = await Promise.all([
|
||||
api.getVersionInfo(),
|
||||
api.readPanelConfig().catch(() => ({})),
|
||||
])
|
||||
lastVersionInfo = info
|
||||
detectedSource = info.source || 'chinese'
|
||||
const ver = info.current || t('common.unknown')
|
||||
@@ -82,6 +91,7 @@ async function loadVersion(page) {
|
||||
const sourceTag = isChinese ? t('services.chineseEdition') : t('services.officialEdition')
|
||||
const switchLabel = isChinese ? t('services.switchToOfficial') : t('services.switchToChinese')
|
||||
const switchTarget = isChinese ? 'official' : 'chinese'
|
||||
const dockerImage = (panelConfig?.dockerDefaultImage || '').trim() || 'ghcr.io/qingchencloud/openclaw'
|
||||
const policyNote = aheadOfRecommended
|
||||
? t('services.policyAhead', { ver, recommended: info.recommended })
|
||||
: t('services.policyDefault')
|
||||
@@ -96,7 +106,7 @@ async function loadVersion(page) {
|
||||
<div class="stat-card-value">${ver}</div>
|
||||
<div class="stat-card-meta">${info.latest_update_available ? t('services.latestUpstream', { version: info.latest }) + '(' + t('services.pullNewImage') + ')' : t('services.currentImageVer')}</div>
|
||||
${info.latest_update_available ? `<div style="margin-top:var(--space-sm)">
|
||||
<code style="font-size:var(--font-size-xs);background:var(--bg-tertiary);padding:4px 8px;border-radius:4px;user-select:all">docker pull ghcr.io/qingchencloud/openclaw:latest</code>
|
||||
<code style="font-size:var(--font-size-xs);background:var(--bg-tertiary);padding:4px 8px;border-radius:4px;user-select:all">${escapeHtml(`docker pull ${dockerImage}:latest`)}</code>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
@@ -131,6 +141,123 @@ async function loadVersion(page) {
|
||||
}
|
||||
}
|
||||
|
||||
function configuredDockerImage(panelConfig) {
|
||||
return (panelConfig?.dockerDefaultImage || '').trim() || 'ghcr.io/qingchencloud/openclaw'
|
||||
}
|
||||
|
||||
function formatDockerBytes(bytes) {
|
||||
const value = Number(bytes || 0)
|
||||
if (!Number.isFinite(value) || value <= 0) return '0 B'
|
||||
if (value >= 1024 * 1024 * 1024) return `${(value / (1024 * 1024 * 1024)).toFixed(1)} GB`
|
||||
if (value >= 1024 * 1024) return `${(value / (1024 * 1024)).toFixed(1)} MB`
|
||||
if (value >= 1024) return `${(value / 1024).toFixed(1)} KB`
|
||||
return `${value} B`
|
||||
}
|
||||
|
||||
function parseOptionalPort(value) {
|
||||
const raw = String(value || '').trim()
|
||||
if (!raw) return null
|
||||
const num = Number(raw)
|
||||
if (!Number.isInteger(num) || num < 1 || num > 65535) throw new Error(t('services.invalidPort', { value: raw }))
|
||||
return num
|
||||
}
|
||||
|
||||
async function hasDockerManagerBackend() {
|
||||
try {
|
||||
const resp = await fetch('/__api/health', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{}',
|
||||
})
|
||||
const ct = (resp.headers.get('content-type') || '').toLowerCase()
|
||||
return resp.ok && !ct.includes('text/html') && !ct.includes('text/plain')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDockerManager(page) {
|
||||
const bar = page.querySelector('#docker-manager-bar')
|
||||
if (!bar) return
|
||||
const backendReady = await hasDockerManagerBackend()
|
||||
if (!backendReady) {
|
||||
bar.innerHTML = `<div class="stat-card"><div class="stat-card-meta">${t('services.dockerManagerUnavailable')}</div></div>`
|
||||
return
|
||||
}
|
||||
try {
|
||||
const [overview, panelConfig] = await Promise.all([
|
||||
api.dockerClusterOverview(),
|
||||
api.readPanelConfig().catch(() => ({})),
|
||||
])
|
||||
const totalNodes = overview.length
|
||||
const onlineNodes = overview.filter(node => node.online).length
|
||||
const totalContainers = overview.reduce((sum, node) => sum + (node.containers?.length || 0), 0)
|
||||
const runningContainers = overview.reduce((sum, node) => sum + (node.containers?.filter?.(ct => ct.state === 'running').length || 0), 0)
|
||||
bar.innerHTML = `
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;gap:var(--space-sm);flex-wrap:wrap;margin-bottom:var(--space-md)">
|
||||
<div class="stat-card" style="padding:12px 16px;min-width:260px">
|
||||
<div class="stat-card-label">${t('services.dockerManager')}</div>
|
||||
<div class="stat-card-meta">${onlineNodes}/${totalNodes} ${t('services.dockerOnline')} · ${runningContainers}/${totalContainers} ${t('services.dockerContainersLabel')}</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<button class="btn btn-secondary btn-sm" data-action="docker-refresh">${t('services.dockerRefresh')}</button>
|
||||
<button class="btn btn-secondary btn-sm" data-action="docker-add-node">${t('services.dockerAddNode')}</button>
|
||||
<button class="btn btn-secondary btn-sm" data-action="docker-pull-image">${t('services.dockerPullAction')}</button>
|
||||
<button class="btn btn-primary btn-sm" data-action="docker-create-container">${t('services.dockerCreateContainer')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:var(--space-md)">
|
||||
${overview.map(node => {
|
||||
const containers = node.containers || []
|
||||
const nodeMeta = node.online
|
||||
? `${escapeHtml(node.endpoint || '')} · Docker ${escapeHtml(node.dockerVersion || t('common.unknown'))} · ${formatDockerBytes(node.memory)} · CPU ${node.cpus || 0}`
|
||||
: `${escapeHtml(node.endpoint || '')} · ${escapeHtml(node.error || t('services.dockerOffline'))}`
|
||||
return `
|
||||
<div class="service-card" data-docker-node="${escapeHtml(node.id)}" style="display:block">
|
||||
<div style="display:flex;justify-content:space-between;gap:var(--space-sm);align-items:flex-start;flex-wrap:wrap">
|
||||
<div class="service-info">
|
||||
<span class="status-dot ${node.online ? 'running' : 'stopped'}"></span>
|
||||
<div>
|
||||
<div class="service-name">${escapeHtml(node.name)}${node.id === 'local' ? ` <span class="clawhub-badge" style="margin-left:6px;background:rgba(99,102,241,0.14);color:#6366f1">${t('services.dockerLocalNode')}</span>` : ''}</div>
|
||||
<div class="service-desc">${nodeMeta}</div>
|
||||
<div class="service-desc">${node.online ? `${t('services.dockerContainersLabel')}: ${node.runningContainers || 0}/${node.totalContainers || containers.length}` : t('services.dockerOffline')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="service-actions">
|
||||
${node.id !== 'local' ? `<button class="btn btn-danger btn-sm" data-action="docker-remove-node" data-node-id="${escapeHtml(node.id)}" data-name="${escapeHtml(node.name)}">${t('common.delete')}</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:var(--space-sm);display:flex;flex-direction:column;gap:8px">
|
||||
${containers.length ? containers.map(ct => `
|
||||
<div class="service-card" style="background:var(--bg-secondary);border:1px solid var(--border-primary)">
|
||||
<div class="service-info">
|
||||
<span class="status-dot ${ct.state === 'running' ? 'running' : 'stopped'}"></span>
|
||||
<div>
|
||||
<div class="service-name">${escapeHtml(ct.name)}</div>
|
||||
<div class="service-desc">${escapeHtml(ct.image)} · ${escapeHtml(ct.status || ct.state || t('common.unknown'))}${ct.ports ? ` · ${escapeHtml(ct.ports)}` : ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="service-actions">
|
||||
${ct.state === 'running'
|
||||
? `<button class="btn btn-secondary btn-sm" data-action="docker-restart-container" data-node-id="${escapeHtml(node.id)}" data-container-id="${escapeHtml(ct.id)}" data-name="${escapeHtml(ct.name)}">${t('services.restart')}</button>
|
||||
<button class="btn btn-secondary btn-sm" data-action="docker-stop-container" data-node-id="${escapeHtml(node.id)}" data-container-id="${escapeHtml(ct.id)}" data-name="${escapeHtml(ct.name)}">${t('services.stop')}</button>`
|
||||
: `<button class="btn btn-primary btn-sm" data-action="docker-start-container" data-node-id="${escapeHtml(node.id)}" data-container-id="${escapeHtml(ct.id)}" data-name="${escapeHtml(ct.name)}">${t('services.start')}</button>`}
|
||||
<button class="btn btn-danger btn-sm" data-action="docker-remove-container" data-node-id="${escapeHtml(node.id)}" data-container-id="${escapeHtml(ct.id)}" data-name="${escapeHtml(ct.name)}" data-running="${ct.state === 'running' ? '1' : ''}">${t('common.delete')}</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('') : `<div class="form-hint" style="padding:4px 0">${t('services.dockerNoContainers')}</div>`}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}).join('')}
|
||||
</div>
|
||||
<div class="form-hint" style="margin-top:var(--space-sm)">${t('services.dockerDefaultImageHint')} <code>${escapeHtml(configuredDockerImage(panelConfig))}</code></div>
|
||||
`
|
||||
} catch (e) {
|
||||
bar.innerHTML = `<div class="stat-card"><div class="stat-card-meta" style="color:var(--error)">${t('services.dockerManagerLoadFailed')}: ${escapeHtml(e?.message || e)}</div></div>`
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 服务列表 =====
|
||||
|
||||
async function loadServices(page) {
|
||||
@@ -138,11 +265,174 @@ async function loadServices(page) {
|
||||
try {
|
||||
const services = await api.getServicesStatus()
|
||||
renderServices(container, services)
|
||||
const gw = services?.find?.(s => s.label === 'ai.openclaw.gateway') || services?.[0] || null
|
||||
if (gw) {
|
||||
maybeShowForeignGatewayBindingPrompt({
|
||||
service: gw,
|
||||
onRefresh: () => loadServices(page),
|
||||
}).catch(() => {})
|
||||
}
|
||||
} catch (e) {
|
||||
container.innerHTML = `<div style="color:var(--error)">${t('services.serviceLoadFailed')}: ${escapeHtml(String(e))}</div>`
|
||||
}
|
||||
}
|
||||
|
||||
async function openDockerAddNode(page) {
|
||||
showModal({
|
||||
title: t('services.dockerAddNode'),
|
||||
fields: [
|
||||
{ name: 'name', label: t('services.dockerNodeName'), value: '', placeholder: 'docker-node-1' },
|
||||
{ name: 'endpoint', label: t('services.dockerNodeEndpoint'), value: '', placeholder: 'tcp://192.168.1.20:2375' },
|
||||
],
|
||||
onConfirm: async ({ name, endpoint }) => {
|
||||
try {
|
||||
await api.dockerAddNode((name || '').trim(), (endpoint || '').trim())
|
||||
toast(t('services.dockerNodeAdded'), 'success')
|
||||
await loadDockerManager(page)
|
||||
} catch (e) {
|
||||
toast(e?.message || e, 'error')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function openDockerPullImage(page) {
|
||||
const [nodes, panelConfig] = await Promise.all([
|
||||
api.dockerListNodes(),
|
||||
api.readPanelConfig().catch(() => ({})),
|
||||
])
|
||||
showModal({
|
||||
title: t('services.dockerPullTitle'),
|
||||
fields: [
|
||||
{ name: 'nodeId', type: 'select', label: t('services.dockerNodeName'), value: nodes[0]?.id || 'local', options: nodes.map(node => ({ value: node.id, label: node.name })) },
|
||||
{ name: 'image', label: t('services.dockerImageLabel'), value: configuredDockerImage(panelConfig), hint: t('services.dockerDefaultImageHint') },
|
||||
{ name: 'tag', label: t('services.dockerTagLabel'), value: 'latest' },
|
||||
],
|
||||
onConfirm: async ({ nodeId, image, tag }) => {
|
||||
const requestId = `pull-${Date.now()}`
|
||||
const modal = showUpgradeModal(t('services.dockerPullTitle'))
|
||||
let lastMessage = ''
|
||||
const timer = setInterval(async () => {
|
||||
try {
|
||||
const status = await api.dockerPullStatus(requestId)
|
||||
if (Number.isFinite(status?.percent)) modal.setProgress(status.percent)
|
||||
if (status?.message && status.message !== lastMessage) {
|
||||
lastMessage = status.message
|
||||
modal.appendLog(status.message)
|
||||
}
|
||||
} catch {}
|
||||
}, 800)
|
||||
|
||||
try {
|
||||
const result = await api.dockerPullImage({
|
||||
nodeId: nodeId || null,
|
||||
image: (image || '').trim() || configuredDockerImage(panelConfig),
|
||||
tag: (tag || '').trim() || 'latest',
|
||||
requestId,
|
||||
})
|
||||
clearInterval(timer)
|
||||
modal.setProgress(100)
|
||||
if (result?.message) modal.appendLog(result.message)
|
||||
modal.setDone(t('services.dockerPullDone'))
|
||||
toast(t('services.dockerPullDone'), 'success')
|
||||
await loadDockerManager(page)
|
||||
} catch (e) {
|
||||
clearInterval(timer)
|
||||
modal.appendLog(e?.message || String(e))
|
||||
modal.setError(e?.message || String(e))
|
||||
toast(e?.message || e, 'error')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function openDockerCreateContainer(page) {
|
||||
const [nodes, panelConfig] = await Promise.all([
|
||||
api.dockerListNodes(),
|
||||
api.readPanelConfig().catch(() => ({})),
|
||||
])
|
||||
showModal({
|
||||
title: t('services.dockerCreateTitle'),
|
||||
fields: [
|
||||
{ name: 'nodeId', type: 'select', label: t('services.dockerNodeName'), value: nodes[0]?.id || 'local', options: nodes.map(node => ({ value: node.id, label: node.name })) },
|
||||
{ name: 'name', label: t('services.dockerContainerNameLabel'), value: '', placeholder: 'openclaw-worker-1' },
|
||||
{ name: 'image', label: t('services.dockerImageLabel'), value: configuredDockerImage(panelConfig), hint: t('services.dockerDefaultImageHint') },
|
||||
{ name: 'tag', label: t('services.dockerTagLabel'), value: 'latest' },
|
||||
{ name: 'panelPort', label: t('services.dockerPanelPortLabel'), value: '1420', hint: t('services.dockerPortOptionalHint') },
|
||||
{ name: 'gatewayPort', label: t('services.dockerGatewayPortLabel'), value: '18789', hint: t('services.dockerPortOptionalHint') },
|
||||
{ name: 'volume', type: 'checkbox', label: t('services.dockerUseVolume'), value: true },
|
||||
],
|
||||
onConfirm: async ({ nodeId, name, image, tag, panelPort, gatewayPort, volume }) => {
|
||||
try {
|
||||
await api.dockerCreateContainer({
|
||||
nodeId: nodeId || null,
|
||||
name: (name || '').trim() || undefined,
|
||||
image: (image || '').trim() || configuredDockerImage(panelConfig),
|
||||
tag: (tag || '').trim() || 'latest',
|
||||
panelPort: parseOptionalPort(panelPort),
|
||||
gatewayPort: parseOptionalPort(gatewayPort),
|
||||
volume: !!volume,
|
||||
})
|
||||
toast(t('services.dockerContainerCreated'), 'success')
|
||||
await loadDockerManager(page)
|
||||
} catch (e) {
|
||||
toast(e?.message || e, 'error')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function handleDockerRemoveNode(btn, page) {
|
||||
const name = btn.dataset.name || btn.dataset.nodeId || ''
|
||||
const yes = await showConfirm(t('services.dockerRemoveNodeConfirm', { name }))
|
||||
if (!yes) return
|
||||
await api.dockerRemoveNode(btn.dataset.nodeId)
|
||||
toast(t('services.dockerNodeRemoved'), 'success')
|
||||
await loadDockerManager(page)
|
||||
}
|
||||
|
||||
async function handleDockerContainerAction(action, btn, page) {
|
||||
const nodeId = btn.dataset.nodeId || null
|
||||
const containerId = btn.dataset.containerId
|
||||
const name = btn.dataset.name || containerId
|
||||
if (!containerId) throw new Error(t('services.missingContainerId'))
|
||||
if (action === 'docker-remove-container') {
|
||||
const yes = await showConfirm(t('services.dockerRemoveContainerConfirm', { name }))
|
||||
if (!yes) return
|
||||
await api.dockerRemoveContainer(nodeId, containerId, btn.dataset.running === '1')
|
||||
toast(t('services.dockerContainerRemoved'), 'success')
|
||||
await loadDockerManager(page)
|
||||
return
|
||||
}
|
||||
|
||||
const label = {
|
||||
'docker-start-container': t('services.start'),
|
||||
'docker-stop-container': t('services.stop'),
|
||||
'docker-restart-container': t('services.restart'),
|
||||
}[action]
|
||||
const fn = {
|
||||
'docker-start-container': api.dockerStartContainer,
|
||||
'docker-stop-container': api.dockerStopContainer,
|
||||
'docker-restart-container': api.dockerRestartContainer,
|
||||
}[action]
|
||||
await fn(nodeId, containerId)
|
||||
toast(t('services.actionDone', { label: name, action: label }), 'success')
|
||||
await loadDockerManager(page)
|
||||
}
|
||||
|
||||
async function openGatewayConflict(page, error = null) {
|
||||
const services = await api.getServicesStatus().catch(() => [])
|
||||
const gw = services?.find?.(s => s.label === 'ai.openclaw.gateway') || services?.[0] || null
|
||||
await showGatewayConflictGuidance({
|
||||
error,
|
||||
service: gw,
|
||||
onRefresh: async () => {
|
||||
await loadVersion(page)
|
||||
await loadServices(page)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function renderServices(container, services) {
|
||||
const gw = services.find(s => s.label === 'ai.openclaw.gateway')
|
||||
|
||||
@@ -150,6 +440,8 @@ function renderServices(container, services) {
|
||||
if (gw) {
|
||||
// 检测 CLI 是否安装
|
||||
const cliMissing = gw.cli_installed === false
|
||||
const foreignGateway = !cliMissing && isForeignGatewayService(gw)
|
||||
const foreignPidText = gw.pid ? ` (PID: ${gw.pid})` : ''
|
||||
|
||||
html += `
|
||||
<div class="service-card" data-label="${gw.label}">
|
||||
@@ -159,6 +451,8 @@ function renderServices(container, services) {
|
||||
<div class="service-name">${gw.label}</div>
|
||||
<div class="service-desc">${cliMissing
|
||||
? t('services.cliNotInstalled')
|
||||
: foreignGateway
|
||||
? t('services.foreignGatewayDesc', { pid: foreignPidText, settings: t('sidebar.settings') })
|
||||
: (gw.description || '') + (gw.pid ? ' (PID: ' + gw.pid + ')' : '')
|
||||
}</div>
|
||||
</div>
|
||||
@@ -170,6 +464,14 @@ function renderServices(container, services) {
|
||||
<code style="font-size:var(--font-size-xs);background:var(--bg-tertiary);padding:2px 8px;border-radius:4px;user-select:all">npm install -g @qingchencloud/openclaw-zh</code>
|
||||
<button class="btn btn-secondary btn-sm" data-action="refresh-services" style="margin-top:4px">${t('services.refreshStatus')}</button>
|
||||
</div>`
|
||||
: foreignGateway
|
||||
? `<div style="display:flex;flex-direction:column;gap:var(--space-xs);align-items:flex-end">
|
||||
<div style="color:var(--warning);font-size:var(--font-size-xs);max-width:320px;text-align:right">${t('services.foreignGatewayHint')}</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;justify-content:flex-end">
|
||||
<button class="btn btn-secondary btn-sm" data-action="resolve-foreign-gateway">${t('dashboard.viewGuidance')}</button>
|
||||
<button class="btn btn-secondary btn-sm" data-action="refresh-services">${t('services.refreshStatus')}</button>
|
||||
</div>
|
||||
</div>`
|
||||
: gw.running
|
||||
? `<button class="btn btn-secondary btn-sm" data-action="restart" data-label="${gw.label}">${t('services.restart')}</button>
|
||||
<button class="btn btn-danger btn-sm" data-action="stop" data-label="${gw.label}">${t('services.stop')}</button>
|
||||
@@ -283,6 +585,30 @@ function bindEvents(page) {
|
||||
case 'refresh-services':
|
||||
await loadServices(page)
|
||||
break
|
||||
case 'resolve-foreign-gateway':
|
||||
await openGatewayConflict(page)
|
||||
break
|
||||
case 'docker-refresh':
|
||||
await loadDockerManager(page)
|
||||
break
|
||||
case 'docker-add-node':
|
||||
await openDockerAddNode(page)
|
||||
break
|
||||
case 'docker-pull-image':
|
||||
await openDockerPullImage(page)
|
||||
break
|
||||
case 'docker-create-container':
|
||||
await openDockerCreateContainer(page)
|
||||
break
|
||||
case 'docker-remove-node':
|
||||
await handleDockerRemoveNode(btn, page)
|
||||
break
|
||||
case 'docker-start-container':
|
||||
case 'docker-stop-container':
|
||||
case 'docker-restart-container':
|
||||
case 'docker-remove-container':
|
||||
await handleDockerContainerAction(action, btn, page)
|
||||
break
|
||||
}
|
||||
} catch (e) {
|
||||
toast(e.toString(), 'error')
|
||||
@@ -333,7 +659,11 @@ async function handleServiceAction(action, label, page) {
|
||||
try {
|
||||
await fn(label)
|
||||
} catch (e) {
|
||||
toast(t('services.actionCmdFailed', { action: actionLabel, error: e.message || e }), 'error')
|
||||
if (isForeignGatewayError(e)) {
|
||||
await openGatewayConflict(page, e)
|
||||
} else {
|
||||
toast(t('services.actionCmdFailed', { action: actionLabel, error: e.message || e }), 'error')
|
||||
}
|
||||
if (actionsEl) actionsEl.innerHTML = origHtml
|
||||
if (dot) dot.className = 'status-dot stopped'
|
||||
return
|
||||
|
||||
@@ -6,6 +6,7 @@ import { api } from '../lib/tauri-api.js'
|
||||
import { toast } from '../components/toast.js'
|
||||
import { showConfirm } from '../components/modal.js'
|
||||
import { t, getLang, setLang, getAvailableLangs, onLangChange } from '../lib/i18n.js'
|
||||
import { isMacPlatform } from '../lib/app-state.js'
|
||||
import { renderSidebar } from '../components/sidebar.js'
|
||||
|
||||
const isTauri = !!window.__TAURI_INTERNALS__
|
||||
@@ -15,6 +16,44 @@ function escapeHtml(str) {
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
}
|
||||
|
||||
function platformDefaultDockerEndpoint() {
|
||||
const isWin = navigator.platform?.startsWith('Win') || navigator.userAgent?.includes('Windows')
|
||||
return isWin ? '//./pipe/docker_engine' : '/var/run/docker.sock'
|
||||
}
|
||||
|
||||
function effectiveDockerEndpoint(cfg) {
|
||||
return (cfg?.dockerEndpoint || '').trim() || platformDefaultDockerEndpoint()
|
||||
}
|
||||
|
||||
function effectiveDockerImage(cfg) {
|
||||
return (cfg?.dockerDefaultImage || '').trim() || 'ghcr.io/qingchencloud/openclaw'
|
||||
}
|
||||
|
||||
function openclawInstallationIdentity(installation) {
|
||||
const rawPath = String(installation?.path || '').trim()
|
||||
if (!rawPath) return ''
|
||||
const isWin = navigator.platform?.startsWith('Win') || navigator.userAgent?.includes('Windows')
|
||||
if (!isWin) return rawPath
|
||||
return rawPath
|
||||
.replace(/\//g, '\\')
|
||||
.replace(/\\openclaw(?:\.exe|\.ps1)?$/i, '\\openclaw.cmd')
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
function dedupeOpenclawInstallations(list = []) {
|
||||
const map = new Map()
|
||||
const preferCmd = inst => /openclaw\.cmd$/i.test(String(inst?.path || ''))
|
||||
for (const installation of Array.isArray(list) ? list : []) {
|
||||
const key = openclawInstallationIdentity(installation)
|
||||
if (!key) continue
|
||||
const existing = map.get(key)
|
||||
if (!existing || (!existing.active && installation.active) || (!preferCmd(existing) && preferCmd(installation))) {
|
||||
map.set(key, installation)
|
||||
}
|
||||
}
|
||||
return [...map.values()]
|
||||
}
|
||||
|
||||
const REGISTRIES = [
|
||||
{ label: () => t('settings.registryTaobao'), value: 'https://registry.npmmirror.com' },
|
||||
{ label: () => t('settings.registryNpm'), value: 'https://registry.npmjs.org' },
|
||||
@@ -51,6 +90,16 @@ export async function render() {
|
||||
<div id="openclaw-dir-bar"><div class="stat-card loading-placeholder" style="height:48px"></div></div>
|
||||
</div>
|
||||
|
||||
<div class="config-section" id="openclaw-search-section">
|
||||
<div class="config-section-title">${t('settings.openclawSearchPaths')}</div>
|
||||
<div id="openclaw-search-bar"><div class="stat-card loading-placeholder" style="height:96px"></div></div>
|
||||
</div>
|
||||
|
||||
<div class="config-section" id="docker-defaults-section">
|
||||
<div class="config-section-title">${t('settings.dockerDefaults')}</div>
|
||||
<div id="docker-defaults-bar"><div class="stat-card loading-placeholder" style="height:84px"></div></div>
|
||||
</div>
|
||||
|
||||
<div class="config-section" id="cli-binding-section">
|
||||
<div class="config-section-title">${t('settings.openclawCli')}</div>
|
||||
<div id="cli-binding-bar"><div class="stat-card loading-placeholder" style="height:48px"></div></div>
|
||||
@@ -62,7 +111,7 @@ export async function render() {
|
||||
</div>
|
||||
|
||||
${window.__TAURI_INTERNALS__ ? `<div class="config-section" id="autostart-section">
|
||||
<div class="config-section-title">${t('settings.autostart') || '开机自启'}</div>
|
||||
<div class="config-section-title">${t('settings.autostart')}</div>
|
||||
<div id="autostart-bar"><div class="stat-card loading-placeholder" style="height:48px"></div></div>
|
||||
</div>` : ''}
|
||||
|
||||
@@ -74,7 +123,7 @@ export async function render() {
|
||||
}
|
||||
|
||||
async function loadAll(page) {
|
||||
const tasks = [loadProxyConfig(page), loadModelProxyConfig(page), loadOpenclawDir(page), loadCliBinding(page)]
|
||||
const tasks = [loadProxyConfig(page), loadModelProxyConfig(page), loadOpenclawDir(page), loadOpenclawSearchPaths(page), loadDockerDefaults(page), loadCliBinding(page)]
|
||||
tasks.push(loadRegistry(page))
|
||||
if (window.__TAURI_INTERNALS__) tasks.push(loadAutostart(page))
|
||||
await Promise.all(tasks)
|
||||
@@ -171,7 +220,7 @@ 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 info = await api.getOpenclawDir()
|
||||
const cfg = await api.readPanelConfig()
|
||||
const customValue = cfg?.openclawDir || ''
|
||||
const statusText = info.configExists
|
||||
@@ -209,7 +258,14 @@ async function handleSaveOpenclawDir(page) {
|
||||
}
|
||||
await api.writePanelConfig(cfg)
|
||||
await loadOpenclawDir(page)
|
||||
await promptRestart(value ? t('settings.customPathSaved') : t('settings.defaultRestored'))
|
||||
await loadCliBinding(page)
|
||||
const savedMsg = value ? t('settings.customPathSaved') : t('settings.defaultRestored')
|
||||
const refreshed = await maybeRefreshGatewayServiceBinding()
|
||||
if (refreshed) {
|
||||
toast(savedMsg, 'success')
|
||||
return
|
||||
}
|
||||
await promptRestart(savedMsg)
|
||||
}
|
||||
|
||||
async function handleResetOpenclawDir(page) {
|
||||
@@ -217,9 +273,145 @@ async function handleResetOpenclawDir(page) {
|
||||
delete cfg.openclawDir
|
||||
await api.writePanelConfig(cfg)
|
||||
await loadOpenclawDir(page)
|
||||
await loadCliBinding(page)
|
||||
const refreshed = await maybeRefreshGatewayServiceBinding()
|
||||
if (refreshed) {
|
||||
toast(t('settings.defaultRestored'), 'success')
|
||||
return
|
||||
}
|
||||
await promptRestart(t('settings.defaultRestored'))
|
||||
}
|
||||
|
||||
async function loadOpenclawSearchPaths(page) {
|
||||
const bar = page.querySelector('#openclaw-search-bar')
|
||||
if (!bar) return
|
||||
try {
|
||||
const cfg = await api.readPanelConfig()
|
||||
const value = Array.isArray(cfg?.openclawSearchPaths) ? cfg.openclawSearchPaths.join('\n') : ''
|
||||
bar.innerHTML = `
|
||||
<div style="display:flex;flex-direction:column;gap:var(--space-sm)">
|
||||
<textarea class="form-input" data-name="openclaw-search-paths" rows="4" placeholder="${t('settings.searchPathsPlaceholder')}" style="max-width:680px;min-height:108px;resize:vertical">${escapeHtml(value)}</textarea>
|
||||
<div style="display:flex;align-items:center;gap:var(--space-sm);flex-wrap:wrap">
|
||||
<button class="btn btn-primary btn-sm" data-action="save-openclaw-search-paths">${t('common.save')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-hint" style="margin-top:var(--space-xs)">
|
||||
${t('settings.searchPathsHint')}
|
||||
</div>
|
||||
`
|
||||
} catch (e) {
|
||||
bar.innerHTML = `<div style="color:var(--error)">${t('common.loadFailed')}: ${escapeHtml(String(e))}</div>`
|
||||
}
|
||||
}
|
||||
|
||||
function parseOpenclawSearchPaths(raw) {
|
||||
const values = []
|
||||
const seen = new Set()
|
||||
for (const part of String(raw || '').split(/[\r\n;]+/)) {
|
||||
const value = part.trim()
|
||||
if (!value) continue
|
||||
const key = value.toLowerCase()
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
values.push(value)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
async function handleSaveOpenclawSearchPaths(page) {
|
||||
const input = page.querySelector('[data-name="openclaw-search-paths"]')
|
||||
const paths = parseOpenclawSearchPaths(input?.value || '')
|
||||
const cfg = await api.readPanelConfig()
|
||||
if (paths.length > 0) {
|
||||
cfg.openclawSearchPaths = paths
|
||||
} else {
|
||||
delete cfg.openclawSearchPaths
|
||||
}
|
||||
await api.writePanelConfig(cfg)
|
||||
await loadOpenclawSearchPaths(page)
|
||||
await loadCliBinding(page)
|
||||
toast(paths.length > 0 ? t('settings.searchPathsSaved') : t('settings.searchPathsCleared'), 'success')
|
||||
}
|
||||
|
||||
async function loadDockerDefaults(page) {
|
||||
const bar = page.querySelector('#docker-defaults-bar')
|
||||
if (!bar) return
|
||||
try {
|
||||
const cfg = await api.readPanelConfig()
|
||||
const endpoint = cfg?.dockerEndpoint || ''
|
||||
const image = cfg?.dockerDefaultImage || ''
|
||||
const currentEndpoint = effectiveDockerEndpoint(cfg)
|
||||
const currentImage = effectiveDockerImage(cfg)
|
||||
bar.innerHTML = `
|
||||
<div style="margin-bottom:var(--space-xs);display:flex;flex-direction:column;gap:4px">
|
||||
<div><span class="form-hint">${t('settings.currentDefault')}:</span> <code style="font-size:var(--font-size-xs)">${escapeHtml(currentEndpoint)}</code></div>
|
||||
<div><span class="form-hint">${t('settings.dockerDefaultImage')}:</span> <code style="font-size:var(--font-size-xs)">${escapeHtml(currentImage)}</code></div>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:var(--space-sm)">
|
||||
<input class="form-input" data-name="docker-endpoint" placeholder="${t('settings.dockerEndpointPlaceholder')}" value="${escapeHtml(endpoint)}" style="max-width:680px">
|
||||
<input class="form-input" data-name="docker-default-image" placeholder="${t('settings.dockerDefaultImagePlaceholder')}" value="${escapeHtml(image)}" style="max-width:680px">
|
||||
<div style="display:flex;align-items:center;gap:var(--space-sm);flex-wrap:wrap">
|
||||
<button class="btn btn-primary btn-sm" data-action="save-docker-defaults">${t('common.save')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-hint" style="margin-top:var(--space-xs)">
|
||||
${t('settings.dockerDefaultsHint')}
|
||||
</div>
|
||||
`
|
||||
} catch (e) {
|
||||
bar.innerHTML = `<div style="color:var(--error)">${t('common.loadFailed')}: ${escapeHtml(String(e))}</div>`
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveDockerDefaults(page) {
|
||||
const endpointInput = page.querySelector('[data-name="docker-endpoint"]')
|
||||
const imageInput = page.querySelector('[data-name="docker-default-image"]')
|
||||
const endpoint = (endpointInput?.value || '').trim()
|
||||
const image = (imageInput?.value || '').trim()
|
||||
const cfg = await api.readPanelConfig()
|
||||
if (endpoint) cfg.dockerEndpoint = endpoint
|
||||
else delete cfg.dockerEndpoint
|
||||
if (image) cfg.dockerDefaultImage = image
|
||||
else delete cfg.dockerDefaultImage
|
||||
await api.writePanelConfig(cfg)
|
||||
await loadDockerDefaults(page)
|
||||
toast(t('settings.dockerDefaultsSaved'), 'success')
|
||||
}
|
||||
|
||||
async function maybeRefreshGatewayServiceBinding() {
|
||||
if (!isMacPlatform()) return false
|
||||
|
||||
const [versionInfo, dirInfo] = await Promise.all([
|
||||
api.getVersionInfo().catch(() => null),
|
||||
api.getOpenclawDir().catch(() => null),
|
||||
])
|
||||
if (!versionInfo?.cli_path || dirInfo?.configExists === false) {
|
||||
return false
|
||||
}
|
||||
|
||||
const shouldRefresh = await showConfirm(t('settings.gatewayServiceRefreshConfirm'))
|
||||
if (!shouldRefresh) return false
|
||||
|
||||
toast(t('settings.gatewayServiceRefreshing'), 'info')
|
||||
try {
|
||||
const services = await api.getServicesStatus().catch(() => [])
|
||||
const gw = services?.find?.(s => s.label === 'ai.openclaw.gateway') || services?.[0] || null
|
||||
const shouldStartAgain = gw?.running === true && gw?.owned_by_current_instance !== false
|
||||
|
||||
await api.uninstallGateway().catch(() => {})
|
||||
await api.installGateway()
|
||||
if (shouldStartAgain) {
|
||||
await api.startService('ai.openclaw.gateway')
|
||||
}
|
||||
|
||||
toast(t('settings.gatewayServiceRefreshed'), 'success')
|
||||
return true
|
||||
} catch (e) {
|
||||
toast(`${t('settings.gatewayServiceRefreshFailed')}: ${e?.message || e}`, 'warning')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function promptRestart(msg) {
|
||||
if (!isTauri) { toast(msg, 'success'); return }
|
||||
const ok = await showConfirm(`${msg}\n\n${t('settings.restartConfirm')}`)
|
||||
@@ -262,6 +454,12 @@ function bindEvents(page) {
|
||||
case 'reset-openclaw-dir':
|
||||
await handleResetOpenclawDir(page)
|
||||
break
|
||||
case 'save-openclaw-search-paths':
|
||||
await handleSaveOpenclawSearchPaths(page)
|
||||
break
|
||||
case 'save-docker-defaults':
|
||||
await handleSaveDockerDefaults(page)
|
||||
break
|
||||
case 'bind-cli':
|
||||
await handleBindCli(page, btn.dataset.path)
|
||||
break
|
||||
@@ -359,7 +557,7 @@ async function loadCliBinding(page) {
|
||||
const version = await api.getVersionInfo()
|
||||
const cfg = await api.readPanelConfig()
|
||||
const boundPath = cfg?.openclawCliPath || ''
|
||||
const installations = version.all_installations || []
|
||||
const installations = dedupeOpenclawInstallations(version.all_installations || [])
|
||||
const currentPath = version.cli_path || ''
|
||||
|
||||
const sourceLabel = (src) => ({
|
||||
@@ -417,6 +615,7 @@ async function handleBindCli(page, path) {
|
||||
await api.writePanelConfig(cfg)
|
||||
toast(t('common.saveSuccess'), 'success')
|
||||
await loadCliBinding(page)
|
||||
await maybeRefreshGatewayServiceBinding()
|
||||
}
|
||||
|
||||
async function handleUnbindCli(page) {
|
||||
@@ -425,6 +624,7 @@ async function handleUnbindCli(page) {
|
||||
await api.writePanelConfig(cfg)
|
||||
toast(t('common.saveSuccess'), 'success')
|
||||
await loadCliBinding(page)
|
||||
await maybeRefreshGatewayServiceBinding()
|
||||
}
|
||||
|
||||
// ===== 语言切换 =====
|
||||
@@ -468,28 +668,28 @@ async function loadAutostart(page) {
|
||||
<div style="display:flex;align-items:center;gap:var(--space-sm)">
|
||||
<label style="display:flex;align-items:center;gap:6px;font-size:var(--font-size-sm);cursor:pointer">
|
||||
<input type="checkbox" id="autostart-toggle" ${enabled ? 'checked' : ''}>
|
||||
${t('settings.autostartToggle') || '系统启动时自动运行 ClawPanel'}
|
||||
${t('settings.autostartToggle')}
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-hint" style="margin-top:var(--space-xs)">
|
||||
${t('settings.autostartHint') || '开启后,电脑重启时 ClawPanel 会自动启动并检测 Gateway 状态'}
|
||||
${t('settings.autostartHint')}
|
||||
</div>
|
||||
`
|
||||
bar.querySelector('#autostart-toggle')?.addEventListener('change', async (e) => {
|
||||
try {
|
||||
if (e.target.checked) {
|
||||
await enable()
|
||||
toast(t('settings.autostartEnabled') || '已开启开机自启', 'success')
|
||||
toast(t('settings.autostartEnabled'), 'success')
|
||||
} else {
|
||||
await disable()
|
||||
toast(t('settings.autostartDisabled') || '已关闭开机自启', 'success')
|
||||
toast(t('settings.autostartDisabled'), 'success')
|
||||
}
|
||||
} catch (err) {
|
||||
e.target.checked = !e.target.checked
|
||||
toast((t('settings.autostartFailed') || '设置失败') + ': ' + err, 'error')
|
||||
toast(t('settings.autostartFailed') + ': ' + err, 'error')
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
bar.innerHTML = `<div style="color:var(--text-tertiary);font-size:var(--font-size-sm)">${t('settings.autostartUnavailable') || '当前环境不支持开机自启'}</div>`
|
||||
bar.innerHTML = `<div style="color:var(--text-tertiary);font-size:var(--font-size-sm)">${t('settings.autostartUnavailable')}</div>`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,28 +10,92 @@ import { diagnoseInstallError } from '../lib/error-diagnosis.js'
|
||||
import { icon, statusIcon } from '../lib/icons.js'
|
||||
import { t } from '../lib/i18n.js'
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (str == null) return ''
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
function openclawSourceLabel(src) {
|
||||
return ({
|
||||
standalone: t('dashboard.cliSourceStandalone'),
|
||||
'npm-zh': t('dashboard.cliSourceNpmZh'),
|
||||
'npm-official': t('dashboard.cliSourceNpmOfficial'),
|
||||
'npm-global': t('dashboard.cliSourceNpmGlobal'),
|
||||
})[src] || t('dashboard.cliSourceUnknown')
|
||||
}
|
||||
|
||||
function parseOpenclawSearchPaths(raw) {
|
||||
const values = []
|
||||
const seen = new Set()
|
||||
for (const part of String(raw || '').split(/[\r\n;]+/)) {
|
||||
const value = part.trim()
|
||||
if (!value) continue
|
||||
const key = value.toLowerCase()
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
values.push(value)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
function buildStatusMeta(...parts) {
|
||||
return parts
|
||||
.map(part => String(part || '').trim())
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
}
|
||||
|
||||
function renderDetectionHint(pathValue, sourceLabel = '') {
|
||||
const normalizedPath = String(pathValue || '').trim()
|
||||
const normalizedSource = String(sourceLabel || '').trim()
|
||||
if (!normalizedPath && !normalizedSource) return ''
|
||||
return `
|
||||
<div class="setup-inline-note" style="margin-top:8px;line-height:1.6">
|
||||
${normalizedPath ? `<div><span style="color:var(--text-secondary)">${t('setup.detectedPathLabel')}:</span> <code style="font-size:11px">${escapeHtml(normalizedPath)}</code></div>` : ''}
|
||||
${normalizedSource ? `<div${normalizedPath ? ' style="margin-top:4px"' : ''}><span style="color:var(--text-secondary)">${t('setup.detectedFromLabel')}:</span> ${escapeHtml(normalizedSource)}</div>` : ''}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
function renderStatusCard(title, ok, meta) {
|
||||
return `
|
||||
<div class="setup-status-card ${ok ? 'is-ok' : 'is-pending'}">
|
||||
<div class="setup-status-icon">${ok ? '✓' : '✦'}</div>
|
||||
<div class="setup-status-body">
|
||||
<div class="setup-status-title">${title}</div>
|
||||
<div class="setup-status-meta">${escapeHtml(meta)}</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
export async function render() {
|
||||
const page = document.createElement('div')
|
||||
page.className = 'page'
|
||||
|
||||
page.innerHTML = `
|
||||
<div style="max-width:560px;margin:48px auto;text-align:center">
|
||||
<div style="margin-bottom:var(--space-lg)">
|
||||
<img src="/images/logo-brand.png" alt="ClawPanel" style="max-width:160px;width:100%;height:auto">
|
||||
<div class="setup-shell">
|
||||
<div class="setup-hero">
|
||||
<div class="setup-hero-brand">
|
||||
<img src="/images/logo-brand.png" alt="ClawPanel" class="setup-hero-logo">
|
||||
<div class="setup-hero-copy">
|
||||
<h1 class="setup-hero-title">${t('setup.headerTitle')}</h1>
|
||||
<p class="setup-hero-desc">${t('setup.headerDesc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setup-hero-actions">
|
||||
<button class="btn btn-secondary btn-sm" id="btn-recheck" style="min-width:120px">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="margin-right:4px"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>
|
||||
${t('setup.recheck')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<h1 style="font-size:var(--font-size-xl);margin-bottom:var(--space-xs)">${t('setup.headerTitle')}</h1>
|
||||
<p style="color:var(--text-secondary);margin-bottom:var(--space-xl);line-height:1.6">
|
||||
${t('setup.headerDesc')}
|
||||
</p>
|
||||
|
||||
<div id="setup-steps"></div>
|
||||
|
||||
<div style="margin-top:var(--space-lg)">
|
||||
<button class="btn btn-secondary btn-sm" id="btn-recheck" style="min-width:120px">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="margin-right:4px"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>
|
||||
${t('setup.recheck')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
@@ -97,76 +161,100 @@ function renderSteps(page, { node, git, cliOk, config, version }) {
|
||||
const nodeOk = node.installed
|
||||
const gitOk = git?.installed || false
|
||||
const allOk = nodeOk && cliOk && config.installed
|
||||
const nodeStatusMeta = nodeOk
|
||||
? buildStatusMeta(node.version || t('setup.statusReady'), node.path)
|
||||
: t('setup.statusActionNeeded')
|
||||
const gitStatusMeta = gitOk
|
||||
? buildStatusMeta(git.version || t('setup.statusReady'), git.path)
|
||||
: t('setup.statusActionNeeded')
|
||||
const cliPrimaryMeta = cliOk
|
||||
? buildStatusMeta(version?.cli_source ? openclawSourceLabel(version.cli_source) : '', version?.current ? `v${version.current}` : t('setup.statusReady'))
|
||||
: ''
|
||||
const cliStatusMeta = cliOk
|
||||
? buildStatusMeta(cliPrimaryMeta, version?.cli_path)
|
||||
: t('setup.statusActionNeeded')
|
||||
const configStatusMeta = config.installed
|
||||
? (config.path || t('setup.statusReady'))
|
||||
: t('setup.statusActionNeeded')
|
||||
|
||||
let html = ''
|
||||
const statusCards = [
|
||||
renderStatusCard(t('setup.stepNode'), nodeOk, nodeStatusMeta),
|
||||
renderStatusCard(t('setup.stepGit'), gitOk, gitStatusMeta),
|
||||
renderStatusCard('OpenClaw CLI', cliOk, cliStatusMeta),
|
||||
renderStatusCard(t('setup.stepConfig'), config.installed, configStatusMeta),
|
||||
].join('')
|
||||
|
||||
let html = `
|
||||
<div class="setup-status-grid">${statusCards}</div>
|
||||
<div class="setup-main-grid">
|
||||
<div class="setup-column">
|
||||
`
|
||||
|
||||
// 第一步:Node.js
|
||||
html += `
|
||||
<div class="config-section" style="text-align:left">
|
||||
<div class="config-section-title" style="display:flex;align-items:center;gap:4px">
|
||||
${stepIcon(nodeOk)} ${t('setup.stepNode')}
|
||||
if (!nodeOk) {
|
||||
html += `
|
||||
<div class="config-section" style="text-align:left">
|
||||
<div class="config-section-title" style="display:flex;align-items:center;gap:4px">
|
||||
${stepIcon(nodeOk)} ${t('setup.stepNode')}
|
||||
</div>
|
||||
<p style="color:var(--text-secondary);font-size:var(--font-size-sm);margin-bottom:var(--space-sm)">
|
||||
${t('setup.stepNodeHint')}
|
||||
</p>
|
||||
<a class="btn btn-primary btn-sm" href="https://nodejs.org/" target="_blank" rel="noopener">${t('setup.downloadNode')}</a>
|
||||
<span class="form-hint" style="margin-left:8px">${t('setup.recheckAfterInstall')}</span>
|
||||
<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);color:var(--text-secondary);line-height:1.6">
|
||||
<strong>${t('setup.nodeInstalledButNotDetected')}</strong>
|
||||
${isMacPlatform()
|
||||
? `${t('setup.macNodeHint')}<br>
|
||||
<code style="background:var(--bg-secondary);padding:2px 6px;border-radius:3px;user-select:all">open /Applications/ClawPanel.app</code>`
|
||||
: `${t('setup.winNodeHint')}`
|
||||
}
|
||||
<div style="margin-top:8px;display:flex;gap:6px;align-items:center;flex-wrap:wrap">
|
||||
<button class="btn btn-secondary btn-sm" id="btn-scan-node" style="font-size:11px;padding:3px 10px">${icon('search', 12)} ${t('setup.scanNodeBtn')}</button>
|
||||
<span style="color:var(--text-tertiary)">${t('setup.orManualPath')}</span>
|
||||
</div>
|
||||
<div class="setup-input-row" style="margin-top:6px">
|
||||
<input id="input-node-path" type="text" placeholder="${isMacPlatform() ? '/usr/local/bin' : 'F:\\AI\\Node'}"
|
||||
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-check-path" style="font-size:11px;padding:3px 10px">${t('setup.checkPathBtn')}</button>
|
||||
</div>
|
||||
<div id="scan-result" style="margin-top:6px;display:none"></div>
|
||||
</div>
|
||||
</div>
|
||||
${nodeOk
|
||||
? `<p style="color:var(--success);font-size:var(--font-size-sm)">${t('setup.installed')} ${node.version || ''}</p>`
|
||||
: `<p style="color:var(--text-secondary);font-size:var(--font-size-sm);margin-bottom:var(--space-sm)">
|
||||
${t('setup.stepNodeHint')}
|
||||
</p>
|
||||
<a class="btn btn-primary btn-sm" href="https://nodejs.org/" target="_blank" rel="noopener">${t('setup.downloadNode')}</a>
|
||||
<span class="form-hint" style="margin-left:8px">${t('setup.recheckAfterInstall')}</span>
|
||||
<div style="margin-top:var(--space-sm);padding:8px 12px;background:var(--bg-tertiary);border-radius:var(--radius-sm);font-size:var(--font-size-xs);color:var(--text-secondary);line-height:1.6">
|
||||
<strong>${t('setup.nodeInstalledButNotDetected')}</strong>
|
||||
${isMacPlatform()
|
||||
? `${t('setup.macNodeHint')}<br>
|
||||
<code style="background:var(--bg-secondary);padding:2px 6px;border-radius:3px;user-select:all">open /Applications/ClawPanel.app</code>`
|
||||
: `${t('setup.winNodeHint')}`
|
||||
}
|
||||
<div style="margin-top:8px;display:flex;gap:6px;align-items:center;flex-wrap:wrap">
|
||||
<button class="btn btn-secondary btn-sm" id="btn-scan-node" style="font-size:11px;padding:3px 10px">${icon('search', 12)} ${t('setup.scanNodeBtn')}</button>
|
||||
<span style="color:var(--text-tertiary)">${t('setup.orManualPath')}</span>
|
||||
</div>
|
||||
<div style="margin-top:6px;display:flex;gap:6px">
|
||||
<input id="input-node-path" type="text" placeholder="${isMacPlatform() ? '/usr/local/bin' : 'F:\\\\AI\\\\Node'}"
|
||||
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-check-path" style="font-size:11px;padding:3px 10px">${t('setup.checkPathBtn')}</button>
|
||||
</div>
|
||||
<div id="scan-result" style="margin-top:6px;display:none"></div>
|
||||
</div>`
|
||||
}
|
||||
</div>
|
||||
`
|
||||
`
|
||||
}
|
||||
|
||||
// 第二步:Git
|
||||
html += `
|
||||
<div class="config-section" style="text-align:left;${nodeOk ? '' : 'opacity:0.4;pointer-events:none'}">
|
||||
<div class="config-section-title" style="display:flex;align-items:center;gap:4px">
|
||||
${stepIcon(gitOk)} ${t('setup.stepGit')}
|
||||
if (!gitOk) {
|
||||
html += `
|
||||
<div class="config-section" style="text-align:left;${nodeOk ? '' : 'opacity:0.65;pointer-events:none'}">
|
||||
<div class="config-section-title" style="display:flex;align-items:center;gap:4px">
|
||||
${stepIcon(gitOk)} ${t('setup.stepGit')}
|
||||
</div>
|
||||
<p style="color:var(--text-secondary);font-size:var(--font-size-sm);margin-bottom:var(--space-sm);line-height:1.5">
|
||||
${t('setup.stepGitHint')}
|
||||
</p>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<button class="btn btn-primary btn-sm" id="btn-auto-install-git">${t('setup.autoInstallGitBtn')}</button>
|
||||
<a class="btn btn-secondary btn-sm" href="https://git-scm.com/downloads" target="_blank" rel="noopener">${t('setup.manualDownload')}</a>
|
||||
</div>
|
||||
<div id="git-install-result" style="margin-top:var(--space-sm);display:none"></div>
|
||||
<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary);line-height:1.5">
|
||||
${t('setup.gitOptionalHint')}
|
||||
</div>
|
||||
</div>
|
||||
${gitOk
|
||||
? `<p style="color:var(--success);font-size:var(--font-size-sm)">${t('setup.installed')} ${git.version || ''}</p>
|
||||
<p style="font-size:var(--font-size-xs);color:var(--text-tertiary);margin-top:4px">✅ ${t('setup.gitHttpsConfigured')}</p>`
|
||||
: `<p style="color:var(--text-secondary);font-size:var(--font-size-sm);margin-bottom:var(--space-sm);line-height:1.5">
|
||||
${t('setup.stepGitHint')}
|
||||
</p>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<button class="btn btn-primary btn-sm" id="btn-auto-install-git">${t('setup.autoInstallGitBtn')}</button>
|
||||
<a class="btn btn-secondary btn-sm" href="https://git-scm.com/downloads" target="_blank" rel="noopener">${t('setup.manualDownload')}</a>
|
||||
</div>
|
||||
<div id="git-install-result" style="margin-top:var(--space-sm);display:none"></div>
|
||||
<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary);line-height:1.5">
|
||||
${t('setup.gitOptionalHint')}
|
||||
</div>`
|
||||
}
|
||||
</div>
|
||||
`
|
||||
`
|
||||
}
|
||||
|
||||
// 第三步:OpenClaw CLI
|
||||
html += `
|
||||
<div class="config-section" style="text-align:left;${nodeOk ? '' : 'opacity:0.4;pointer-events:none'}">
|
||||
<div class="config-section" style="text-align:left;${nodeOk ? '' : 'opacity:0.65;pointer-events:none'}">
|
||||
<div class="config-section-title" style="display:flex;align-items:center;gap:4px">
|
||||
${stepIcon(cliOk)} OpenClaw CLI
|
||||
</div>
|
||||
${cliOk
|
||||
? `<p style="color:var(--success);font-size:var(--font-size-sm)">${t('setup.cliAvailable')}</p>
|
||||
${renderDetectionHint(version?.cli_path, version?.cli_source ? openclawSourceLabel(version.cli_source) : '')}
|
||||
${version?.ahead_of_recommended && version?.recommended
|
||||
? `<div style="margin-top:8px;padding:8px 12px;background:var(--bg-tertiary);border-radius:var(--radius-sm);font-size:var(--font-size-xs);color:var(--warning,#f59e0b);line-height:1.6">
|
||||
${t('setup.cliAheadWarning', { current: version.current || '', recommended: version.recommended })}
|
||||
@@ -176,18 +264,26 @@ function renderSteps(page, { node, git, cliOk, config, version }) {
|
||||
}
|
||||
</div>
|
||||
`
|
||||
|
||||
html += `
|
||||
</div>
|
||||
<div class="setup-column">
|
||||
`
|
||||
|
||||
// 第四步:配置文件 + 自定义路径
|
||||
html += `
|
||||
<div class="config-section" style="text-align:left;${cliOk ? '' : 'opacity:0.4;pointer-events:none'}">
|
||||
<div class="config-section" style="text-align:left">
|
||||
<div class="config-section-title" style="display:flex;align-items:center;gap:4px">
|
||||
${stepIcon(config.installed)} ${t('setup.stepConfig')}
|
||||
</div>
|
||||
${config.installed
|
||||
? `<p style="color:var(--success);font-size:var(--font-size-sm)">${t('setup.configAt', { path: config.path || '' })}</p>`
|
||||
? `<p style="color:var(--success);font-size:var(--font-size-sm)">${t('setup.configAt', { path: config.path || '' })}</p>
|
||||
${renderDetectionHint(config.path)}`
|
||||
: `<p style="color:var(--text-secondary);font-size:var(--font-size-sm);margin-bottom:var(--space-sm)">
|
||||
${t('setup.configMissing')}
|
||||
</p>
|
||||
<button class="btn btn-primary btn-sm" id="btn-init-config">${t('setup.initConfigLabel')}</button>`
|
||||
${renderDetectionHint(config.path)}
|
||||
<button class="btn btn-primary btn-sm" id="btn-init-config" style="margin-top:10px">${t('setup.initConfigLabel')}</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">
|
||||
@@ -197,7 +293,8 @@ function renderSteps(page, { node, git, cliOk, config, version }) {
|
||||
<p style="color:var(--text-secondary);margin-bottom:8px">
|
||||
${t('setup.customDirHint')}
|
||||
</p>
|
||||
<div style="display:flex;gap:6px">
|
||||
<div class="setup-inline-note" style="margin-bottom:8px">${t('setup.customDirNotice')}</div>
|
||||
<div class="setup-input-row">
|
||||
<input id="input-openclaw-dir" type="text" placeholder="${t('setup.customDirPlaceholder')}"
|
||||
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">${t('setup.saveBtn')}</button>
|
||||
@@ -211,7 +308,7 @@ function renderSteps(page, { node, git, cliOk, config, version }) {
|
||||
|
||||
// AI 助手入口
|
||||
html += `
|
||||
<div class="config-section" style="text-align:left;margin-top:var(--space-md)">
|
||||
<div class="config-section" style="text-align:left">
|
||||
<div class="config-section-title" style="display:flex;align-items:center;gap:6px">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"/></svg>
|
||||
${t('setup.aiAssistant')}
|
||||
@@ -232,6 +329,15 @@ function renderSteps(page, { node, git, cliOk, config, version }) {
|
||||
</div>
|
||||
`
|
||||
|
||||
html += `
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
if (!cliOk) {
|
||||
html += renderEnvironmentHint()
|
||||
}
|
||||
|
||||
// 全部就绪 → 进入面板
|
||||
if (allOk) {
|
||||
html += `
|
||||
@@ -262,107 +368,144 @@ function renderSteps(page, { node, git, cliOk, config, version }) {
|
||||
}
|
||||
|
||||
function renderInstallSection() {
|
||||
return `
|
||||
<div class="setup-search-panel">
|
||||
<div style="font-weight:600;color:var(--text-primary);margin-bottom:4px">${t('setup.searchOpenclawTitle')}</div>
|
||||
<div style="color:var(--text-secondary)">${t('setup.searchOpenclawDesc')}</div>
|
||||
<div class="setup-input-row" style="margin-top:8px">
|
||||
<button class="btn btn-secondary btn-sm" id="btn-scan-openclaw" style="font-size:11px;padding:3px 10px">${icon('search', 12)} ${t('setup.searchOpenclawBtn')}</button>
|
||||
</div>
|
||||
<div class="setup-inline-note" style="margin-top:12px">${t('setup.searchOpenclawHint')}</div>
|
||||
<details style="margin-top:12px;cursor:pointer" id="advanced-openclaw-search-details">
|
||||
<summary style="font-size:var(--font-size-xs);color:var(--text-secondary);font-weight:600;user-select:none">
|
||||
${t('setup.searchOpenclawAdvancedTitle')}
|
||||
</summary>
|
||||
<div style="margin-top:var(--space-sm);display:flex;flex-direction:column;gap:12px">
|
||||
<div class="setup-inline-note">${t('setup.searchOpenclawAdvancedHint')}</div>
|
||||
<div>
|
||||
<label style="font-size:var(--font-size-xs);color:var(--text-secondary);display:block;margin-bottom:6px">${t('setup.searchOpenclawExtraPathsLabel')}</label>
|
||||
<textarea id="input-openclaw-search-paths" rows="3" placeholder="${t('setup.searchOpenclawExtraPathsPlaceholder')}"
|
||||
style="width:100%;padding:6px 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;resize:vertical;min-height:78px"></textarea>
|
||||
<div class="setup-input-row" style="margin-top:6px">
|
||||
<button class="btn btn-secondary btn-sm" id="btn-save-openclaw-search-paths" style="font-size:11px;padding:3px 10px">${t('setup.searchOpenclawExtraPathsSave')}</button>
|
||||
</div>
|
||||
<div class="setup-inline-note">${t('setup.searchOpenclawExtraPathsHint')}</div>
|
||||
<div id="openclaw-search-paths-result" style="margin-top:6px;display:none"></div>
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:var(--font-size-xs);color:var(--text-secondary);display:block;margin-bottom:6px">${t('setup.searchOpenclawManualLabel')}</label>
|
||||
<div class="setup-input-row">
|
||||
<input id="input-openclaw-cli-path" type="text" placeholder="${t('setup.searchOpenclawManualPlaceholder')}"
|
||||
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-check-openclaw-path" style="font-size:11px;padding:3px 10px">${t('setup.searchOpenclawManualBtn')}</button>
|
||||
</div>
|
||||
<div class="setup-inline-note">${t('setup.searchOpenclawManualHint')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<div id="scan-openclaw-result" style="margin-top:8px;display:none"></div>
|
||||
</div>
|
||||
<div class="setup-install-panel">
|
||||
<div style="font-weight:600;color:var(--text-primary);margin-bottom:6px">${t('setup.installOpenclaw')}</div>
|
||||
<p style="color:var(--text-secondary);font-size:var(--font-size-sm);margin-bottom:var(--space-sm)">
|
||||
${t('setup.installHint')}
|
||||
</p>
|
||||
<p style="color:var(--text-tertiary);font-size:var(--font-size-xs);line-height:1.6;margin:-4px 0 var(--space-sm)">
|
||||
${t('setup.installHint2')}
|
||||
</p>
|
||||
<div style="display:flex;gap:var(--space-sm);margin-bottom:var(--space-sm)">
|
||||
<label class="setup-source-option" style="flex:1;cursor:pointer">
|
||||
<input type="radio" name="install-source" value="chinese" checked style="margin-right:6px">
|
||||
<div>
|
||||
<div style="font-weight:600;font-size:var(--font-size-sm)">${t('setup.sourceChineseLabel')}</div>
|
||||
<div style="font-size:var(--font-size-xs);color:var(--text-tertiary)">@qingchencloud/openclaw-zh</div>
|
||||
</div>
|
||||
</label>
|
||||
<label class="setup-source-option" style="flex:1;cursor:pointer">
|
||||
<input type="radio" name="install-source" value="official" style="margin-right:6px">
|
||||
<div>
|
||||
<div style="font-weight:600;font-size:var(--font-size-sm)">${t('setup.sourceOfficialLabel')}</div>
|
||||
<div style="font-size:var(--font-size-xs);color:var(--text-tertiary)">openclaw</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div style="margin-bottom:var(--space-sm)" id="install-method-section">
|
||||
<label style="font-size:var(--font-size-xs);color:var(--text-tertiary);display:block;margin-bottom:4px">${t('setup.installMethodLabel')}</label>
|
||||
<select id="install-method" style="width:100%;padding:6px 8px;border-radius:var(--radius-sm);border:1px solid var(--border-primary);background:var(--bg-secondary);color:var(--text-primary);font-size:var(--font-size-sm)">
|
||||
<option value="auto">${t('setup.methodAuto')}</option>
|
||||
<option value="standalone-r2">${t('setup.methodStandaloneR2')}</option>
|
||||
<option value="standalone-github">${t('setup.methodStandaloneGithub')}</option>
|
||||
<option value="npm">${t('setup.methodNpm')}</option>
|
||||
</select>
|
||||
<div id="method-hint" style="font-size:var(--font-size-xs);color:var(--text-tertiary);margin-top:4px;line-height:1.5"></div>
|
||||
</div>
|
||||
<div style="margin-bottom:var(--space-sm)" id="registry-section">
|
||||
<label style="font-size:var(--font-size-xs);color:var(--text-tertiary);display:block;margin-bottom:4px">${t('setup.registryLabel')}</label>
|
||||
<select id="registry-select" style="width:100%;padding:6px 8px;border-radius:var(--radius-sm);border:1px solid var(--border-primary);background:var(--bg-secondary);color:var(--text-primary);font-size:var(--font-size-sm)">
|
||||
<option value="https://registry.npmmirror.com">${t('setup.registryTaobao')}</option>
|
||||
<option value="https://registry.npmjs.org">${t('setup.registryNpm')}</option>
|
||||
<option value="https://repo.huaweicloud.com/repository/npm/">${t('setup.registryHuawei')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" id="btn-install">${t('setup.installBtn')}</button>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
function renderEnvironmentHint() {
|
||||
const isWin = navigator.platform?.startsWith('Win') || navigator.userAgent?.includes('Windows')
|
||||
const isMac = navigator.platform?.startsWith('Mac') || navigator.userAgent?.includes('Macintosh')
|
||||
const isDesktop = !!window.__TAURI_INTERNALS__
|
||||
|
||||
let envHint = ''
|
||||
if (isDesktop) {
|
||||
envHint = `
|
||||
<div style="margin-top:var(--space-sm);padding:10px 12px;background:var(--bg-tertiary);border-radius:var(--radius-sm);border-left:3px solid var(--warning);font-size:var(--font-size-xs);color:var(--text-secondary);line-height:1.7">
|
||||
<strong style="color:var(--text-primary)">${t('setup.envHintTitle')}</strong>
|
||||
<p style="margin:6px 0 2px">${t('setup.envHintDesc')}</p>
|
||||
<ul style="margin:4px 0 8px 16px;padding:0">
|
||||
${isWin ? `
|
||||
<li><strong>${t('setup.envHintWsl')}</strong> — ${t('setup.envHintWslDesc')}</li>
|
||||
<li><strong>${t('setup.envHintDocker')}</strong> — ${t('setup.envHintDockerDesc')}</li>
|
||||
` : ''}
|
||||
${isMac ? `
|
||||
<li><strong>${t('setup.envHintDocker')}</strong> — ${t('setup.envHintDockerDesc')}</li>
|
||||
<li><strong>${t('setup.envHintRemote')}</strong> — ${t('setup.envHintRemoteDesc')}</li>
|
||||
` : ''}
|
||||
${!isWin && !isMac ? `
|
||||
<li><strong>${t('setup.envHintDocker')}</strong> — ${t('setup.envHintDockerDesc')}</li>
|
||||
` : ''}
|
||||
</ul>
|
||||
<details style="cursor:pointer">
|
||||
<summary style="font-weight:600;color:var(--primary);margin-bottom:6px">
|
||||
${t('setup.envHintInstallManage')}
|
||||
</summary>
|
||||
<div style="margin-top:8px">
|
||||
${isWin ? `
|
||||
<div style="margin-bottom:10px">
|
||||
<div style="font-weight:600;margin-bottom:4px">${t('setup.wslWebHint')}</div>
|
||||
<div style="margin-bottom:2px;opacity:0.8">${t('setup.wslWebDesc')}</div>
|
||||
<code style="display:block;background:var(--bg-secondary);padding:6px 10px;border-radius:4px;user-select:all;word-break:break-all">curl -fsSL https://raw.githubusercontent.com/qingchencloud/clawpanel/main/deploy.sh | bash</code>
|
||||
<div style="margin-top:4px;opacity:0.7">${t('setup.domesticMirror')}<code style="background:var(--bg-secondary);padding:2px 4px;border-radius:3px;user-select:all">curl -fsSL https://gitee.com/QtCodeCreators/clawpanel/raw/main/deploy.sh | bash</code></div>
|
||||
<div style="margin-top:4px;opacity:0.7">${t('setup.wslWebPostDeploy')}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
<div style="margin-bottom:10px">
|
||||
<div style="font-weight:600;margin-bottom:4px">${t('setup.dockerHint')}</div>
|
||||
<div style="margin-bottom:2px;opacity:0.8">${t('setup.dockerDesc')}</div>
|
||||
<code style="display:block;background:var(--bg-secondary);padding:6px 10px;border-radius:4px;user-select:all;word-break:break-all;margin-bottom:4px">npm i -g @qingchencloud/openclaw-zh</code>
|
||||
<code style="display:block;background:var(--bg-secondary);padding:6px 10px;border-radius:4px;user-select:all;word-break:break-all">curl -fsSL https://raw.githubusercontent.com/qingchencloud/clawpanel/main/deploy.sh | bash</code>
|
||||
<div style="margin-top:4px;opacity:0.7">${t('setup.domesticMirrorShort')}<code style="background:var(--bg-secondary);padding:2px 4px;border-radius:3px;user-select:all">curl -fsSL https://gitee.com/QtCodeCreators/clawpanel/raw/main/deploy.sh | bash</code></div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-weight:600;margin-bottom:4px">${t('setup.remoteHint')}</div>
|
||||
<div style="margin-bottom:2px;opacity:0.8">${t('setup.remoteDesc')}</div>
|
||||
<code style="display:block;background:var(--bg-secondary);padding:6px 10px;border-radius:4px;user-select:all;word-break:break-all">curl -fsSL https://raw.githubusercontent.com/qingchencloud/clawpanel/main/deploy.sh | bash</code>
|
||||
<div style="margin-top:4px;opacity:0.7">${t('setup.domesticMirrorShort')}<code style="background:var(--bg-secondary);padding:2px 4px;border-radius:3px;user-select:all">curl -fsSL https://gitee.com/QtCodeCreators/clawpanel/raw/main/deploy.sh | bash</code></div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<div style="margin-top:6px;opacity:0.7">
|
||||
${t('setup.envHintLocalReinstall')}
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
if (!isDesktop) return ''
|
||||
|
||||
return `
|
||||
<p style="color:var(--text-secondary);font-size:var(--font-size-sm);margin-bottom:var(--space-sm)">
|
||||
${t('setup.installHint')}
|
||||
</p>
|
||||
<p style="color:var(--text-tertiary);font-size:var(--font-size-xs);line-height:1.6;margin:-4px 0 var(--space-sm)">
|
||||
${t('setup.installHint2')}
|
||||
</p>
|
||||
<div style="display:flex;gap:var(--space-sm);margin-bottom:var(--space-sm)">
|
||||
<label class="setup-source-option" style="flex:1;cursor:pointer">
|
||||
<input type="radio" name="install-source" value="chinese" checked style="margin-right:6px">
|
||||
<div>
|
||||
<div style="font-weight:600;font-size:var(--font-size-sm)">${t('setup.sourceChineseLabel')}</div>
|
||||
<div style="font-size:var(--font-size-xs);color:var(--text-tertiary)">@qingchencloud/openclaw-zh</div>
|
||||
<div class="config-section" style="text-align:left;margin-top:var(--space-md)">
|
||||
<div class="config-section-title">${t('setup.envHintTitle')}</div>
|
||||
<p style="color:var(--text-secondary);font-size:var(--font-size-sm);line-height:1.6;margin-bottom:var(--space-sm)">
|
||||
${t('setup.envHintDesc')}
|
||||
</p>
|
||||
<details class="setup-help-details">
|
||||
<summary>${t('setup.envHintInstallManage')}</summary>
|
||||
<div class="setup-help-content">
|
||||
<ul style="margin:0 0 12px 18px;padding:0;line-height:1.8;color:var(--text-secondary)">
|
||||
${isWin ? `
|
||||
<li><strong>${t('setup.envHintWsl')}</strong> — ${t('setup.envHintWslDesc')}</li>
|
||||
<li><strong>${t('setup.envHintDocker')}</strong> — ${t('setup.envHintDockerDesc')}</li>
|
||||
` : ''}
|
||||
${isMac ? `
|
||||
<li><strong>${t('setup.envHintDocker')}</strong> — ${t('setup.envHintDockerDesc')}</li>
|
||||
<li><strong>${t('setup.envHintRemote')}</strong> — ${t('setup.envHintRemoteDesc')}</li>
|
||||
` : ''}
|
||||
${!isWin && !isMac ? `
|
||||
<li><strong>${t('setup.envHintDocker')}</strong> — ${t('setup.envHintDockerDesc')}</li>
|
||||
` : ''}
|
||||
</ul>
|
||||
${isWin ? `
|
||||
<div class="setup-help-block">
|
||||
<div class="setup-help-label">${t('setup.wslWebHint')}</div>
|
||||
<div class="setup-help-copy">${t('setup.wslWebDesc')}</div>
|
||||
<code class="setup-help-code">curl -fsSL https://raw.githubusercontent.com/qingchencloud/clawpanel/main/deploy.sh | bash</code>
|
||||
<div class="setup-help-copy">${t('setup.domesticMirror')} <code>curl -fsSL https://gitee.com/QtCodeCreators/clawpanel/raw/main/deploy.sh | bash</code></div>
|
||||
<div class="setup-help-copy">${t('setup.wslWebPostDeploy')}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="setup-help-block">
|
||||
<div class="setup-help-label">${t('setup.dockerHint')}</div>
|
||||
<div class="setup-help-copy">${t('setup.dockerDesc')}</div>
|
||||
<code class="setup-help-code">npm i -g @qingchencloud/openclaw-zh</code>
|
||||
<code class="setup-help-code">curl -fsSL https://raw.githubusercontent.com/qingchencloud/clawpanel/main/deploy.sh | bash</code>
|
||||
<div class="setup-help-copy">${t('setup.domesticMirrorShort')} <code>curl -fsSL https://gitee.com/QtCodeCreators/clawpanel/raw/main/deploy.sh | bash</code></div>
|
||||
</div>
|
||||
<div class="setup-help-block">
|
||||
<div class="setup-help-label">${t('setup.remoteHint')}</div>
|
||||
<div class="setup-help-copy">${t('setup.remoteDesc')}</div>
|
||||
<code class="setup-help-code">curl -fsSL https://raw.githubusercontent.com/qingchencloud/clawpanel/main/deploy.sh | bash</code>
|
||||
<div class="setup-help-copy">${t('setup.domesticMirrorShort')} <code>curl -fsSL https://gitee.com/QtCodeCreators/clawpanel/raw/main/deploy.sh | bash</code></div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<label class="setup-source-option" style="flex:1;cursor:pointer">
|
||||
<input type="radio" name="install-source" value="official" style="margin-right:6px">
|
||||
<div>
|
||||
<div style="font-weight:600;font-size:var(--font-size-sm)">${t('setup.sourceOfficialLabel')}</div>
|
||||
<div style="font-size:var(--font-size-xs);color:var(--text-tertiary)">openclaw</div>
|
||||
</div>
|
||||
</label>
|
||||
</details>
|
||||
<div class="setup-inline-note">${t('setup.envHintLocalReinstall')}</div>
|
||||
</div>
|
||||
<div style="margin-bottom:var(--space-sm)" id="install-method-section">
|
||||
<label style="font-size:var(--font-size-xs);color:var(--text-tertiary);display:block;margin-bottom:4px">${t('setup.installMethodLabel')}</label>
|
||||
<select id="install-method" style="width:100%;padding:6px 8px;border-radius:var(--radius-sm);border:1px solid var(--border-primary);background:var(--bg-secondary);color:var(--text-primary);font-size:var(--font-size-sm)">
|
||||
<option value="auto">${t('setup.methodAuto')}</option>
|
||||
<option value="standalone-r2">${t('setup.methodStandaloneR2')}</option>
|
||||
<option value="standalone-github">${t('setup.methodStandaloneGithub')}</option>
|
||||
<option value="npm">${t('setup.methodNpm')}</option>
|
||||
</select>
|
||||
<div id="method-hint" style="font-size:var(--font-size-xs);color:var(--text-tertiary);margin-top:4px;line-height:1.5"></div>
|
||||
</div>
|
||||
<div style="margin-bottom:var(--space-sm)" id="registry-section">
|
||||
<label style="font-size:var(--font-size-xs);color:var(--text-tertiary);display:block;margin-bottom:4px">${t('setup.registryLabel')}</label>
|
||||
<select id="registry-select" style="width:100%;padding:6px 8px;border-radius:var(--radius-sm);border:1px solid var(--border-primary);background:var(--bg-secondary);color:var(--text-primary);font-size:var(--font-size-sm)">
|
||||
<option value="https://registry.npmmirror.com">${t('setup.registryTaobao')}</option>
|
||||
<option value="https://registry.npmjs.org">${t('setup.registryNpm')}</option>
|
||||
<option value="https://repo.huaweicloud.com/repository/npm/">${t('setup.registryHuawei')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" id="btn-install">${t('setup.installBtn')}</button>
|
||||
${envHint}
|
||||
`
|
||||
}
|
||||
|
||||
@@ -462,6 +605,13 @@ function bindEvents(page, nodeOk, detectState) {
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
const searchPathsInput = page.querySelector('#input-openclaw-search-paths')
|
||||
api.readPanelConfig().then(cfg => {
|
||||
if (searchPathsInput) {
|
||||
const values = Array.isArray(cfg?.openclawSearchPaths) ? cfg.openclawSearchPaths : []
|
||||
searchPathsInput.value = values.join('\n')
|
||||
}
|
||||
}).catch(() => {})
|
||||
|
||||
page.querySelector('#btn-save-openclaw-dir')?.addEventListener('click', async () => {
|
||||
const value = dirInput?.value?.trim()
|
||||
@@ -485,6 +635,39 @@ function bindEvents(page, nodeOk, detectState) {
|
||||
}
|
||||
})
|
||||
|
||||
page.querySelector('#btn-save-openclaw-search-paths')?.addEventListener('click', async () => {
|
||||
const btn = page.querySelector('#btn-save-openclaw-search-paths')
|
||||
const resultEl = page.querySelector('#openclaw-search-paths-result')
|
||||
const paths = parseOpenclawSearchPaths(searchPathsInput?.value || '')
|
||||
btn.disabled = true
|
||||
if (resultEl) {
|
||||
resultEl.style.display = 'block'
|
||||
resultEl.innerHTML = `<span style="color:var(--text-tertiary)">${t('setup.saving')}</span>`
|
||||
}
|
||||
try {
|
||||
const cfg = await api.readPanelConfig()
|
||||
if (paths.length > 0) {
|
||||
cfg.openclawSearchPaths = paths
|
||||
} else {
|
||||
delete cfg.openclawSearchPaths
|
||||
}
|
||||
await api.writePanelConfig(cfg)
|
||||
invalidate()
|
||||
if (resultEl) {
|
||||
resultEl.innerHTML = `<span style="color:var(--success)">✓ ${paths.length > 0 ? t('setup.searchOpenclawExtraPathsSaved') : t('setup.searchOpenclawExtraPathsCleared')}</span>`
|
||||
}
|
||||
toast(paths.length > 0 ? t('setup.searchOpenclawExtraPathsSaved') : t('setup.searchOpenclawExtraPathsCleared'), 'success')
|
||||
setTimeout(() => runDetect(page), 300)
|
||||
} catch (e) {
|
||||
if (resultEl) {
|
||||
resultEl.innerHTML = `<span style="color:var(--error)">${t('setup.saveFailed', { err: e })}</span>`
|
||||
}
|
||||
toast(t('setup.saveFailed', { err: 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
|
||||
@@ -584,6 +767,105 @@ function bindEvents(page, nodeOk, detectState) {
|
||||
}
|
||||
})
|
||||
|
||||
const bindOpenclawCliPath = async (cliPath, btnEl, resultEl, successText = t('setup.searchOpenclawSelectSuccess'), originalText = btnEl?.textContent) => {
|
||||
if (!cliPath) return false
|
||||
if (btnEl) {
|
||||
btnEl.disabled = true
|
||||
btnEl.textContent = t('setup.searchOpenclawUsing')
|
||||
}
|
||||
try {
|
||||
const cfg = await api.readPanelConfig()
|
||||
cfg.openclawCliPath = cliPath
|
||||
await api.writePanelConfig(cfg)
|
||||
await api.invalidatePathCache().catch(() => {})
|
||||
if (resultEl) {
|
||||
resultEl.style.display = 'block'
|
||||
resultEl.innerHTML = `<span style="color:var(--success)">✓ ${successText}</span>`
|
||||
}
|
||||
toast(successText, 'success')
|
||||
setTimeout(() => runDetect(page), 300)
|
||||
return true
|
||||
} catch (e) {
|
||||
if (btnEl) {
|
||||
btnEl.disabled = false
|
||||
btnEl.textContent = originalText || t('setup.scanUseBtn')
|
||||
}
|
||||
if (resultEl) {
|
||||
resultEl.style.display = 'block'
|
||||
resultEl.innerHTML = `<span style="color:var(--danger)">${t('setup.searchOpenclawSelectFailed', { err: e?.message || e })}</span>`
|
||||
}
|
||||
toast(t('setup.searchOpenclawSelectFailed', { err: e?.message || e }), 'error')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
page.querySelector('#btn-check-openclaw-path')?.addEventListener('click', async () => {
|
||||
const input = page.querySelector('#input-openclaw-cli-path')
|
||||
const resultEl = page.querySelector('#scan-openclaw-result')
|
||||
const btn = page.querySelector('#btn-check-openclaw-path')
|
||||
const cliPath = input?.value?.trim()
|
||||
if (!cliPath) { toast(t('setup.enterPath'), 'warning'); return }
|
||||
btn.disabled = true
|
||||
btn.textContent = t('setup.detecting2')
|
||||
resultEl.style.display = 'block'
|
||||
resultEl.innerHTML = `<span style="color:var(--text-tertiary)">${t('setup.detecting2')}</span>`
|
||||
try {
|
||||
const result = await api.checkOpenclawAtPath(cliPath)
|
||||
if (result?.installed && result?.path) {
|
||||
await bindOpenclawCliPath(result.path, btn, resultEl, t('setup.searchOpenclawManualSaved'), t('setup.searchOpenclawManualBtn'))
|
||||
} else {
|
||||
resultEl.innerHTML = `<span style="color:var(--warning)">${t('setup.searchOpenclawManualNotFound')}</span>`
|
||||
btn.disabled = false
|
||||
btn.textContent = t('setup.searchOpenclawManualBtn')
|
||||
}
|
||||
} catch (e) {
|
||||
resultEl.innerHTML = `<span style="color:var(--danger)">${t('setup.scanFailed', { err: e })}</span>`
|
||||
btn.disabled = false
|
||||
btn.textContent = t('setup.searchOpenclawManualBtn')
|
||||
}
|
||||
})
|
||||
|
||||
page.querySelector('#btn-scan-openclaw')?.addEventListener('click', async () => {
|
||||
const btn = page.querySelector('#btn-scan-openclaw')
|
||||
const resultEl = page.querySelector('#scan-openclaw-result')
|
||||
if (!btn || !resultEl) return
|
||||
btn.disabled = true
|
||||
btn.innerHTML = `${icon('search', 12)} ${t('setup.searchOpenclawScanning')}`
|
||||
resultEl.style.display = 'block'
|
||||
resultEl.innerHTML = `<span style="color:var(--text-tertiary)">${t('setup.searchOpenclawScanning')}</span>`
|
||||
try {
|
||||
const results = await api.scanOpenclawPaths()
|
||||
if (!Array.isArray(results) || results.length === 0) {
|
||||
resultEl.innerHTML = `<span style="color:var(--warning)">${t('setup.searchOpenclawEmpty')}</span>`
|
||||
return
|
||||
}
|
||||
resultEl.innerHTML = `${results.map((item, index) => `
|
||||
<div style="display:flex;align-items:center;gap:6px;margin-top:4px">
|
||||
<span style="color:var(--success)">✓</span>
|
||||
<div style="flex:1;min-width:0">
|
||||
<code style="display:block;background:var(--bg-secondary);padding:2px 6px;border-radius:3px;font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${escapeHtml(item.path)}">${escapeHtml(item.path)}</code>
|
||||
<span style="font-size:11px;color:var(--text-tertiary)">${escapeHtml(openclawSourceLabel(item.source))}${item.version ? ` · v${escapeHtml(item.version)}` : ''}</span>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm btn-use-openclaw-path" data-index="${index}" style="font-size:10px;padding:2px 8px">${t('setup.scanUseBtn')}</button>
|
||||
</div>
|
||||
`).join('')}
|
||||
<div style="margin-top:6px;font-size:11px;color:var(--text-tertiary);line-height:1.6">${t('setup.searchOpenclawHint')}</div>`
|
||||
|
||||
resultEl.querySelectorAll('.btn-use-openclaw-path').forEach(btnEl => {
|
||||
btnEl.addEventListener('click', async () => {
|
||||
const item = results[Number(btnEl.dataset.index)]
|
||||
if (!item?.path) return
|
||||
await bindOpenclawCliPath(item.path, btnEl, resultEl)
|
||||
})
|
||||
})
|
||||
} catch (e) {
|
||||
resultEl.innerHTML = `<span style="color:var(--danger)">${t('setup.scanFailed', { err: e })}</span>`
|
||||
} finally {
|
||||
btn.disabled = false
|
||||
btn.innerHTML = `${icon('search', 12)} ${t('setup.searchOpenclawBtn')}`
|
||||
}
|
||||
})
|
||||
|
||||
// 安装方式联动:源切换时更新方式选项可见性
|
||||
const methodSection = page.querySelector('#install-method-section')
|
||||
const registrySection = page.querySelector('#registry-section')
|
||||
|
||||
Reference in New Issue
Block a user