mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-11 10:00:04 +08:00
新功能: - 新增独立面板设置页面(网络代理 + 代理测试 + 模型代理开关 + 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识别) - 弹窗标题根据操作类型显示 - 新增版本维护文档
236 lines
6.7 KiB
JavaScript
236 lines
6.7 KiB
JavaScript
/**
|
||
* 全局 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)
|
||
}
|