v0.8.0: Ollama兼容、Git自动安装、Gitee镜像、会话重命名、消息渠道Agent绑定、仪表盘重设计、环境检测实时生效、#44修复

This commit is contained in:
晴天
2026-03-12 02:17:47 +08:00
parent 62c8fc9355
commit 3e24ceaa4d
32 changed files with 2972 additions and 487 deletions

View File

@@ -402,19 +402,14 @@ async function checkHotUpdate(cards, panelVersion) {
}
})
} else if (!info.compatible) {
meta.innerHTML = '<span style="color:var(--text-tertiary)">需要更新完整安装包</span> <a class="btn btn-secondary btn-sm" href="https://github.com/qingchencloud/clawpanel/releases" target="_blank" rel="noopener" style="padding:2px 8px;font-size:var(--font-size-xs)">下载</a>'
meta.innerHTML = '<span style="color:var(--text-tertiary)">需要更新完整安装包</span> <a class="btn btn-primary btn-sm" href="https://claw.qt.cool" target="_blank" rel="noopener" style="padding:2px 8px;font-size:var(--font-size-xs)">前往官网下载</a> <a class="btn btn-secondary btn-sm" href="https://github.com/qingchencloud/clawpanel/releases" target="_blank" rel="noopener" style="padding:2px 8px;font-size:var(--font-size-xs)">GitHub</a>'
} else {
meta.innerHTML = '<span style="color:var(--success)">已是最新</span>'
}
} catch (err) {
const meta = el()
if (!meta) return
const msg = String(err?.message || err || '')
if (msg.includes('403') || msg.includes('404') || msg.includes('rate limit')) {
meta.innerHTML = '<span style="color:var(--text-tertiary)">暂无法检查更新</span>'
} else {
meta.innerHTML = '<span style="color:var(--text-tertiary)">检查更新失败</span>'
}
meta.innerHTML = `<span style="color:var(--text-tertiary)">暂无法检查更新</span> <a class="btn btn-secondary btn-sm" href="https://claw.qt.cool" target="_blank" rel="noopener" style="padding:2px 8px;font-size:var(--font-size-xs)">前往官网下载</a>`
}
}
@@ -482,6 +477,7 @@ const PROJECTS = [
name: 'ClawPanel',
desc: 'OpenClaw 可视化管理面板Tauri v2 桌面应用',
url: 'https://github.com/qingchencloud/clawpanel',
gitee: 'https://gitee.com/QtCodeCreators/clawpanel',
},
{
name: 'ClawApp',
@@ -507,6 +503,7 @@ function renderProjects(page) {
</div>
<div class="service-actions">
<a class="btn btn-secondary btn-sm" href="${p.url}" target="_blank" rel="noopener">GitHub</a>
${p.gitee ? `<a class="btn btn-secondary btn-sm" href="${p.gitee}" target="_blank" rel="noopener">国内镜像</a>` : ''}
</div>
</div>
`).join('')
@@ -531,6 +528,9 @@ function renderContribute(page) {
<a class="btn btn-secondary btn-sm" href="https://github.com/qingchencloud/clawpanel/blob/main/CONTRIBUTING.md" target="_blank" rel="noopener">贡献指南</a>
<a class="btn btn-secondary btn-sm" href="https://github.com/qingchencloud/clawpanel/issues" target="_blank" rel="noopener">查看 Issues</a>
</div>
<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary)">
国内镜像:<a href="https://gitee.com/QtCodeCreators/clawpanel" target="_blank" rel="noopener" style="color:var(--accent)">Gitee</a>(无法访问 GitHub 时可用)
</div>
`
}

View File

@@ -14,7 +14,7 @@ export async function render() {
<div class="page-header">
<div>
<h1 class="page-title">Agent 管理</h1>
<p class="page-desc">创建和管理 OpenClaw 智能体,配置身份、模型和工作区</p>
<p class="page-desc">创建和管理 OpenClaw Agent,配置身份、模型和工作区</p>
</div>
<div class="page-actions">
<button class="btn btn-primary" id="btn-add-agent">+ 新建 Agent</button>

View File

@@ -22,16 +22,7 @@ const QTCOOL = {
defaultKey: 'sk-0JDu7hyc51ZKD4iNebpFu07EUEhXmVVc',
site: 'https://gpt.qt.cool/',
usageUrl: 'https://gpt.qt.cool/user?key=',
models: [
{ id: 'gpt-5.2-codex', name: 'GPT-5.2 Codex', hot: true },
{ id: 'gpt-5.2', name: 'GPT-5.2' },
{ id: 'gpt-5.1-codex-max', name: 'GPT-5.1 Codex Max' },
{ id: 'gpt-5.1-codex-mini', name: 'GPT-5.1 Codex Mini' },
{ id: 'gpt-5.1-codex', name: 'GPT-5.1 Codex' },
{ id: 'gpt-5.1', name: 'GPT-5.1' },
{ id: 'gpt-5-codex', name: 'GPT-5 Codex' },
{ id: 'gpt-5', name: 'GPT-5' },
]
models: [] // 始终从 API 动态获取最新模型列表
}
// ── 图片文件存储(通过 Tauri 后端持久化到 ~/.openclaw/clawpanel/images/)──
@@ -64,11 +55,48 @@ const DEFAULT_MODE = 'execute'
// ── API 类型 ──
const API_TYPES = [
{ value: 'openai', label: 'OpenAI 兼容 (最常用)' },
{ value: 'anthropic', label: 'Anthropic 原生' },
{ value: 'openai-completions', label: 'OpenAI 兼容 (最常用)' },
{ value: 'anthropic-messages', label: 'Anthropic 原生' },
{ value: 'google-gemini', label: 'Google Gemini' },
]
function normalizeApiType(raw) {
const type = (raw || '').trim()
if (type === 'anthropic' || type === 'anthropic-messages') return 'anthropic-messages'
if (type === 'google-gemini') return 'google-gemini'
if (type === 'openai' || type === 'openai-completions' || type === 'openai-responses') return 'openai-completions'
return 'openai-completions'
}
function requiresApiKey(apiType) {
const type = normalizeApiType(apiType)
return type === 'anthropic-messages' || type === 'google-gemini'
}
function apiHintText(apiType) {
return {
'openai-completions': '自动兼容 Chat Completions 和 Responses APIOllama 可留空 API Key',
'anthropic-messages': '使用 Anthropic Messages API/v1/messages',
'google-gemini': '使用 Gemini generateContent API',
}[normalizeApiType(apiType)] || '自动兼容 Chat Completions 和 Responses APIOllama 可留空 API Key'
}
function apiBasePlaceholder(apiType) {
return {
'openai-completions': 'https://api.openai.com/v1 或 http://127.0.0.1:11434',
'anthropic-messages': 'https://api.anthropic.com',
'google-gemini': 'https://generativelanguage.googleapis.com/v1beta',
}[normalizeApiType(apiType)] || 'https://api.openai.com/v1'
}
function apiKeyPlaceholder(apiType) {
return {
'openai-completions': 'sk-...Ollama 可留空)',
'anthropic-messages': 'sk-ant-...',
'google-gemini': 'AIza...',
}[normalizeApiType(apiType)] || 'sk-...'
}
// ── 系统提示词 ──
const DEFAULT_NAME = '晴辰助手'
const DEFAULT_PERSONALITY = '专业、友善、简洁。善于分析问题,给出可操作的解决方案。'
@@ -1315,7 +1343,7 @@ function loadConfig() {
if (!_config.assistantPersonality) _config.assistantPersonality = DEFAULT_PERSONALITY
if (!_config.tools) _config.tools = { terminal: false, fileOps: false, webSearch: false }
if (!_config.mode) _config.mode = DEFAULT_MODE
if (!_config.apiType) _config.apiType = 'openai'
_config.apiType = normalizeApiType(_config.apiType)
if (_config.autoRounds === undefined) _config.autoRounds = 8
if (!Array.isArray(_config.knowledgeFiles)) _config.knowledgeFiles = []
return _config
@@ -1397,13 +1425,18 @@ function autoTitle(session) {
// ── AI API 调用(自动兼容 Chat Completions + Responses API──
function cleanBaseUrl(raw, apiType) {
let base = raw.replace(/\/+$/, '')
let base = (raw || '').replace(/\/+$/, '')
base = base.replace(/\/api\/chat\/?$/, '')
base = base.replace(/\/api\/generate\/?$/, '')
base = base.replace(/\/api\/tags\/?$/, '')
base = base.replace(/\/api\/?$/, '')
base = base.replace(/\/chat\/completions\/?$/, '')
base = base.replace(/\/completions\/?$/, '')
base = base.replace(/\/responses\/?$/, '')
base = base.replace(/\/messages\/?$/, '')
const type = apiType || _config.apiType || 'openai'
if (type === 'anthropic') {
base = base.replace(/\/models\/?$/, '')
const type = normalizeApiType(apiType || _config.apiType)
if (type === 'anthropic-messages') {
// Anthropic: https://api.anthropic.com/v1
if (!base.endsWith('/v1')) base += '/v1'
return base
@@ -1412,24 +1445,30 @@ function cleanBaseUrl(raw, apiType) {
// Gemini: https://generativelanguage.googleapis.com/v1beta
return base
}
if (!base.endsWith('/v1')) base = base.replace(/\/v1\/.*$/, '/v1')
if (/:(11434)$/i.test(base)) return `${base}/v1`
if (!base.endsWith('/v1')) {
if (/\/v1\/.+/.test(base)) base = base.replace(/\/v1\/.*$/, '/v1')
else base += '/v1'
}
return base
}
function authHeaders(apiType, apiKey) {
const type = apiType || _config.apiType || 'openai'
const type = normalizeApiType(apiType || _config.apiType)
const key = apiKey || _config.apiKey || ''
if (type === 'anthropic') {
return {
if (type === 'anthropic-messages') {
const headers = {
'Content-Type': 'application/json',
'x-api-key': key,
'anthropic-version': '2023-06-01',
}
if (key) headers['x-api-key'] = key
return headers
}
return {
const headers = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${key}`,
}
if (key) headers['Authorization'] = `Bearer ${key}`
return headers
}
// 超时常量
@@ -1438,11 +1477,12 @@ const TIMEOUT_CHUNK = 30_000 // 流式 chunk 间隔超时 30 秒
const TIMEOUT_CONNECT = 30_000 // 连接超时 30 秒
async function callAI(messages, onChunk) {
if (!_config.baseUrl || !_config.apiKey || !_config.model) {
const apiType = normalizeApiType(_config.apiType)
if (!_config.baseUrl || !_config.model || (requiresApiKey(apiType) && !_config.apiKey)) {
throw new Error('请先配置 AI 模型(点击右上角设置按钮)')
}
const base = cleanBaseUrl(_config.baseUrl)
const base = cleanBaseUrl(_config.baseUrl, apiType)
_abortController = new AbortController()
const allMessages = [{ role: 'system', content: buildSystemPrompt() }, ...messages]
@@ -1454,9 +1494,7 @@ async function callAI(messages, onChunk) {
}, TIMEOUT_TOTAL)
try {
const apiType = _config.apiType || 'openai'
if (apiType === 'anthropic') {
if (apiType === 'anthropic-messages') {
await callAnthropicMessages(base, allMessages, onChunk)
return
}
@@ -2015,12 +2053,12 @@ async function executeToolWithSafety(toolName, args, tcForConfirm) {
// 带工具调用的 AI 请求(非流式,用于 tool_calls 检测循环)
async function callAIWithTools(messages, onStatus, onToolProgress) {
if (!_config.baseUrl || !_config.apiKey || !_config.model) {
const apiType = normalizeApiType(_config.apiType)
if (!_config.baseUrl || !_config.model || (requiresApiKey(apiType) && !_config.apiKey)) {
throw new Error('请先配置 AI 模型(点击右上角设置按钮)')
}
const apiType = _config.apiType || 'openai'
const base = cleanBaseUrl(_config.baseUrl)
const base = cleanBaseUrl(_config.baseUrl, apiType)
const tools = getEnabledTools()
let currentMessages = [{ role: 'system', content: buildSystemPrompt() }, ...messages]
const toolHistory = []
@@ -2054,7 +2092,7 @@ async function callAIWithTools(messages, onStatus, onToolProgress) {
onStatus(round === 0 ? 'AI 思考中...' : `AI 处理工具结果 (第${round + 1}轮)...`)
// ── Anthropic 工具调用 ──
if (apiType === 'anthropic') {
if (apiType === 'anthropic-messages') {
const systemMsg = currentMessages.find(m => m.role === 'system')?.content || ''
const chatMsgs = currentMessages.filter(m => m.role !== 'system')
const body = {
@@ -2499,7 +2537,7 @@ function showSettings() {
<div style="display:flex;gap:10px">
<div class="form-group" style="flex:1">
<label class="form-label">API Base URL</label>
<input class="form-input" id="ast-baseurl" value="${escHtml(c.baseUrl)}" placeholder="https://api.openai.com/v1">
<input class="form-input" id="ast-baseurl" value="${escHtml(c.baseUrl)}" placeholder="${escHtml(apiBasePlaceholder(c.apiType))}">
</div>
<div class="form-group" style="width:170px">
<label class="form-label">API 类型</label>
@@ -2511,7 +2549,7 @@ function showSettings() {
<div style="display:flex;gap:10px;align-items:flex-end">
<div class="form-group" style="flex:1;margin-bottom:0">
<label class="form-label">API Key</label>
<input class="form-input" id="ast-apikey" type="password" value="${escHtml(c.apiKey)}" placeholder="sk-...">
<input class="form-input" id="ast-apikey" type="password" value="${escHtml(c.apiKey)}" placeholder="${escHtml(apiKeyPlaceholder(c.apiType))}">
</div>
<div style="display:flex;gap:6px;padding-bottom:1px">
<button class="btn btn-sm btn-secondary" id="ast-btn-test" title="测试连通性">测试</button>
@@ -2533,11 +2571,7 @@ function showSettings() {
<input class="form-input" id="ast-temp" type="number" value="${c.temperature || 0.7}" min="0" max="2" step="0.1">
</div>
</div>
<div class="form-hint" id="ast-api-hint" style="margin-top:-4px">${{
openai: '自动兼容 Chat Completions 和 Responses API',
anthropic: '使用 Anthropic Messages API/v1/messages',
'google-gemini': '使用 Gemini generateContent API',
}[c.apiType || 'openai']}</div>
<div class="form-hint" id="ast-api-hint" style="margin-top:-4px">${apiHintText(c.apiType)}</div>
<div id="ast-qtcool-promo" style="margin-top:14px;border-radius:12px;background:linear-gradient(135deg,#0f0c29 0%,#302b63 50%,#24243e 100%);color:#fff;position:relative;overflow:hidden;box-shadow:0 4px 20px rgba(48,43,99,0.3)">
<div style="position:absolute;top:-40px;right:-40px;width:160px;height:160px;border-radius:50%;background:radial-gradient(circle,rgba(99,102,241,0.15) 0%,transparent 70%);pointer-events:none"></div>
@@ -2704,13 +2738,10 @@ function showSettings() {
const baseUrlInput = overlay.querySelector('#ast-baseurl')
const apiKeyInput = overlay.querySelector('#ast-apikey')
apiTypeSelect.addEventListener('change', () => {
const v = apiTypeSelect.value
const hints = { openai: '自动兼容 Chat Completions 和 Responses API', anthropic: '使用 Anthropic Messages API/v1/messages', 'google-gemini': '使用 Gemini generateContent API' }
const placeholders = { openai: 'https://api.openai.com/v1', anthropic: 'https://api.anthropic.com', 'google-gemini': 'https://generativelanguage.googleapis.com/v1beta' }
const keyPlaceholders = { openai: 'sk-...', anthropic: 'sk-ant-...', 'google-gemini': 'AIza...' }
apiHintEl.textContent = hints[v] || hints.openai
baseUrlInput.placeholder = placeholders[v] || placeholders.openai
apiKeyInput.placeholder = keyPlaceholders[v] || keyPlaceholders.openai
const v = normalizeApiType(apiTypeSelect.value)
apiHintEl.textContent = apiHintText(v)
baseUrlInput.placeholder = apiBasePlaceholder(v)
apiKeyInput.placeholder = apiKeyPlaceholder(v)
})
// 灵魂来源切换
@@ -2969,7 +3000,7 @@ function showSettings() {
overlay.querySelector('#ast-baseurl').value = QTCOOL.baseUrl
overlay.querySelector('#ast-apikey').value = key
overlay.querySelector('#ast-model').value = selectedModel
overlay.querySelector('#ast-apitype').value = 'openai'
overlay.querySelector('#ast-apitype').value = 'openai-completions'
qtcoolStatus.innerHTML = `<span style="color:#34d399">${statusIcon('ok', 14)} 助手已配置为 ${selectedModel}</span>`
toast('助手已接入 gpt.qt.cool — ' + selectedModel, 'success')
@@ -2992,7 +3023,7 @@ function showSettings() {
baseUrl: QTCOOL.baseUrl,
apiKey: key,
api: 'openai-completions',
models: QTCOOL.models.map(m => ({ id: m.id, name: m.name, contextWindow: 128000, reasoning: m.id.includes('codex') }))
models: [{ id: selectedModel, name: selectedModel, contextWindow: 128000, reasoning: selectedModel.includes('codex') }]
}
} else {
config.models.providers.qtcool.apiKey = key
@@ -3029,9 +3060,9 @@ function showSettings() {
const baseUrl = overlay.querySelector('#ast-baseurl').value.trim()
const apiKey = overlay.querySelector('#ast-apikey').value.trim()
const model = overlay.querySelector('#ast-model').value.trim()
const selApiType = overlay.querySelector('#ast-apitype').value || 'openai'
if (!baseUrl || !apiKey) {
resultEl.innerHTML = '<span style="color:var(--warning)">请先填写 Base URL 和 API Key</span>'
const selApiType = normalizeApiType(overlay.querySelector('#ast-apitype').value || 'openai-completions')
if (!baseUrl || (requiresApiKey(selApiType) && !apiKey)) {
resultEl.innerHTML = '<span style="color:var(--warning)">' + escHtml(requiresApiKey(selApiType) ? '请先填写 Base URL 和 API Key' : '请先填写 Base URL') + '</span>'
return
}
if (!model) {
@@ -3048,7 +3079,7 @@ function showSettings() {
let respStatus = 0, respBody = '', reply = '', usedApi = '', reqUrl = '', reqBody = {}
try {
if (selApiType === 'anthropic') {
if (selApiType === 'anthropic-messages') {
usedApi = 'Anthropic Messages'
reqUrl = base + '/messages'
reqBody = { model, messages: [{ role: 'user', content: '你好,请用一句话回复' }], max_tokens: 200 }
@@ -3120,20 +3151,20 @@ function showSettings() {
const btn = e.target
const baseUrl = overlay.querySelector('#ast-baseurl').value.trim()
const apiKey = overlay.querySelector('#ast-apikey').value.trim()
if (!baseUrl || !apiKey) {
resultEl.innerHTML = '<span style="color:var(--warning)">请先填写 Base URL 和 API Key</span>'
const selApiType = normalizeApiType(overlay.querySelector('#ast-apitype').value || 'openai-completions')
if (!baseUrl || (requiresApiKey(selApiType) && !apiKey)) {
resultEl.innerHTML = '<span style="color:var(--warning)">' + escHtml(requiresApiKey(selApiType) ? '请先填写 Base URL 和 API Key' : '请先填写 Base URL') + '</span>'
return
}
btn.disabled = true
btn.textContent = '获取中...'
resultEl.innerHTML = '<span style="color:var(--text-tertiary)">正在获取模型列表...</span>'
const selApiType = overlay.querySelector('#ast-apitype').value || 'openai'
try {
const base = cleanBaseUrl(baseUrl, selApiType)
const hdrs = authHeaders(selApiType, apiKey)
let models = []
if (selApiType === 'anthropic') {
if (selApiType === 'anthropic-messages') {
// Anthropic: GET /v1/models
const resp = await fetch(base + '/models', { headers: hdrs, signal: AbortSignal.timeout(10000) })
if (!resp.ok) {
@@ -3211,13 +3242,13 @@ function showSettings() {
const raw = await api.assistantReadFile(home + '/.openclaw/agents/' + agentId + '/agent/models.json')
const data = JSON.parse(raw)
for (const [pid, p] of Object.entries(data.providers || {})) {
if (p.baseUrl && p.apiKey) {
if (p.baseUrl) {
providers.push({
source: 'Agent: ' + agentId,
name: pid,
baseUrl: p.baseUrl,
apiKey: p.apiKey,
apiType: 'openai',
apiKey: p.apiKey || '',
apiType: normalizeApiType(p.api),
models: (p.models || []).map(m => m.id || m.name).filter(Boolean),
})
}
@@ -3231,14 +3262,14 @@ function showSettings() {
const raw = await api.assistantReadFile(home + '/.openclaw/openclaw.json')
const config = JSON.parse(raw)
for (const [pid, p] of Object.entries(config.models?.providers || {})) {
if (p.baseUrl && p.apiKey && !providers.find(x => x.name === pid)) {
if (p.baseUrl && !providers.find(x => x.name === pid)) {
providers.push({
source: '全局配置',
name: pid,
baseUrl: p.baseUrl,
apiKey: p.apiKey,
apiType: 'openai',
models: [],
apiKey: p.apiKey || '',
apiType: normalizeApiType(p.api),
models: (p.models || []).map(m => m.id || m.name).filter(Boolean),
})
}
}
@@ -3323,7 +3354,7 @@ function showSettings() {
_config.apiKey = overlay.querySelector('#ast-apikey').value.trim()
_config.model = overlay.querySelector('#ast-model').value.trim()
_config.temperature = parseFloat(overlay.querySelector('#ast-temp').value) || 0.7
_config.apiType = overlay.querySelector('#ast-apitype').value || 'openai'
_config.apiType = normalizeApiType(overlay.querySelector('#ast-apitype').value || 'openai-completions')
// 工具开关
_config.tools.terminal = overlay.querySelector('#ast-tool-terminal').checked
_config.tools.fileOps = overlay.querySelector('#ast-tool-fileops').checked

View File

@@ -53,7 +53,7 @@ const PLATFORM_REGISTRY = {
'进入<b>权限管理</b>,参照 <a href="https://open.larkoffice.com/document/server-docs/application-scope/scope-list" target="_blank" style="color:var(--accent);text-decoration:underline">权限列表</a> 开通所需权限(<code>im:message</code> 等)',
'进入<b>事件订阅</b>,选择<b>使用长连接WebSocket</b>模式,订阅<b>接收消息</b>和<b>卡片回调</b>事件。如有 user access token 开关请打开',
'将 App ID 和 App Secret 填入下方表单校验后保存。ClawPanel 会自动安装飞书插件并写入配置',
'保存后在飞书中向机器人发消息,获取配对码,然后在终端执行 <code>openclaw pairing approve feishu &lt;配对码&gt; --notify</code> 完成绑定',
'保存后在飞书中向机器人发消息,获取配对码;你可以直接在下方“配对审批”区域粘贴配对码完成绑定,也可以在终端执行 <code>openclaw pairing approve feishu &lt;配对码&gt; --notify</code>',
],
guideFooter: '<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary)">国际版 Lark 用户请将域名切换为 <b>lark</b>。详细教程:<a href="https://www.feishu.cn/content/article/7613711414611463386" target="_blank" style="color:var(--accent);text-decoration:underline">OpenClaw 飞书官方插件使用指南</a></div>',
fields: [
@@ -62,6 +62,35 @@ const PLATFORM_REGISTRY = {
{ key: 'domain', label: '域名', placeholder: 'feishu国际版选 lark', required: false },
],
pluginRequired: '@openclaw/feishu@latest',
pluginId: 'feishu',
pairingChannel: 'feishu',
pairingNotify: true,
},
dingtalk: {
label: '钉钉',
iconName: 'message-square',
desc: '钉钉企业内部应用 + 机器人 Stream 模式接入',
guide: [
'前往 <a href="https://open-dev.dingtalk.com/" target="_blank" style="color:var(--accent);text-decoration:underline">钉钉开放平台</a> 创建企业内部应用,并添加<b>机器人</b>能力',
'消息接收模式必须选择 <b>Stream 模式</b>,不要选 Webhook',
'在<b>凭证与基础信息</b>页面复制 <b>Client ID</b> 和 <b>Client Secret</b>;如 Gateway 开启了鉴权,请按 <code>gateway.auth.mode</code> 填写 <b>Gateway Token</b> 或 <b>Gateway Password</b>',
'在<b>权限管理</b>中至少确认已开通 <code>Card.Streaming.Write</code>、<code>Card.Instance.Write</code>、<code>qyapi_robot_sendmsg</code>,如需文档能力再补文档相关权限',
'先在钉钉侧<b>发布应用版本</b>,并确认<b>应用可见范围</b>包含你自己和测试成员;否则私聊或加群时可能搜不到机器人',
'回到 ClawPanel 保存。首次保存会自动安装插件,后续保存只更新配置;如果本机已配置 Gateway 鉴权,系统会自动带出对应的 Token 或 Password',
'私聊测试时,可在钉钉客户端搜索应用/机器人名称,或从工作台进入应用后发起对话;若找不到,优先检查“已发布”和“可见范围”',
'如果机器人首次私聊返回的是<b>配对码</b>,你可以直接在下方“配对审批”区域粘贴配对码完成授权,也可以在终端执行 <code>openclaw pairing approve dingtalk-connector &lt;配对码&gt;</code>',
'群聊测试时,先进入目标群 → <b>群设置</b> → <b>智能群助手 / 机器人</b> → <b>添加机器人</b>,搜索并添加该机器人;回群后建议用 <code>@机器人</code> 再发消息,如仍不响应再检查连接器的 <code>groupPolicy</code> 是否被设为 <code>disabled</code>',
],
guideFooter: '<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary)">参考资料:<a href="https://open.dingtalk.com/document/dingstart/install-openclaw-locally" target="_blank" style="color:var(--accent);text-decoration:underline">本地安装 OpenClaw</a>、<a href="https://open.dingtalk.com/document/orgapp/use-group-robots" target="_blank" style="color:var(--accent);text-decoration:underline">添加机器人到钉钉群</a>。排障重点405 通常是 <code>chatCompletions</code> 未启用401 通常是 Gateway 鉴权字段不匹配。</div>',
fields: [
{ key: 'clientId', label: 'Client ID', placeholder: 'dingxxxxxxxxxx', required: true },
{ key: 'clientSecret', label: 'Client Secret', placeholder: '应用密钥', secret: true, required: true },
{ key: 'gatewayToken', label: 'Gateway Token', placeholder: '如已开启 Gateway token 鉴权则填写', required: false },
{ key: 'gatewayPassword', label: 'Gateway Password', placeholder: '与 token 二选一,可选', secret: true, required: false },
],
pluginRequired: '@dingtalk-real-ai/dingtalk-connector',
pluginId: 'dingtalk-connector',
pairingChannel: 'dingtalk-connector',
},
discord: {
label: 'Discord',
@@ -90,7 +119,7 @@ export async function render() {
page.innerHTML = `
<div class="page-header">
<h1 class="page-title">消息渠道</h1>
<p class="page-desc">内置 QQ 机器人,并支持 Telegram、Discord 等外部消息渠道接入</p>
<p class="page-desc">支持 QQTelegram、Discord、飞书、钉钉等消息渠道接入</p>
</div>
<div id="platforms-configured" style="margin-bottom:var(--space-lg)"></div>
<div class="config-section">
@@ -117,6 +146,11 @@ async function loadPlatforms(page, state) {
toast('加载平台列表失败: ' + e, 'error')
state.configured = []
}
// 加载 bindings 信息
try {
const config = await api.readOpenclawConfig()
state.bindings = Array.isArray(config?.bindings) ? config.bindings : []
} catch { state.bindings = [] }
renderConfigured(page, state)
renderAvailable(page, state)
}
@@ -138,11 +172,15 @@ function renderConfigured(page, state) {
const reg = PLATFORM_REGISTRY[p.id]
const label = reg?.label || p.id
const ic = icon(reg?.iconName || 'radio', 22)
const channelKey = getChannelBindingKey(p.id)
const binding = (state.bindings || []).find(b => b.match?.channel === channelKey)
const boundAgent = binding?.agentId || 'main'
return `
<div class="platform-card ${p.enabled ? 'active' : 'inactive'}" data-pid="${p.id}">
<div class="platform-card-header">
<span class="platform-emoji">${ic}</span>
<span class="platform-name">${label}</span>
${boundAgent !== 'main' ? `<span style="font-size:var(--font-size-xs);color:var(--accent);background:var(--accent-muted);padding:1px 6px;border-radius:10px">→ ${escapeAttr(boundAgent)}</span>` : ''}
<span class="platform-status-dot ${p.enabled ? 'on' : 'off'}"></span>
</div>
<div class="platform-card-actions">
@@ -214,16 +252,47 @@ async function openConfigDialog(pid, page, state) {
// 尝试加载已有配置
let existing = {}
let isEdit = false
let agents = []
let currentBinding = ''
try {
const res = await api.readPlatformConfig(pid)
if (res?.exists && res.values) {
if (res?.values) {
existing = res.values
}
if (res?.exists) {
isEdit = true
}
} catch {}
// 加载 Agent 列表和当前 binding
try {
agents = await api.listAgents()
} catch {}
try {
const config = await api.readOpenclawConfig()
const bindings = config?.bindings || []
const channelKey = getChannelBindingKey(pid)
const found = bindings.find(b => b.match?.channel === channelKey)
if (found) currentBinding = found.agentId || ''
} catch {}
const formId = 'platform-form-' + Date.now()
// Agent 绑定选择器
const agentOptions = agents.map(a => {
const label = a.identityName ? a.identityName.split(',')[0].trim() : a.id
return `<option value="${escapeAttr(a.id)}" ${a.id === currentBinding ? 'selected' : ''}>${a.id}${a.id !== label ? ' — ' + label : ''}</option>`
}).join('')
const agentBindingHtml = `
<div class="form-group">
<label class="form-label">绑定 Agent</label>
<select class="form-input" name="__agentBinding">
<option value="" ${!currentBinding ? 'selected' : ''}>默认main</option>
${agentOptions}
</select>
<div class="form-hint">选择该渠道消息路由到哪个 Agent 处理。留空则使用默认 Agentmain</div>
</div>
`
const fieldsHtml = reg.fields.map((f, i) => {
const val = existing[f.key] || ''
return `
@@ -249,12 +318,28 @@ async function openConfigDialog(pid, page, state) {
</details>
` : ''
const pairingHtml = reg.pairingChannel ? `
<div style="margin-top:var(--space-md);padding:12px 14px;background:var(--bg-tertiary);border-radius:var(--radius-md)">
<div style="font-weight:600;font-size:var(--font-size-sm);margin-bottom:6px">配对审批</div>
<div style="font-size:var(--font-size-xs);color:var(--text-secondary);line-height:1.7;margin-bottom:8px">当机器人提示 <code>access not configured</code>、<code>Pairing code</code> 或要求执行 <code>openclaw pairing approve</code> 时,可直接在这里完成批准。</div>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<input class="form-input" name="pairingCode" placeholder="例如 R3ZFPWZP" style="flex:1;min-width:180px">
<button type="button" class="btn btn-sm btn-secondary" id="btn-pairing-list">查看待审批</button>
<button type="button" class="btn btn-sm btn-primary" id="btn-pairing-approve">批准配对码</button>
</div>
<div id="pairing-result" style="margin-top:8px"></div>
</div>
` : ''
const content = `
${guideHtml}
${!isEdit && (existing.gatewayToken || existing.gatewayPassword) ? `<div style="background:var(--bg-tertiary);color:var(--text-secondary);padding:8px 14px;border-radius:var(--radius-md);font-size:var(--font-size-sm);margin-bottom:var(--space-md)">已从当前 Gateway 鉴权配置中自动带出 ${existing.gatewayToken ? 'Token' : 'Password'},通常无需手填</div>` : ''}
${isEdit ? `<div style="background:var(--accent-muted);color:var(--accent);padding:8px 14px;border-radius:var(--radius-md);font-size:var(--font-size-sm);margin-bottom:var(--space-md)">当前已有配置,修改后点击保存即可覆盖</div>` : ''}
<form id="${formId}">
${fieldsHtml}
${agentBindingHtml}
</form>
${pairingHtml}
<div id="verify-result" style="margin-top:var(--space-sm)"></div>
`
@@ -304,6 +389,60 @@ async function openConfigDialog(pid, page, state) {
const btnVerify = modal.querySelector('#btn-verify')
const btnSave = modal.querySelector('#btn-save')
const resultEl = modal.querySelector('#verify-result')
const pairingInput = modal.querySelector('input[name="pairingCode"]')
const pairingResultEl = modal.querySelector('#pairing-result')
const btnPairingList = modal.querySelector('#btn-pairing-list')
const btnPairingApprove = modal.querySelector('#btn-pairing-approve')
if (btnPairingList && pairingResultEl) {
btnPairingList.onclick = async () => {
btnPairingList.disabled = true
btnPairingList.textContent = '读取中...'
pairingResultEl.innerHTML = ''
try {
const output = await api.pairingListChannel(reg.pairingChannel)
pairingResultEl.innerHTML = `
<div style="background:var(--bg-secondary);border:1px solid var(--border-primary);border-radius:var(--radius-md);padding:10px 12px">
<div style="font-size:var(--font-size-xs);color:var(--text-tertiary);margin-bottom:6px">待审批请求</div>
<pre style="margin:0;white-space:pre-wrap;word-break:break-word;font-size:12px;color:var(--text-secondary);font-family:var(--font-mono)">${escapeAttr(output || '暂无待审批请求')}</pre>
</div>`
} catch (e) {
pairingResultEl.innerHTML = `<div style="color:var(--error);font-size:var(--font-size-sm)">读取失败: ${escapeAttr(String(e))}</div>`
} finally {
btnPairingList.disabled = false
btnPairingList.textContent = '查看待审批'
}
}
}
if (btnPairingApprove && pairingInput && pairingResultEl) {
btnPairingApprove.onclick = async () => {
const code = pairingInput.value.trim().toUpperCase()
if (!code) {
toast('请输入配对码', 'warning')
pairingInput.focus()
return
}
btnPairingApprove.disabled = true
btnPairingApprove.textContent = '批准中...'
pairingResultEl.innerHTML = ''
try {
const output = await api.pairingApproveChannel(reg.pairingChannel, code, !!reg.pairingNotify)
pairingResultEl.innerHTML = `
<div style="background:var(--success-muted);color:var(--success);padding:10px 14px;border-radius:var(--radius-md);font-size:var(--font-size-sm)">
${icon('check', 14)} 配对已批准
<div style="margin-top:6px;font-size:12px;white-space:pre-wrap;word-break:break-word;color:var(--text-secondary)">${escapeAttr(output || '操作完成')}</div>
</div>`
pairingInput.value = ''
toast('配对已批准', 'success')
} catch (e) {
pairingResultEl.innerHTML = `<div style="background:var(--error-muted, #fee2e2);color:var(--error);padding:10px 14px;border-radius:var(--radius-md);font-size:var(--font-size-sm)">批准失败: ${escapeAttr(String(e))}</div>`
} finally {
btnPairingApprove.disabled = false
btnPairingApprove.textContent = '批准配对码'
}
}
}
btnVerify.onclick = async () => {
const form = collectForm()
@@ -356,57 +495,77 @@ async function openConfigDialog(pid, page, state) {
try {
// 如果需要安装插件,先安装并显示日志
if (reg.pluginRequired) {
btnSave.textContent = '安装插件中...'
resultEl.innerHTML = `
<div style="background:var(--bg-tertiary);border-radius:var(--radius-md);padding:12px;margin-top:var(--space-sm)">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
${icon('download', 14)}
<span style="font-size:var(--font-size-sm);font-weight:600">安装插件</span>
<span id="plugin-progress-text" style="font-size:var(--font-size-xs);color:var(--text-tertiary);margin-left:auto">0%</span>
const pluginId = reg.pluginId || pid
const pluginStatus = await api.getChannelPluginStatus(pluginId)
if (!pluginStatus?.installed) {
btnSave.textContent = '安装插件中...'
resultEl.innerHTML = `
<div style="background:var(--bg-tertiary);border-radius:var(--radius-md);padding:12px;margin-top:var(--space-sm)">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
${icon('download', 14)}
<span style="font-size:var(--font-size-sm);font-weight:600">安装插件</span>
<span id="plugin-progress-text" style="font-size:var(--font-size-xs);color:var(--text-tertiary);margin-left:auto">0%</span>
</div>
<div style="height:4px;background:var(--bg-secondary);border-radius:2px;overflow:hidden;margin-bottom:8px">
<div id="plugin-progress-bar" style="height:100%;background:var(--accent);width:0%;transition:width 0.3s"></div>
</div>
<div id="plugin-log-box" style="font-family:var(--font-mono);font-size:11px;color:var(--text-secondary);max-height:120px;overflow-y:auto;line-height:1.6;white-space:pre-wrap;word-break:break-all"></div>
</div>
<div style="height:4px;background:var(--bg-secondary);border-radius:2px;overflow:hidden;margin-bottom:8px">
<div id="plugin-progress-bar" style="height:100%;background:var(--accent);width:0%;transition:width 0.3s"></div>
</div>
<div id="plugin-log-box" style="font-family:var(--font-mono);font-size:11px;color:var(--text-secondary);max-height:120px;overflow-y:auto;line-height:1.6;white-space:pre-wrap;word-break:break-all"></div>
</div>
`
const logBox = resultEl.querySelector('#plugin-log-box')
const progressBar = resultEl.querySelector('#plugin-progress-bar')
const progressText = resultEl.querySelector('#plugin-progress-text')
`
const logBox = resultEl.querySelector('#plugin-log-box')
const progressBar = resultEl.querySelector('#plugin-progress-bar')
const progressText = resultEl.querySelector('#plugin-progress-text')
let unlistenLog, unlistenProgress
try {
const { listen } = await import('@tauri-apps/api/event')
unlistenLog = await listen('plugin-log', (e) => {
logBox.textContent += e.payload + '\n'
logBox.scrollTop = logBox.scrollHeight
})
unlistenProgress = await listen('plugin-progress', (e) => {
const pct = e.payload
progressBar.style.width = pct + '%'
progressText.textContent = pct + '%'
})
} catch {}
// 监听 Tauri 事件
let unlistenLog, unlistenProgress
try {
const { listen } = await import('@tauri-apps/api/event')
unlistenLog = await listen('plugin-log', (e) => {
logBox.textContent += e.payload + '\n'
logBox.scrollTop = logBox.scrollHeight
})
unlistenProgress = await listen('plugin-progress', (e) => {
const pct = e.payload
progressBar.style.width = pct + '%'
progressText.textContent = pct + '%'
})
} catch {}
try {
await api.installQqbotPlugin()
} catch (e) {
toast('插件安装失败: ' + e, 'error')
btnSave.disabled = false
btnVerify.disabled = false
btnSave.textContent = isEdit ? '保存' : '接入并保存'
try {
if (pid === 'qqbot') {
await api.installQqbotPlugin()
} else {
await api.installChannelPlugin(reg.pluginRequired, pluginId)
}
} catch (e) {
toast('插件安装失败: ' + e, 'error')
btnSave.disabled = false
btnVerify.disabled = false
btnSave.textContent = isEdit ? '保存' : '接入并保存'
if (unlistenLog) unlistenLog()
if (unlistenProgress) unlistenProgress()
return
}
if (unlistenLog) unlistenLog()
if (unlistenProgress) unlistenProgress()
return
} else {
resultEl.innerHTML = `
<div style="background:var(--accent-muted);color:var(--accent);padding:10px 14px;border-radius:var(--radius-md);font-size:var(--font-size-sm)">
${icon('check', 14)} 已检测到插件,无需重复安装,本次仅更新配置
</div>`
}
if (unlistenLog) unlistenLog()
if (unlistenProgress) unlistenProgress()
}
// 写入配置
btnSave.textContent = '写入配置...'
await api.saveMessagingPlatform(pid, form)
// 写入 Agent 绑定到 openclaw.json bindings
const selectedAgent = modal.querySelector('select[name="__agentBinding"]')?.value || ''
try {
await saveChannelBinding(pid, selectedAgent)
} catch (e) {
console.warn('[channels] 保存 Agent 绑定失败:', e)
}
toast(`${reg.label} 配置已保存Gateway 正在重载`, 'success')
modal.close?.() || modal.remove?.()
await loadPlatforms(page, state)
@@ -420,6 +579,37 @@ async function openConfigDialog(pid, page, state) {
}
}
/** 将平台 ID 映射为 openclaw bindings 中的 channel key */
function getChannelBindingKey(pid) {
const map = {
qqbot: 'qqbot',
telegram: 'telegram',
discord: 'discord',
feishu: 'feishu',
dingtalk: 'dingtalk-connector',
}
return map[pid] || pid
}
/** 保存渠道→Agent 绑定到 openclaw.json 的 bindings 数组 */
async function saveChannelBinding(pid, agentId) {
const config = await api.readOpenclawConfig()
if (!config) return
const channelKey = getChannelBindingKey(pid)
let bindings = Array.isArray(config.bindings) ? [...config.bindings] : []
// 移除该渠道的旧绑定
bindings = bindings.filter(b => b.match?.channel !== channelKey)
// 如果选了非空 Agent 且不是 main添加新绑定
if (agentId && agentId !== 'main') {
bindings.push({ match: { channel: channelKey }, agentId })
}
config.bindings = bindings
await api.writeOpenclawConfig(config)
}
function escapeAttr(str) {
return (str || '').replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}

View File

@@ -2,7 +2,7 @@
* 聊天页面 - 完整版,对接 OpenClaw Gateway
* 支持流式响应、Markdown 渲染、会话管理、Agent 选择、快捷指令
*/
import { api } from '../lib/tauri-api.js'
import { api, invalidate } from '../lib/tauri-api.js'
import { navigate } from '../router.js'
import { wsClient, uuid } from '../lib/ws-client.js'
import { renderMarkdown } from '../lib/markdown.js'
@@ -13,6 +13,9 @@ import { icon as svgIcon } from '../lib/icons.js'
const RENDER_THROTTLE = 30
const STORAGE_SESSION_KEY = 'clawpanel-last-session'
const STORAGE_MODEL_KEY = 'clawpanel-chat-selected-model'
const STORAGE_SIDEBAR_KEY = 'clawpanel-chat-sidebar-open'
const STORAGE_SESSION_NAMES_KEY = 'clawpanel-chat-session-names'
const COMMANDS = [
{ title: '会话', commands: [
@@ -41,6 +44,7 @@ const COMMANDS = [
let _sessionKey = null, _page = null, _messagesEl = null, _textarea = null
let _sendBtn = null, _statusDot = null, _typingEl = null, _scrollBtn = null
let _sessionListEl = null, _cmdPanelEl = null, _attachPreviewEl = null, _fileInputEl = null
let _modelSelectEl = null
let _currentAiBubble = null, _currentAiText = '', _currentAiImages = [], _currentAiVideos = [], _currentAiAudios = [], _currentAiFiles = [], _currentRunId = null
let _isStreaming = false, _isSending = false, _messageQueue = [], _streamStartTime = 0
let _lastRenderTime = 0, _renderPending = false, _lastHistoryHash = ''
@@ -49,6 +53,10 @@ let _pageActive = false
let _errorTimer = null, _lastErrorMsg = null
let _attachments = []
let _hasEverConnected = false
let _availableModels = []
let _primaryModel = ''
let _selectedModel = ''
let _isApplyingModel = false
export async function render() {
const page = document.createElement('div')
@@ -76,6 +84,14 @@ export async function render() {
<span class="chat-title" id="chat-title">聊天</span>
</div>
<div class="chat-header-actions">
<div class="chat-model-group">
<select class="form-input" id="chat-model-select" title="切换当前会话模型" style="width:200px;max-width:28vw;padding:6px 10px;font-size:var(--font-size-xs)">
<option value="">加载模型中...</option>
</select>
<button class="btn btn-sm btn-ghost" id="btn-refresh-models" title="刷新模型列表">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>
</button>
</div>
<button class="btn btn-sm btn-ghost" id="btn-cmd" title="快捷指令">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M18 3a3 3 0 00-3 3v12a3 3 0 003 3 3 3 0 003-3 3 3 0 00-3-3H6a3 3 0 00-3 3 3 3 0 003 3 3 3 0 003-3V6a3 3 0 00-3-3 3 3 0 00-3 3 3 3 0 003 3h12a3 3 0 003-3 3 3 0 00-3-3z"/></svg>
</button>
@@ -132,6 +148,8 @@ export async function render() {
_cmdPanelEl = page.querySelector('#chat-cmd-panel')
_attachPreviewEl = page.querySelector('#chat-attachments-preview')
_fileInputEl = page.querySelector('#chat-file-input')
_modelSelectEl = page.querySelector('#chat-model-select')
page.querySelector('#chat-sidebar')?.classList.toggle('open', getSidebarOpen())
bindEvents(page)
bindConnectOverlay(page)
@@ -139,6 +157,7 @@ export async function render() {
// 首次使用引导提示
showPageGuide(_messagesEl)
loadModelOptions()
// 非阻塞:先返回 DOM后台连接 Gateway
connectGateway()
return page
@@ -173,6 +192,15 @@ function showPageGuide(container) {
// ── 事件绑定 ──
function bindEvents(page) {
if (_modelSelectEl) {
_modelSelectEl.addEventListener('change', () => {
_selectedModel = _modelSelectEl.value
if (_selectedModel) localStorage.setItem(STORAGE_MODEL_KEY, _selectedModel)
else localStorage.removeItem(STORAGE_MODEL_KEY)
applySelectedModel()
})
}
_textarea.addEventListener('input', () => {
_textarea.style.height = 'auto'
_textarea.style.height = Math.min(_textarea.scrollHeight, 150) + 'px'
@@ -193,11 +221,16 @@ function bindEvents(page) {
})
page.querySelector('#btn-toggle-sidebar').addEventListener('click', () => {
page.querySelector('#chat-sidebar').classList.toggle('open')
const sidebar = page.querySelector('#chat-sidebar')
if (!sidebar) return
const nextOpen = !sidebar.classList.contains('open')
sidebar.classList.toggle('open', nextOpen)
setSidebarOpen(nextOpen)
})
page.querySelector('#btn-new-session').addEventListener('click', () => showNewSessionDialog())
page.querySelector('#btn-cmd').addEventListener('click', () => toggleCmdPanel())
page.querySelector('#btn-reset-session').addEventListener('click', () => resetCurrentSession())
page.querySelector('#btn-refresh-models')?.addEventListener('click', () => loadModelOptions(true))
// 文件上传
page.querySelector('#chat-attach-btn').addEventListener('click', () => _fileInputEl.click())
@@ -213,6 +246,113 @@ function bindEvents(page) {
_messagesEl.addEventListener('click', () => hideCmdPanel())
}
async function loadModelOptions(showToast = false) {
if (!_modelSelectEl) return
// 显示加载状态
_modelSelectEl.innerHTML = '<option value="">加载模型中...</option>'
_modelSelectEl.disabled = true
try {
invalidate('read_openclaw_config')
const configPromise = api.readOpenclawConfig()
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('读取超时(8s),请检查配置文件')), 8000))
const config = await Promise.race([configPromise, timeoutPromise])
const providers = config?.models?.providers || {}
_primaryModel = config?.agents?.defaults?.model?.primary || ''
const models = []
const seen = new Set()
if (_primaryModel) {
seen.add(_primaryModel)
models.push(_primaryModel)
}
for (const [providerKey, provider] of Object.entries(providers)) {
for (const item of (provider?.models || [])) {
const modelId = typeof item === 'string' ? item : item?.id
if (!modelId) continue
const full = `${providerKey}/${modelId}`
if (seen.has(full)) continue
seen.add(full)
models.push(full)
}
}
_availableModels = models
const saved = localStorage.getItem(STORAGE_MODEL_KEY) || ''
_selectedModel = models.includes(saved) ? saved : (_primaryModel || models[0] || '')
renderModelSelect()
if (showToast) toast(`已刷新,共 ${models.length} 个模型`, 'success')
} catch (e) {
_availableModels = []
_primaryModel = ''
_selectedModel = ''
renderModelSelect(`加载失败: ${e.message || e}`)
if (showToast) toast('加载模型失败: ' + (e.message || e), 'error')
}
}
function renderModelSelect(errorText = '') {
if (!_modelSelectEl) return
if (!_availableModels.length) {
_modelSelectEl.innerHTML = `<option value="">${escapeAttr(errorText || '未配置模型')}</option>`
_modelSelectEl.disabled = true
_modelSelectEl.title = errorText || '请先到模型配置页面添加模型'
return
}
_modelSelectEl.disabled = _isApplyingModel
_modelSelectEl.innerHTML = _availableModels.map(full => {
const suffix = full === _primaryModel ? '(主模型)' : ''
return `<option value="${escapeAttr(full)}" ${full === _selectedModel ? 'selected' : ''}>${full}${suffix}</option>`
}).join('')
_modelSelectEl.title = _selectedModel ? `切换当前会话模型:${_selectedModel}` : '切换当前会话模型'
}
function escapeAttr(str) {
return (str || '').replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
/** 本地会话别名缓存 */
function getSessionNames() {
try { return JSON.parse(localStorage.getItem(STORAGE_SESSION_NAMES_KEY) || '{}') } catch { return {} }
}
function setSessionName(key, name) {
const names = getSessionNames()
if (name) names[key] = name
else delete names[key]
localStorage.setItem(STORAGE_SESSION_NAMES_KEY, JSON.stringify(names))
}
function getDisplayLabel(key) {
const custom = getSessionNames()[key]
return custom || parseSessionLabel(key)
}
function getSidebarOpen() {
return localStorage.getItem(STORAGE_SIDEBAR_KEY) === '1'
}
function setSidebarOpen(open) {
localStorage.setItem(STORAGE_SIDEBAR_KEY, open ? '1' : '0')
}
async function applySelectedModel() {
if (!_selectedModel) {
toast('请先选择模型', 'warning')
return
}
if (!wsClient.gatewayReady || !_sessionKey) {
toast('Gateway 未就绪,连接成功后再切换模型', 'warning')
return
}
_isApplyingModel = true
renderModelSelect()
try {
await wsClient.chatSend(_sessionKey, `/model ${_selectedModel}`)
toast(`已切换当前会话模型为 ${_selectedModel}`, 'success')
} catch (e) {
toast('切换模型失败: ' + (e.message || e), 'error')
} finally {
_isApplyingModel = false
renderModelSelect()
}
}
// ── 连接引导遮罩 ──
function bindConnectOverlay(page) {
@@ -450,9 +590,21 @@ function renderSessionList(sessions) {
const key = s.sessionKey || s.key || ''
const active = key === _sessionKey ? ' active' : ''
const label = parseSessionLabel(key)
return `<div class="chat-session-item${active}" data-key="${key}">
<span class="chat-session-label">${label}</span>
<button class="chat-session-del" data-del="${key}" title="删除">×</button>
const ts = s.updatedAt || s.lastActivity || s.createdAt || 0
const timeStr = ts ? formatSessionTime(ts) : ''
const msgCount = s.messageCount || s.messages || 0
const agentId = parseSessionAgent(key)
const displayLabel = getDisplayLabel(key) || label
return `<div class="chat-session-card${active}" data-key="${escapeAttr(key)}">
<div class="chat-session-card-header">
<span class="chat-session-label" title="双击重命名">${escapeAttr(displayLabel)}</span>
<button class="chat-session-del" data-del="${escapeAttr(key)}" title="删除">×</button>
</div>
<div class="chat-session-card-meta">
${agentId && agentId !== 'main' ? `<span class="chat-session-agent">${escapeAttr(agentId)}</span>` : ''}
${msgCount > 0 ? `<span>${msgCount} 条消息</span>` : ''}
${timeStr ? `<span>${timeStr}</span>` : ''}
</div>
</div>`
}).join('')
@@ -462,6 +614,31 @@ function renderSessionList(sessions) {
const item = e.target.closest('[data-key]')
if (item) switchSession(item.dataset.key)
}
_sessionListEl.ondblclick = (e) => {
const labelEl = e.target.closest('.chat-session-label')
if (!labelEl) return
const card = labelEl.closest('[data-key]')
if (!card) return
e.stopPropagation()
renameSession(card.dataset.key, labelEl)
}
}
function formatSessionTime(ts) {
const d = new Date(typeof ts === 'number' && ts < 1e12 ? ts * 1000 : ts)
if (isNaN(d.getTime())) return ''
const now = new Date()
const diffMs = now - d
if (diffMs < 60000) return '刚刚'
if (diffMs < 3600000) return Math.floor(diffMs / 60000) + ' 分钟前'
if (diffMs < 86400000) return Math.floor(diffMs / 3600000) + ' 小时前'
if (diffMs < 604800000) return Math.floor(diffMs / 86400000) + ' 天前'
return `${(d.getMonth() + 1).toString().padStart(2, '0')}-${d.getDate().toString().padStart(2, '0')}`
}
function parseSessionAgent(key) {
const parts = (key || '').split(':')
return parts.length >= 2 ? parts[1] : ''
}
function parseSessionLabel(key) {
@@ -499,7 +676,7 @@ async function showNewSessionDialog() {
title: '新建会话',
fields: [
{ name: 'name', label: '会话名称', value: '', placeholder: '例如:翻译助手' },
{ name: 'agent', label: '智能体', type: 'select', value: defaultAgent, options: initialOptions },
{ name: 'agent', label: 'Agent', type: 'select', value: defaultAgent, options: initialOptions },
],
onConfirm: (result) => {
const name = (result.name || '').trim()
@@ -555,6 +732,9 @@ async function deleteSession(key) {
async function resetCurrentSession() {
if (!_sessionKey) return
const label = getDisplayLabel(_sessionKey)
const yes = await showConfirm(`确定要重置会话「${label}」吗?\n\n重置后将清空该会话的所有聊天记录,此操作不可撤销。`)
if (!yes) return
try {
await wsClient.sessionsReset(_sessionKey)
clearMessages()
@@ -568,7 +748,42 @@ async function resetCurrentSession() {
function updateSessionTitle() {
const el = _page?.querySelector('#chat-title')
if (el) el.textContent = parseSessionLabel(_sessionKey)
if (el) el.textContent = getDisplayLabel(_sessionKey)
}
function renameSession(key, labelEl) {
const current = getDisplayLabel(key)
const input = document.createElement('input')
input.type = 'text'
input.value = current
input.className = 'chat-session-rename-input'
input.style.cssText = 'width:100%;padding:2px 6px;border:1px solid var(--accent);border-radius:4px;background:var(--bg-secondary);color:var(--text-primary);font-size:12px;outline:none'
const originalText = labelEl.textContent
labelEl.textContent = ''
labelEl.appendChild(input)
input.focus()
input.select()
let done = false
const finish = () => {
if (done) return
done = true
const newName = input.value.trim()
if (newName && newName !== parseSessionLabel(key)) {
setSessionName(key, newName)
toast('会话已重命名', 'success')
} else if (!newName || newName === parseSessionLabel(key)) {
setSessionName(key, '') // clear custom name
}
labelEl.textContent = getDisplayLabel(key)
// 如果是当前会话,同步更新顶部标题
if (key === _sessionKey) updateSessionTitle()
}
input.addEventListener('blur', finish)
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); input.blur() }
if (e.key === 'Escape') { input.value = originalText; input.blur() }
})
}
// ── 快捷指令面板 ──

View File

@@ -1,13 +1,14 @@
/**
* 定时任务管理
* 通过 Gateway WebSocket RPC 直接管理计划任务cron.list / cron.add / cron.update / cron.remove / cron.run
* 通过 Gateway WebSocket RPC 管理cron.list / cron.add / cron.update / cron.remove / cron.run
* 注意openclaw.json 不支持 cron.jobs 字段,定时任务只能通过 Gateway 在线管理
*/
import { toast } from '../components/toast.js'
import { showContentModal, showConfirm } from '../components/modal.js'
import { icon } from '../lib/icons.js'
import { onGatewayChange } from '../lib/app-state.js'
import { wsClient } from '../lib/ws-client.js'
import { api } from '../lib/tauri-api.js'
import { api, invalidate } from '../lib/tauri-api.js'
let _unsub = null
@@ -34,7 +35,15 @@ export async function render() {
<h1 class="page-title">定时任务</h1>
<p class="page-desc">创建计划任务,让 AI 按设定时间自动执行指令</p>
</div>
<div id="cron-gw-warn" style="display:none"></div>
<div id="cron-gw-hint" style="display:none;margin-bottom:var(--space-md)">
<div class="config-section" style="border-left:3px solid var(--warning);padding:12px 16px">
<div style="display:flex;align-items:center;gap:8px;color:var(--text-secondary);font-size:var(--font-size-sm)">
${icon('alert-circle', 16)}
<span>定时任务通过 Gateway 管理。请先启动 Gateway 后使用此功能。</span>
<a href="#/services" class="btn btn-sm btn-secondary" style="margin-left:auto;font-size:11px">服务管理</a>
</div>
</div>
</div>
<div id="cron-stats" class="stat-cards" style="margin-bottom:var(--space-lg)"></div>
<div class="config-actions" style="margin-bottom:var(--space-md)">
<button class="btn btn-primary btn-sm" id="btn-new-task">+ 创建任务</button>
@@ -48,14 +57,17 @@ export async function render() {
page.querySelector('#btn-new-task').onclick = () => openTaskDialog(null, page, state)
page.querySelector('#btn-refresh-tasks').onclick = () => fetchJobs(page, state)
// 自动修复:移除可能被写入的无效 cron.jobs 字段
fixInvalidCronConfig()
// 监听 Gateway 状态变化
if (_unsub) _unsub()
_unsub = onGatewayChange(() => {
updateGatewayWarning(page)
updateGatewayHint(page)
fetchJobs(page, state)
})
updateGatewayWarning(page)
updateGatewayHint(page)
await fetchJobs(page, state)
return page
@@ -65,35 +77,36 @@ export function cleanup() {
if (_unsub) { _unsub(); _unsub = null }
}
// ── Gateway 连接检查 ──
/** 自动移除无效的 cron.jobs 字段(之前版本错误写入,会导致 Gateway 崩溃) */
async function fixInvalidCronConfig() {
try {
invalidate('read_openclaw_config')
const config = await api.readOpenclawConfig()
if (config?.cron?.jobs) {
delete config.cron.jobs
if (Object.keys(config.cron).length === 0) delete config.cron
await api.writeOpenclawConfig(config)
toast('已自动修复配置(移除无效的 cron.jobs', 'info')
}
} catch {}
}
function isGatewayUp() {
return wsClient && wsClient._gatewayReady
return wsClient && wsClient.gatewayReady
}
function updateGatewayWarning(page) {
const el = page.querySelector('#cron-gw-warn')
function updateGatewayHint(page) {
const el = page.querySelector('#cron-gw-hint')
if (!el) return
if (isGatewayUp()) {
el.style.display = 'none'
} else {
el.style.display = ''
el.innerHTML = `
<div class="config-section" style="border-color:var(--warning);margin-bottom:var(--space-md)">
<div style="display:flex;align-items:center;gap:8px;color:var(--warning);font-size:var(--font-size-sm)">
${icon('alert-circle', 16)}
Gateway 未连接,定时任务功能需要 Gateway 在线才能使用
</div>
</div>
`
}
el.style.display = isGatewayUp() ? 'none' : ''
}
// ── 数据加载(直连 Gateway RPC ──
// ── 数据加载Gateway RPC ──
async function fetchJobs(page, state) {
if (!isGatewayUp()) {
state.jobs = []
state.loading = false
renderStats(page, state)
renderList(page, state)
return
@@ -107,24 +120,18 @@ async function fetchJobs(page, state) {
let jobs = res?.jobs || res
if (!Array.isArray(jobs)) jobs = []
// 映射 Gateway CronJob 格式到 UI 格式
state.jobs = jobs.map(j => ({
id: j.id,
name: j.name || '未命名',
id: j.name || j.id,
name: j.name || j.id || '未命名',
description: j.description || '',
message: j.payload?.message || j.payload?.text || '',
payloadKind: j.payload?.kind || 'agentTurn',
schedule: j.schedule || {},
enabled: j.enabled !== false,
agentId: j.agentId || null,
// 运行状态
lastRunStatus: j.state?.lastRunStatus || j.state?.lastStatus || null,
lastRunAtMs: j.state?.lastRunAtMs || null,
lastError: j.state?.lastError || null,
lastDurationMs: j.state?.lastDurationMs || null,
nextRunAtMs: j.state?.nextRunAtMs || null,
consecutiveErrors: j.state?.consecutiveErrors || 0,
updatedAtMs: j.updatedAtMs || null,
}))
} catch (e) {
toast('获取任务列表失败: ' + e, 'error')
@@ -240,7 +247,7 @@ function renderList(page, state) {
const btn = e.currentTarget
btn.disabled = true
try {
await wsClient.request('cron.run', { id: jid, mode: 'force' })
await wsClient.request('cron.run', { name: jid })
toast('任务已触发执行', 'success')
setTimeout(() => fetchJobs(page, state), 2000)
} catch (err) { toast('触发失败: ' + err, 'error') }
@@ -252,7 +259,7 @@ function renderList(page, state) {
btn.disabled = true
btn.innerHTML = icon('refresh-cw', 14)
try {
await wsClient.request('cron.update', { id: jid, patch: { enabled: !job.enabled } })
await wsClient.request('cron.update', { name: jid, patch: { enabled: !job.enabled } })
toast(job.enabled ? '已暂停' : '已启用', 'info')
await fetchJobs(page, state)
} catch (err) { toast('操作失败: ' + err, 'error'); btn.disabled = false; btn.innerHTML = job.enabled ? icon('pause', 14) : icon('play', 14) }
@@ -260,16 +267,16 @@ function renderList(page, state) {
card.querySelector('[data-action="edit"]').onclick = () => openTaskDialog(job, page, state)
card.querySelector('[data-action="delete"]').onclick = async (e) => {
card.querySelector('[data-action="delete"]').onclick = async function() {
const btn = this
const yes = await showConfirm(`确定删除任务「${job.name}」?`)
if (!yes) return
const btn = e.currentTarget
btn.disabled = true
if (btn) btn.disabled = true
try {
await wsClient.request('cron.remove', { id: jid })
await wsClient.request('cron.remove', { name: jid })
toast('已删除', 'info')
await fetchJobs(page, state)
} catch (err) { toast('删除失败: ' + err, 'error'); btn.disabled = false }
} catch (err) { toast('删除失败: ' + err, 'error'); if (btn) btn.disabled = false }
}
})
}
@@ -278,10 +285,9 @@ function renderList(page, state) {
async function openTaskDialog(job, page, state) {
if (!isGatewayUp()) {
toast('Gateway 未连接,无法管理任务', 'warning')
toast('Gateway 未连接,无法管理定时任务。请先启动 Gateway', 'warning')
return
}
const isEdit = !!job
const initSchedule = extractCronExpr(job?.schedule) || '0 9 * * *'
const formId = 'cron-form-' + Date.now()
@@ -291,18 +297,8 @@ async function openTaskDialog(job, page, state) {
return `<button type="button" class="btn btn-sm ${selected ? 'btn-primary' : 'btn-secondary'} cron-shortcut" data-expr="${s.expr}">${s.text}</button>`
}).join('')
// 加载 agent 列表用于选择器
let agents = []
try {
const res = await api.listAgents()
agents = Array.isArray(res) ? res : (res?.agents || [])
} catch {}
const agentOptionsHtml = agents.length
? `<option value="">默认 Agent</option>` + agents.map(a =>
`<option value="${escapeAttr(a.id)}" ${job?.agentId === a.id ? 'selected' : ''}>${escapeHtml(a.name || a.id)}</option>`
).join('')
: `<option value="">默认 Agent未检测到其他 Agent</option>`
// 先用默认选项,弹窗后异步加载 Agent 列表
const agentOptionsHtml = `<option value="" ${!job?.agentId ? 'selected' : ''}>默认 Agent</option>${job?.agentId ? `<option value="${escapeAttr(job.agentId)}" selected>${escapeHtml(job.agentId)}</option>` : ''}`
const content = `
<form id="${formId}" style="display:flex;flex-direction:column;gap:var(--space-md)">
@@ -344,6 +340,18 @@ async function openTaskDialog(job, page, state) {
width: 500,
})
// 异步加载 Agent 列表并更新下拉框(不阻塞弹窗显示)
api.listAgents().then(res => {
const agents = Array.isArray(res) ? res : (res?.agents || [])
if (!agents.length) return
const select = modal.querySelector('select[name="agentId"]')
if (!select) return
const currentVal = select.value
select.innerHTML = `<option value="">默认 Agent</option>` + agents.map(a =>
`<option value="${escapeAttr(a.id)}" ${a.id === (job?.agentId || currentVal) ? 'selected' : ''}>${escapeHtml(a.name || a.id)}</option>`
).join('')
}).catch(() => {})
// 快捷预设按钮
modal.querySelectorAll('.cron-shortcut').forEach(btn => {
btn.onclick = () => {
@@ -396,7 +404,7 @@ async function openTaskDialog(job, page, state) {
patch.schedule = { kind: 'cron', expr: schedule }
patch.payload = { kind: 'agentTurn', message }
if (agentId) patch.agentId = agentId
await wsClient.request('cron.update', { id: job.id, patch })
await wsClient.request('cron.update', { name: job.id, patch })
toast('任务已更新', 'success')
} else {
const params = {

View File

@@ -157,6 +157,14 @@ function renderStatCards(page, services, version, agents, config) {
<div class="stat-card-value">${runningCount}/${services.length}</div>
<div class="stat-card-meta">存活率 ${services.length ? Math.round(runningCount / services.length * 100) : 0}%</div>
</div>
<div class="stat-card stat-card-clickable" id="card-control-ui" title="打开 OpenClaw 原生控制面板">
<div class="stat-card-header">
<span class="stat-card-label">Control UI</span>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="opacity:0.5"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
</div>
<div class="stat-card-value" style="font-size:var(--font-size-sm)">OpenClaw 原生面板</div>
<div class="stat-card-meta">${gw?.running ? '点击打开浏览器' : 'Gateway 未运行'}</div>
</div>
`
}
@@ -178,75 +186,95 @@ function renderOverview(page, services, mcpConfig, backups, config, agents) {
const latestBackup = backups.length > 0 ? backups.sort((a,b) => b.created_at - a.created_at)[0] : null
const lastUpdate = config?.meta?.lastTouchedVersion || '未知'
const gwPort = config?.gateway?.port || 18789
const primaryModel = config?.agents?.defaults?.model?.primary || '未设置'
containerEl.innerHTML = `
<div class="dashboard-overview">
<div class="overview-section">
<div class="overview-item">
<div class="overview-label">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect><line x1="8" y1="21" x2="16" y2="21"></line><line x1="12" y1="17" x2="12" y2="21"></line></svg>
Gateway 核心网关
<div class="overview-grid">
<div class="overview-card" data-nav="/gateway">
<div class="overview-card-icon" style="color:${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-actions">
<span class="overview-status" style="color: ${gw?.running ? 'var(--success)' : 'var(--error)'}">
${gw?.running ? '运行中' : '已停止'}
</span>
<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 ? '运行中' : '已停止'}</div>
<div class="overview-card-meta">端口 ${gwPort} ${gw?.pid ? '· PID ' + gw.pid : ''}</div>
</div>
<div class="overview-card-actions">
${gw?.running
? '<button class="btn btn-danger btn-xs" data-action="stop-gw">停止</button><button class="btn btn-secondary btn-xs" data-action="restart-gw">重启</button>'
: '<button class="btn btn-primary btn-xs" data-action="start-gw">启动</button>'
}
</div>
</div>
<div class="overview-item">
<div class="overview-label">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 2 7 12 12 22 7 12 2"></polygon><polyline points="2 17 12 22 22 17"></polyline><polyline points="2 12 12 17 22 12"></polyline></svg>
MCP 扩展工具
</div>
<div class="overview-value">
${mcpCount} 个已挂载
</div>
</div>
</div>
<div class="overview-section">
<div class="overview-item">
<div class="overview-label">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="17 8 12 3 7 8"></polyline><line x1="12" y1="3" x2="12" y2="15"></line></svg>
最近备份
<div class="overview-card" data-nav="/models">
<div class="overview-card-icon" style="color:var(--accent)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><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>
</div>
<div class="overview-value">
${latestBackup ? formatDate(latestBackup.created_at) : '从无备份'}
<div class="overview-card-body">
<div class="overview-card-title">主模型</div>
<div class="overview-card-value" style="font-size:var(--font-size-sm)">${primaryModel}</div>
<div class="overview-card-meta">并发上限 ${config?.agents?.defaults?.maxConcurrent || 4}</div>
</div>
</div>
<div class="overview-item">
<div class="overview-label">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>
配置版本标识
<div class="overview-card" data-nav="/skills">
<div class="overview-card-icon" style="color:var(--warning)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>
</div>
<div class="overview-value">
${lastUpdate}
<div class="overview-card-body">
<div class="overview-card-title">MCP 工具</div>
<div class="overview-card-value">${mcpCount} 个</div>
<div class="overview-card-meta">已挂载扩展</div>
</div>
</div>
<div class="overview-item">
<div class="overview-label">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>
并行推理队列最大值
<div class="overview-card" data-nav="/services">
<div class="overview-card-icon" style="color:var(--text-tertiary)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
</div>
<div class="overview-value">
${config?.agents?.defaults?.maxConcurrent || 4}
<div class="overview-card-body">
<div class="overview-card-title">最近备份</div>
<div class="overview-card-value" style="font-size:var(--font-size-sm)">${latestBackup ? formatDate(latestBackup.created_at) : '从无备份'}</div>
<div class="overview-card-meta">${backups.length} 个备份文件</div>
</div>
</div>
<div class="overview-item">
<div class="overview-label">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><line x1="9" y1="3" x2="9" y2="21"></line></svg>
工作区文件隔离
<div class="overview-card" data-nav="/agents">
<div class="overview-card-icon" style="color:var(--success)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87"/><path d="M16 3.13a4 4 0 010 7.75"/></svg>
</div>
<div class="overview-value" style="color: ${agents.some(a => a.workspace) ? 'var(--success)' : 'var(--text-tertiary)'}">
${agents.filter(a => a.workspace).length} 个 Agent 启用
<div class="overview-card-body">
<div class="overview-card-title">Agent 舰队</div>
<div class="overview-card-value">${agents.length} 个</div>
<div class="overview-card-meta">${agents.filter(a => a.workspace).length} 个独立工作区</div>
</div>
</div>
<div class="overview-card">
<div class="overview-card-icon" style="color:var(--text-tertiary)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</div>
<div class="overview-card-body">
<div class="overview-card-title">配置版本</div>
<div class="overview-card-value" style="font-size:var(--font-size-sm)">${lastUpdate}</div>
<div class="overview-card-meta">openclaw.json</div>
</div>
</div>
</div>
</div>
`
// 概览卡片点击导航
containerEl.querySelectorAll('[data-nav]').forEach(card => {
card.style.cursor = 'pointer'
card.addEventListener('click', (e) => {
if (e.target.closest('button')) return
navigate(card.dataset.nav)
})
})
}
function renderLogs(page, logs) {
@@ -265,6 +293,31 @@ function bindActions(page) {
const btnUpdate = page.querySelector('#btn-check-update')
const btnCreateBackup = page.querySelector('#btn-create-backup')
// Control UI 卡片点击 → 打开 OpenClaw 原生面板(用事件委托,因为卡片是动态渲染的)
page.addEventListener('click', async (e) => {
const card = e.target.closest('#card-control-ui')
if (!card) return
if (e.target.closest('button')) return
try {
const config = await api.readOpenclawConfig()
const port = config?.gateway?.port || 18789
const url = `http://127.0.0.1:${port}`
// 尝试多种方式打开浏览器
if (window.__TAURI_INTERNALS__) {
try {
const { open } = await import('@tauri-apps/plugin-shell')
await open(url)
} catch {
window.open(url, '_blank')
}
} else {
window.open(url, '_blank')
}
} catch (e2) {
toast('打开 Control UI 失败: ' + (e2.message || e2), 'error')
}
})
// 概览区域的 Gateway 启动/停止/重启 + ClawApp 导航
page.addEventListener('click', async (e) => {
const actionBtn = e.target.closest('[data-action]')

View File

@@ -21,6 +21,7 @@ const PROVIDER_PRESETS = [
{ key: 'anthropic', label: 'Anthropic 官方', baseUrl: 'https://api.anthropic.com', api: 'anthropic-messages' },
{ key: 'deepseek', label: 'DeepSeek', baseUrl: 'https://api.deepseek.com/v1', api: 'openai-completions' },
{ key: 'google', label: 'Google Gemini', baseUrl: 'https://generativelanguage.googleapis.com/v1beta', api: 'google-gemini' },
{ key: 'ollama', label: 'Ollama (本地)', baseUrl: 'http://127.0.0.1:11434/v1', api: 'openai-completions' },
]
// gpt.qt.cool 推广配置
@@ -31,16 +32,7 @@ const QTCOOL = {
usageUrl: 'https://gpt.qt.cool/user?key=',
providerKey: 'qtcool',
api: 'openai-completions',
models: [
{ id: 'gpt-5.2-codex', name: 'GPT-5.2 Codex', contextWindow: 128000, reasoning: true },
{ id: 'gpt-5.2', name: 'GPT-5.2', contextWindow: 128000 },
{ id: 'gpt-5.1-codex-max', name: 'GPT-5.1 Codex Max', contextWindow: 128000, reasoning: true },
{ id: 'gpt-5.1-codex-mini', name: 'GPT-5.1 Codex Mini', contextWindow: 128000, reasoning: true },
{ id: 'gpt-5.1-codex', name: 'GPT-5.1 Codex', contextWindow: 128000, reasoning: true },
{ id: 'gpt-5.1', name: 'GPT-5.1', contextWindow: 128000 },
{ id: 'gpt-5-codex', name: 'GPT-5 Codex', contextWindow: 128000, reasoning: true },
{ id: 'gpt-5', name: 'GPT-5', contextWindow: 128000 },
]
models: [] // 不使用硬编码模型列表,始终从 API 动态获取最新列表
}
// 常用模型预设(按服务商分组)
@@ -62,6 +54,11 @@ const MODEL_PRESETS = {
{ id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', contextWindow: 1000000, reasoning: true },
{ id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', contextWindow: 1000000 },
],
ollama: [
{ id: 'qwen2.5:7b', name: 'Qwen 2.5 7B', contextWindow: 32768 },
{ id: 'llama3.2', name: 'Llama 3.2', contextWindow: 8192 },
{ id: 'gemma3', name: 'Gemma 3', contextWindow: 32768 },
],
}
export async function render() {
@@ -133,6 +130,15 @@ async function loadConfig(page, state) {
const listEl = page.querySelector('#providers-list')
try {
state.config = await api.readOpenclawConfig()
// 自动修复现有配置中的 baseUrl如 Ollama 缺少 /v1一次性迁移
const before = JSON.stringify(state.config?.models?.providers || {})
normalizeProviderUrls(state.config)
const after = JSON.stringify(state.config?.models?.providers || {})
if (before !== after) {
console.log('[models] 自动修复了服务商 baseUrl正在保存...')
await api.writeOpenclawConfig(state.config)
toast('已自动修复模型接口地址(如 Ollama /v1', 'info')
}
renderDefaultBar(page, state)
renderProviders(page, state)
} catch (e) {
@@ -408,11 +414,41 @@ function autoSave(state) {
_saveTimer = setTimeout(() => doAutoSave(state), 300)
}
/** 保存前规范化所有服务商的 baseUrl确保 Gateway 能正确调用 */
function normalizeProviderUrls(config) {
const providers = config?.models?.providers
if (!providers) return
for (const [, p] of Object.entries(providers)) {
if (!p.baseUrl) continue
let url = p.baseUrl.replace(/\/+$/, '')
// 去掉尾部的已知端点路径(用户可能粘贴了完整 URL
for (const suffix of ['/api/chat', '/api/generate', '/api/tags', '/api', '/chat/completions', '/completions', '/responses', '/messages', '/models']) {
if (url.endsWith(suffix)) { url = url.slice(0, -suffix.length); break }
}
url = url.replace(/\/+$/, '')
const apiType = (p.api || 'openai-completions').toLowerCase()
if (apiType === 'anthropic-messages') {
if (!url.endsWith('/v1')) url += '/v1'
} else if (apiType !== 'google-gemini') {
// Ollama 端口检测11434 默认需要加 /v1
if (/:11434$/.test(url)) url += '/v1'
// 其他 OpenAI 兼容: 确保有 /v1
if (!url.endsWith('/v1')) {
const idx = url.indexOf('/v1/')
if (idx >= 0) url = url.slice(0, idx + 3)
else url += '/v1'
}
}
p.baseUrl = url
}
}
// 仅保存配置,不重启 Gateway用于测试结果等元数据持久化
async function saveConfigOnly(state) {
try {
const primary = getCurrentPrimary(state.config)
if (primary) applyDefaultModel(state)
normalizeProviderUrls(state.config)
await api.writeOpenclawConfig(state.config)
} catch (e) {
toast('保存失败: ' + e, 'error')
@@ -423,6 +459,7 @@ async function doAutoSave(state) {
try {
const primary = getCurrentPrimary(state.config)
if (primary) applyDefaultModel(state)
normalizeProviderUrls(state.config)
await api.writeOpenclawConfig(state.config)
// 重启 Gateway 使配置生效Gateway 不支持 SIGHUP 热重载)
@@ -770,6 +807,11 @@ function bindTopActions(page, state) {
btn.innerHTML = `${icon('zap', 14)} 一键添加全部模型`
btn.disabled = false
if (!models.length) {
toast('无法获取模型列表,请检查网络或稍后重试', 'error')
return
}
pushUndo(state)
if (!state.config.models) state.config.models = {}
if (!state.config.models.providers) state.config.models.providers = {}
@@ -832,7 +874,7 @@ function addProvider(page, state) {
<div class="form-group">
<label class="form-label">接口地址</label>
<input class="form-input" data-name="baseUrl" placeholder="https://api.openai.com/v1">
<div class="form-hint">模型服务的 API 地址,通常以 /v1 结尾</div>
<div class="form-hint">模型服务的 API 地址,通常以 /v1 结尾Ollama 可直接填 http://127.0.0.1:11434</div>
</div>
<div class="form-group">
<label class="form-label">密钥 (API Key)</label>
@@ -844,7 +886,7 @@ function addProvider(page, state) {
<select class="form-input" data-name="api">
${API_TYPES.map(t => `<option value="${t.value}">${t.label}</option>`).join('')}
</select>
<div class="form-hint">大多数中转站选「OpenAI 兼容」即可</div>
<div class="form-hint">大多数中转站和 Ollama 选「OpenAI 兼容」即可</div>
</div>
<div class="modal-actions">
<button class="btn btn-secondary btn-sm" data-action="cancel">取消</button>
@@ -903,12 +945,12 @@ function editProvider(page, state, providerKey) {
showModal({
title: `编辑服务商: ${providerKey}`,
fields: [
{ name: 'baseUrl', label: '接口地址', value: p.baseUrl || '', hint: '模型服务的 API 地址,通常以 /v1 结尾' },
{ name: 'baseUrl', label: '接口地址', value: p.baseUrl || '', hint: '模型服务的 API 地址,通常以 /v1 结尾Ollama 可直接填 http://127.0.0.1:11434' },
{ name: 'apiKey', label: '密钥 (API Key)', value: p.apiKey || '', hint: '修改后自动保存生效' },
{
name: 'api', label: '接口类型', type: 'select', value: p.api || 'openai-completions',
options: API_TYPES,
hint: '大多数中转站选「OpenAI 兼容」即可',
hint: '大多数中转站和 Ollama 选「OpenAI 兼容」即可',
},
],
onConfirm: ({ baseUrl, apiKey, api: apiType }) => {
@@ -1157,7 +1199,7 @@ async function handleBatchTest(section, state, providerKey) {
const start = Date.now()
try {
await api.testModel(provider.baseUrl, provider.apiKey || '', modelId)
await api.testModel(provider.baseUrl, provider.apiKey || '', modelId, provider.api || 'openai-completions')
const elapsed = Date.now() - start
if (model && typeof model === 'object') {
model.latency = elapsed
@@ -1215,7 +1257,7 @@ async function fetchRemoteModels(btn, page, state, providerKey) {
btn.textContent = '获取中...'
try {
const remoteIds = await api.listRemoteModels(provider.baseUrl, provider.apiKey || '')
const remoteIds = await api.listRemoteModels(provider.baseUrl, provider.apiKey || '', provider.api || 'openai-completions')
btn.disabled = false
btn.textContent = '获取列表'
@@ -1315,7 +1357,7 @@ async function testModel(btn, state, providerKey, idx) {
const start = Date.now()
try {
const reply = await api.testModel(provider.baseUrl, provider.apiKey || '', modelId)
const reply = await api.testModel(provider.baseUrl, provider.apiKey || '', modelId, provider.api || 'openai-completions')
const elapsed = Date.now() - start
// 记录到模型对象
if (typeof model === 'object') {

View File

@@ -2,7 +2,7 @@
* 初始设置页面 — openclaw 未安装时的引导
* 自动检测环境 → 版本选择 → 一键安装 → 自动跳转
*/
import { api } from '../lib/tauri-api.js'
import { api, invalidate } from '../lib/tauri-api.js'
import { showUpgradeModal } from '../components/modal.js'
import { toast } from '../components/toast.js'
import { setUpgrading, isMacPlatform } from '../lib/app-state.js'
@@ -45,15 +45,20 @@ async function runDetect(page) {
<div class="stat-card loading-placeholder" style="height:48px"></div>
<div class="stat-card loading-placeholder" style="height:48px;margin-top:8px"></div>
<div class="stat-card loading-placeholder" style="height:48px;margin-top:8px"></div>
<div class="stat-card loading-placeholder" style="height:48px;margin-top:8px"></div>
`
// 并行检测 Node.js、OpenClaw CLI、配置文件
const [nodeRes, clawRes, configRes] = await Promise.allSettled([
// 清除缓存,确保拿到最新检测结果
invalidate('check_node', 'check_git', 'get_services_status', 'check_installation')
// 并行检测 Node.js、Git、OpenClaw CLI、配置文件
const [nodeRes, gitRes, clawRes, configRes] = await Promise.allSettled([
api.checkNode(),
api.checkGit(),
api.getServicesStatus(),
api.checkInstallation(),
])
const node = nodeRes.status === 'fulfilled' ? nodeRes.value : { installed: false }
const git = gitRes.status === 'fulfilled' ? gitRes.value : { installed: false }
const cliOk = clawRes.status === 'fulfilled'
&& clawRes.value?.length > 0
&& clawRes.value[0]?.cli_installed !== false
@@ -64,7 +69,6 @@ async function runDetect(page) {
try {
const initResult = await api.initOpenclawConfig()
if (initResult?.created) {
// 重新检测配置
config = await api.checkInstallation()
}
} catch (e) {
@@ -72,7 +76,12 @@ async function runDetect(page) {
}
}
renderSteps(page, { node, cliOk, config })
// Git 已安装时,自动配置 HTTPS 替代 SSH静默执行
if (git.installed) {
api.configureGitHttps().catch(() => {})
}
renderSteps(page, { node, git, cliOk, config })
}
function stepIcon(ok) {
@@ -80,9 +89,10 @@ function stepIcon(ok) {
return `<span style="color:${color};font-weight:700;width:18px;display:inline-block">${ok ? '✓' : '✗'}</span>`
}
function renderSteps(page, { node, cliOk, config }) {
function renderSteps(page, { node, git, cliOk, config }) {
const stepsEl = page.querySelector('#setup-steps')
const nodeOk = node.installed
const gitOk = git?.installed || false
const allOk = nodeOk && cliOk && config.installed
let html = ''
@@ -105,7 +115,7 @@ function renderSteps(page, { node, cliOk, config }) {
${isMacPlatform()
? `macOS 上从 Finder 启动可能找不到 Node.js。试试关掉 ClawPanel 后从终端启动:<br>
<code style="background:var(--bg-secondary);padding:2px 6px;border-radius:3px;user-select:all">open /Applications/ClawPanel.app</code>`
: `安装 Node.js 后需要<strong>重启 ClawPanel</strong>,新的环境变量才能生效`
: `安装 Node.js 后点击「重新检测」或使用下方「自动扫描」,无需重启`
}
<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)} 自动扫描</button>
@@ -122,7 +132,31 @@ function renderSteps(page, { node, cliOk, config }) {
</div>
`
// 第二步:OpenClaw CLI
// 第二步: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)} Git 版本管理
</div>
${gitOk
? `<p style="color:var(--success);font-size:var(--font-size-sm)">已安装 ${git.version || ''}</p>
<p style="font-size:var(--font-size-xs);color:var(--text-tertiary);margin-top:4px">✅ 已自动配置 Git 使用 HTTPS避免 SSH 连接问题)</p>`
: `<p style="color:var(--text-secondary);font-size:var(--font-size-sm);margin-bottom:var(--space-sm);line-height:1.5">
部分依赖需要 Git 下载源码。点击下方按钮自动安装,如果失败请手动安装。
</p>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="btn btn-primary btn-sm" id="btn-auto-install-git">一键安装 Git</button>
<a class="btn btn-secondary btn-sm" href="https://git-scm.com/downloads" target="_blank" rel="noopener">手动下载</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">
<strong>没有 Git 也能安装?</strong> 大部分情况下可以,但个别依赖可能需要 Git。建议安装以避免问题。
</div>`
}
</div>
`
// 第三步OpenClaw CLI
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">
@@ -134,7 +168,7 @@ function renderSteps(page, { node, cliOk, config }) {
}
</div>
`
// 第步:配置文件
// 第步:配置文件
html += `
<div class="config-section" style="text-align:left;${cliOk ? '' : 'opacity:0.4;pointer-events:none'}">
<div class="config-section-title" style="display:flex;align-items:center;gap:4px">
@@ -176,6 +210,22 @@ function renderSteps(page, { node, cliOk, config }) {
// 全部就绪 → 进入面板
if (allOk) {
html += `
<div class="config-section" style="text-align:left;margin-top:var(--space-md)">
<div class="config-section-title">下一步建议</div>
<div style="color:var(--text-secondary);font-size:var(--font-size-sm);line-height:1.7">
当前仅表示运行环境已经就绪,并不代表已经可以直接聊天。通常还需要继续完成以下步骤:
<ol style="margin:8px 0 0 18px;padding:0">
<li>前往「模型配置」添加至少一个可用模型,并确认主模型已设置</li>
<li>前往「Gateway」确认服务已启动</li>
<li>如需飞书、钉钉、QQ 等消息渠道,请到「消息渠道」完成接入与配对</li>
</ol>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:10px">
<button class="btn btn-secondary btn-sm" id="btn-goto-models">配置模型</button>
<button class="btn btn-secondary btn-sm" id="btn-goto-gateway">Gateway 设置</button>
<button class="btn btn-secondary btn-sm" id="btn-goto-channels">消息渠道</button>
</div>
</div>
<div style="margin-top:var(--space-lg)">
<button class="btn btn-primary" id="btn-enter" style="min-width:200px">进入面板</button>
</div>
@@ -183,7 +233,7 @@ function renderSteps(page, { node, cliOk, config }) {
}
stepsEl.innerHTML = html
bindEvents(page, nodeOk, { node, cliOk, config })
bindEvents(page, nodeOk, { node, git, cliOk, config })
}
function renderInstallSection() {
@@ -220,6 +270,7 @@ function renderInstallSection() {
<div style="font-weight:600;margin-bottom:4px">WSL 中使用 Web 版:</div>
<div style="margin-bottom:2px;opacity:0.8">打开 WSL 终端,一键部署 ClawPanel Web 版:</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">国内用户如无法访问 GitHub<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">部署后在浏览器访问 WSL 的 IP 即可管理。</div>
</div>
` : ''}
@@ -228,11 +279,13 @@ function renderInstallSection() {
<div style="margin-bottom:2px;opacity:0.8">在容器内安装 OpenClaw + ClawPanel Web 版:</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">国内镜像:<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">远程服务器:</div>
<div style="margin-bottom:2px;opacity:0.8">SSH 登录服务器后执行:</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">国内镜像:<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>
@@ -275,10 +328,12 @@ function renderInstallSection() {
`
}
function buildSetupProblemPrompt({ node, cliOk, config }) {
function buildSetupProblemPrompt({ node, git, cliOk, config }) {
const problems = []
if (!node.installed) problems.push('- Node.js 未安装或未检测到')
else problems.push(`- Node.js 已安装: ${node.version || '版本未知'}`)
if (!git?.installed) problems.push('- Git 未安装')
else problems.push(`- Git 已安装: ${git.version || '版本未知'}`)
if (!cliOk) problems.push('- OpenClaw CLI 未安装')
else problems.push('- OpenClaw CLI 已安装')
if (!config.installed) problems.push('- 配置文件不存在')
@@ -310,6 +365,52 @@ function bindEvents(page, nodeOk, detectState) {
page.querySelector('#btn-enter')?.addEventListener('click', () => {
window.location.hash = '/dashboard'
})
page.querySelector('#btn-goto-models')?.addEventListener('click', () => {
window.location.hash = '/models'
})
page.querySelector('#btn-goto-gateway')?.addEventListener('click', () => {
window.location.hash = '/gateway'
})
page.querySelector('#btn-goto-channels')?.addEventListener('click', () => {
window.location.hash = '/channels'
})
// 一键安装 Git
page.querySelector('#btn-auto-install-git')?.addEventListener('click', async () => {
const btn = page.querySelector('#btn-auto-install-git')
const resultEl = page.querySelector('#git-install-result')
btn.disabled = true
btn.textContent = '安装中...'
if (resultEl) {
resultEl.style.display = 'block'
resultEl.innerHTML = '<span style="color:var(--text-tertiary)">正在安装 Git请稍候...</span>'
}
try {
const msg = await api.autoInstallGit()
if (resultEl) resultEl.innerHTML = `<span style="color:var(--success)">✓ ${msg}</span>`
toast('Git 安装成功', 'success')
// 安装成功后自动配置 HTTPS
api.configureGitHttps().catch(() => {})
setTimeout(() => runDetect(page), 1000)
} catch (e) {
const errMsg = String(e.message || e)
if (resultEl) {
resultEl.innerHTML = `<div>
<span style="color:var(--danger)">自动安装失败: ${errMsg}</span>
<p style="margin-top:6px;font-size:var(--font-size-xs);color:var(--text-secondary);line-height:1.5">
请手动安装 Git<br>
<strong>Windows:</strong> 下载 <a href="https://git-scm.com/downloads" target="_blank" style="color:var(--accent)">git-scm.com</a> 安装包<br>
<strong>macOS:</strong> 在终端执行 <code style="background:var(--bg-secondary);padding:2px 4px;border-radius:3px">xcode-select --install</code> 或 <code style="background:var(--bg-secondary);padding:2px 4px;border-radius:3px">brew install git</code><br>
<strong>Linux:</strong> <code style="background:var(--bg-secondary);padding:2px 4px;border-radius:3px">sudo apt install git</code> 或 <code style="background:var(--bg-secondary);padding:2px 4px;border-radius:3px">sudo yum install git</code>
</p>
</div>`
}
toast('Git 自动安装失败,请手动安装', 'warning')
} finally {
btn.disabled = false
btn.textContent = '一键安装 Git'
}
})
// 一键初始化配置
page.querySelector('#btn-init-config')?.addEventListener('click', async () => {
@@ -356,7 +457,7 @@ function bindEvents(page, nodeOk, detectState) {
b.addEventListener('click', async () => {
await api.saveCustomNodePath(b.dataset.path)
toast('Node.js 路径已保存,正在重新检测...', 'success')
setTimeout(() => window.location.reload(), 500)
setTimeout(() => runDetect(page), 300)
})
})
}
@@ -382,7 +483,7 @@ function bindEvents(page, nodeOk, detectState) {
await api.saveCustomNodePath(dir)
resultEl.innerHTML = `<span style="color:var(--success)">✓ 找到 Node.js ${result.version},路径已保存</span>`
toast('Node.js 路径已保存,正在重新检测...', 'success')
setTimeout(() => window.location.reload(), 500)
setTimeout(() => runDetect(page), 300)
} else {
resultEl.innerHTML = `<span style="color:var(--warning)">该目录下未找到 node 可执行文件,请确认路径正确。</span>`
}