Files
clawpanel/src/components/ai-drawer.js
晴天 394813a96c feat: v0.9.1 — 面板设置页、网络代理、后台安装、模型服务商扩展、多项修复
新功能:
- 新增独立面板设置页面(网络代理 + 代理测试 + 模型代理开关 + npm源)
- 网络代理支持:下载类操作走代理,自动绕过内网地址
- 安装/升级/卸载改为后台执行,不再阻塞界面
- 全局任务状态栏:关闭弹窗后顶部显示进度,可重新查看日志
- 安装/卸载完成后自动刷新界面状态
- 新增多个模型服务商快捷配置(硅基流动、火山引擎、阿里云百炼、智谱AI、MiniMax、NVIDIA NIM、胜算云)
- AI助手浮动按钮恢复,首次提示可拖动,实时聊天页隐藏

修复:
- 修复版本更新误判(本地版本高于远端不再误弹更新)
- 修复Windows下nvm/自定义Node路径CLI检测
- 修复npm EEXIST文件冲突(--force + 安装前自动清理)
- 修复汉化版-zh.x后缀版本比较错误
- 修复模型URL自动拼接/v1问题
- 修复切换版本后Gateway重装失败(PATH缓存刷新)
- 修复切换助手服务商时旧模型名残留

优化:
- macOS图标改用docs/logo.png统一生成
- 内置推荐版本号更新到OpenClaw 2026.3.13
- 错误诊断增强(EEXIST识别)
- 弹窗标题根据操作类型显示
- 新增版本维护文档
2026-03-14 19:57:22 +08:00

