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

@@ -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;')
}