236 lines
6.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 全局 AI 助手浮动按钮FAB
* 右下角可拖动按钮 → 点击导航到 AI 助手页面(复用完整功能)
* 自动注入当前页面上下文到 AI 助手会话
*/
const BOT_ICON = '<svg viewBox="0 0 24 24"><path d="M12 8V4H8"/><rect x="5" y="8" width="14" height="12" rx="2"/><path d="M9 13h0"/><path d="M15 13h0"/><path d="M10 17h4"/></svg>'
const POS_KEY = 'clawpanel-fab-pos'
const ENABLE_AI_FAB = true
// ── 页面上下文收集器注册表 ──
const _contextProviders = {}
/**
* 注册页面上下文提供器
* @param {string} route - 路由路径,如 '/chat-debug'
* @param {function} provider - 返回 { label, detail } 的函数(可 async
*/
export function registerPageContext(route, provider) {
_contextProviders[route] = provider
}
// ── 单例 ──
let _fab = null
/** 初始化 FAB */
export function initAIFab() {
if (!ENABLE_AI_FAB) {
document.querySelectorAll('.ai-fab').forEach(el => el.remove())
_fab = null
return null
}
if (_fab) return _fab
_fab = createFab()
showDragHintOnce(_fab.el)
return _fab
}
/** 导航到 AI 助手并注入错误上下文(显示为可操作的 banner而非自动发送 */
export function openAIDrawerWithError(errorCtx) {
sessionStorage.setItem('assistant-error-context', JSON.stringify({
scene: errorCtx.scene || '',
title: errorCtx.title || '操作失败',
hint: errorCtx.hint || '',
error: truncate(errorCtx.error || '', 3000),
ts: Date.now(),
}))
// 不自动导航 — FAB 按钮会出现红点提示,用户主动点击时跳转
// 如果用户已在助手页,也会实时检测到
if (getCurrentRoute() !== '/assistant') {
if (_fab?.el) {
_fab.el.classList.add('has-error')
} else {
import('./toast.js')
.then(({ toast }) => toast('已保存诊断上下文,可从侧边栏进入「晴辰助手」继续处理', 'info'))
.catch(() => {})
}
} else {
// 已在助手页 → 直接触发 banner 显示
window.dispatchEvent(new CustomEvent('assistant-error-injected'))
}
}
function truncate(str, max) {
if (!str || str.length <= max) return str
return str.slice(0, max) + '\n... (截断)'
}
// ── 创建 FAB ──
function createFab() {
const fab = document.createElement('button')
fab.className = 'ai-fab'
fab.title = 'AI 助手'
fab.innerHTML = BOT_ICON
document.body.appendChild(fab)
// 恢复保存的位置
restorePosition(fab)
// ── 拖动逻辑 ──
let _dragging = false
let _dragMoved = false
let _startX = 0, _startY = 0
let _fabX = 0, _fabY = 0
function onPointerDown(e) {
if (e.button !== 0) return
_dragging = true
_dragMoved = false
_startX = e.clientX
_startY = e.clientY
const rect = fab.getBoundingClientRect()
_fabX = rect.left
_fabY = rect.top
fab.style.transition = 'none'
fab.setPointerCapture(e.pointerId)
e.preventDefault()
}
function onPointerMove(e) {
if (!_dragging) return
const dx = e.clientX - _startX
const dy = e.clientY - _startY
if (!_dragMoved && Math.abs(dx) < 4 && Math.abs(dy) < 4) return
_dragMoved = true
fab.classList.add('dragging')
// 计算新位置(限制在视口内)
const vw = window.innerWidth
const vh = window.innerHeight
const size = 48
let newX = Math.max(8, Math.min(vw - size - 8, _fabX + dx))
let newY = Math.max(8, Math.min(vh - size - 8, _fabY + dy))
fab.style.left = newX + 'px'
fab.style.top = newY + 'px'
fab.style.right = 'auto'
fab.style.bottom = 'auto'
}
function onPointerUp(e) {
if (!_dragging) return
_dragging = false
fab.classList.remove('dragging')
fab.style.transition = ''
if (_dragMoved) {
// 吸附到最近的边(左/右)
const rect = fab.getBoundingClientRect()
const vw = window.innerWidth
const vh = window.innerHeight
const snapRight = rect.left > vw / 2
const y = Math.max(8, Math.min(vh - 56, rect.top))
if (snapRight) {
fab.style.left = 'auto'
fab.style.right = '24px'
} else {
fab.style.left = '24px'
fab.style.right = 'auto'
}
fab.style.top = y + 'px'
fab.style.bottom = 'auto'
// 保存位置
savePosition(snapRight ? 'right' : 'left', y)
} else {
// 没有拖动 → 点击
handleClick()
}
}
fab.addEventListener('pointerdown', onPointerDown)
document.addEventListener('pointermove', onPointerMove)
document.addEventListener('pointerup', onPointerUp)
// ── 点击 → 导航到 AI 助手 ──
async function handleClick() {
const route = getCurrentRoute()
// 已经在 AI 助手页面,不做任何操作
if (route === '/assistant') return
// 清除红点
fab.classList.remove('has-error')
// 如果没有错误上下文待处理,收集当前页面上下文
if (!sessionStorage.getItem('assistant-error-context')) {
const provider = _contextProviders[route]
if (provider) {
try {
const ctx = await provider()
if (ctx?.detail) {
const prompt = `以下是当前页面的上下文信息,请根据情况提供帮助:\n\n${ctx.detail}`
sessionStorage.setItem('assistant-auto-prompt', prompt)
}
} catch (e) {
console.warn('[ai-fab] 上下文收集失败:', e)
}
}
}
window.location.hash = '#/assistant'
}
// ── 路由变化时隐藏/显示(助手页和实时聊天页隐藏) ──
const HIDE_ROUTES = ['/assistant', '/chat']
function updateVisibility() {
const route = getCurrentRoute()
fab.style.display = HIDE_ROUTES.includes(route) ? 'none' : 'flex'
}
window.addEventListener('hashchange', updateVisibility)
updateVisibility()
return { el: fab }
}
function getCurrentRoute() {
return (window.location.hash.replace('#', '') || '/dashboard').split('?')[0]
}
function savePosition(side, top) {
try {
localStorage.setItem(POS_KEY, JSON.stringify({ side, top }))
} catch {}
}
function restorePosition(fab) {
try {
const raw = localStorage.getItem(POS_KEY)
if (!raw) return
const { side, top } = JSON.parse(raw)
if (side === 'left') {
fab.style.left = '24px'
fab.style.right = 'auto'
}
if (typeof top === 'number') {
fab.style.top = top + 'px'
fab.style.bottom = 'auto'
}
} catch {}
}
const HINT_KEY = 'clawpanel-fab-hint-shown'
function showDragHintOnce(el) {
if (!el || localStorage.getItem(HINT_KEY)) return
const tip = document.createElement('div')
tip.className = 'ai-fab-hint'
tip.textContent = '长按可拖动'
el.appendChild(tip)
localStorage.setItem(HINT_KEY, '1')
setTimeout(() => tip.remove(), 4000)
}