mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-06-07 16:49:42 +08:00
feat: AI助手支持 Anthropic/Gemini 原生API + 修复Windows终端闪烁
- AI助手新增 API 类型选择器(OpenAI兼容 / Anthropic原生 / Google Gemini) - 实现 Anthropic Messages API 流式调用 + 工具调用(tool_use/tool_result) - 实现 Google Gemini streamGenerateContent + 工具调用(functionCall) - 设置弹窗动态切换 placeholder 和提示文本 - 测试按钮和模型拉取适配三种 API 类型 - 修复 Windows 上 Gateway 状态轮询导致终端反复闪烁(execSync/spawn 加 windowsHide) - 默认密码统一为 123456 + 改密码后自动移除顶部横幅 - 后端 API 增加暴力破解保护、配置缓存、请求体大小限制
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
import { navigate, getCurrentRoute } from '../router.js'
|
||||
import { toggleTheme, getTheme } from '../lib/theme.js'
|
||||
import { isOpenclawReady } from '../lib/app-state.js'
|
||||
import { version as APP_VERSION } from '../../package.json'
|
||||
|
||||
const NAV_ITEMS_FULL = [
|
||||
{
|
||||
@@ -22,6 +23,7 @@ const NAV_ITEMS_FULL = [
|
||||
{ route: '/models', label: '模型配置', icon: 'models' },
|
||||
{ route: '/agents', label: 'Agent 管理', icon: 'agents' },
|
||||
{ route: '/gateway', label: 'Gateway', icon: 'gateway' },
|
||||
{ route: '/security', label: '安全设置', icon: 'security' },
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -81,6 +83,7 @@ const ICONS = {
|
||||
extensions: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>',
|
||||
about: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>',
|
||||
assistant: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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"/><path d="M18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456z"/></svg>',
|
||||
security: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>',
|
||||
debug: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/><circle cx="12" cy="12" r="3"/></svg>',
|
||||
}
|
||||
|
||||
@@ -128,6 +131,10 @@ export function renderSidebar(el) {
|
||||
${isDark ? sunIcon : moonIcon}
|
||||
<span>${isDark ? '日间模式' : '夜间模式'}</span>
|
||||
</div>
|
||||
<div class="sidebar-meta">
|
||||
<a href="https://claw.qt.cool" target="_blank" rel="noopener" class="sidebar-link">claw.qt.cool</a>
|
||||
<span class="sidebar-version">v${APP_VERSION}</span>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
|
||||
@@ -113,6 +113,10 @@ async function webInvoke(cmd, args) {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(args),
|
||||
})
|
||||
if (resp.status === 401 && window.__clawpanel_show_login) {
|
||||
window.__clawpanel_show_login()
|
||||
throw new Error('需要登录')
|
||||
}
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json().catch(() => ({ error: `HTTP ${resp.status}` }))
|
||||
throw new Error(data.error || `HTTP ${resp.status}`)
|
||||
@@ -298,6 +302,10 @@ export const api = {
|
||||
deleteMemoryFile: (path, agentId) => { invalidate('list_memory_files'); return invoke('delete_memory_file', { path, agentId: agentId || null }) },
|
||||
exportMemoryZip: (category, agentId) => invoke('export_memory_zip', { category, agentId: agentId || null }),
|
||||
|
||||
// 面板配置 (clawpanel.json)
|
||||
readPanelConfig: () => invoke('read_panel_config'),
|
||||
writePanelConfig: (config) => invoke('write_panel_config', { config }),
|
||||
|
||||
// 安装/部署
|
||||
checkInstallation: () => cachedInvoke('check_installation', {}, 60000),
|
||||
initOpenclawConfig: () => { invalidate('check_installation'); return invoke('init_openclaw_config') },
|
||||
|
||||
146
src/main.js
146
src/main.js
@@ -7,6 +7,7 @@ import { initTheme } from './lib/theme.js'
|
||||
import { detectOpenclawStatus, isOpenclawReady, isGatewayRunning, onGatewayChange, startGatewayPoll, onGuardianGiveUp, resetAutoRestart } from './lib/app-state.js'
|
||||
import { wsClient } from './lib/ws-client.js'
|
||||
import { api } from './lib/tauri-api.js'
|
||||
import { version as APP_VERSION } from '../package.json'
|
||||
|
||||
// 样式
|
||||
import './style/variables.css'
|
||||
@@ -22,6 +23,130 @@ import './style/assistant.css'
|
||||
// 初始化主题
|
||||
initTheme()
|
||||
|
||||
// === 访问密码保护(Web + 桌面端通用) ===
|
||||
const isTauri = !!window.__TAURI_INTERNALS__
|
||||
|
||||
async function checkAuth() {
|
||||
if (isTauri) {
|
||||
// 桌面端:读 clawpanel.json,检查密码配置
|
||||
try {
|
||||
const { api } = await import('./lib/tauri-api.js')
|
||||
const cfg = await api.readPanelConfig()
|
||||
if (!cfg.accessPassword) return { ok: true }
|
||||
if (sessionStorage.getItem('clawpanel_authed') === '1') return { ok: true }
|
||||
// 默认密码:直接传给登录页,避免二次读取
|
||||
const defaultPw = (cfg.mustChangePassword && cfg.accessPassword) ? cfg.accessPassword : null
|
||||
return { ok: false, defaultPw }
|
||||
} catch { return { ok: true } }
|
||||
}
|
||||
// Web 模式
|
||||
try {
|
||||
const resp = await fetch('/__api/auth_check', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })
|
||||
const data = await resp.json()
|
||||
if (!data.required || data.authenticated) return { ok: true }
|
||||
return { ok: false, defaultPw: data.defaultPassword || null }
|
||||
} catch { return { ok: true } }
|
||||
}
|
||||
|
||||
const _logoSvg = `<svg class="login-logo" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<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"/>
|
||||
<path d="M18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456z"/>
|
||||
</svg>`
|
||||
|
||||
function _hideSplash() {
|
||||
const splash = document.getElementById('splash')
|
||||
if (splash) { splash.classList.add('hide'); setTimeout(() => splash.remove(), 500) }
|
||||
}
|
||||
|
||||
function showLoginOverlay(defaultPw) {
|
||||
const hasDefault = !!defaultPw
|
||||
const overlay = document.createElement('div')
|
||||
overlay.id = 'login-overlay'
|
||||
overlay.innerHTML = `
|
||||
<div class="login-card">
|
||||
${_logoSvg}
|
||||
<div class="login-title">ClawPanel</div>
|
||||
<div class="login-desc">${hasDefault
|
||||
? '首次使用,默认密码已自动填充<br><span style="font-size:12px;color:#6366f1;font-weight:600">登录后请前往「安全设置」修改密码</span>'
|
||||
: (isTauri ? '应用已锁定,请输入密码' : '请输入访问密码')}</div>
|
||||
<form id="login-form">
|
||||
<input class="login-input" type="${hasDefault ? 'text' : 'password'}" id="login-pw" placeholder="访问密码" autocomplete="current-password" autofocus value="${hasDefault ? defaultPw : ''}" />
|
||||
<button class="login-btn" type="submit">登 录</button>
|
||||
<div class="login-error" id="login-error"></div>
|
||||
</form>
|
||||
<div style="margin-top:20px;font-size:11px;color:#aaa;text-align:center">
|
||||
<a href="https://claw.qt.cool" target="_blank" rel="noopener" style="color:#aaa;text-decoration:none">claw.qt.cool</a>
|
||||
<span style="margin:0 6px">·</span>v${APP_VERSION}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
document.body.appendChild(overlay)
|
||||
_hideSplash()
|
||||
|
||||
return new Promise((resolve) => {
|
||||
overlay.querySelector('#login-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault()
|
||||
const pw = overlay.querySelector('#login-pw').value
|
||||
const btn = overlay.querySelector('.login-btn')
|
||||
const errEl = overlay.querySelector('#login-error')
|
||||
btn.disabled = true
|
||||
btn.textContent = '登录中...'
|
||||
errEl.textContent = ''
|
||||
try {
|
||||
if (isTauri) {
|
||||
// 桌面端:本地比对密码
|
||||
const { api } = await import('./lib/tauri-api.js')
|
||||
const cfg = await api.readPanelConfig()
|
||||
if (pw !== cfg.accessPassword) {
|
||||
errEl.textContent = '密码错误'
|
||||
btn.disabled = false
|
||||
btn.textContent = '登 录'
|
||||
return
|
||||
}
|
||||
sessionStorage.setItem('clawpanel_authed', '1')
|
||||
overlay.classList.add('hide')
|
||||
setTimeout(() => overlay.remove(), 400)
|
||||
if (cfg.accessPassword === '123456') {
|
||||
sessionStorage.setItem('clawpanel_must_change_pw', '1')
|
||||
}
|
||||
resolve()
|
||||
} else {
|
||||
// Web 模式:调后端
|
||||
const resp = await fetch('/__api/auth_login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: pw }),
|
||||
})
|
||||
const data = await resp.json()
|
||||
if (!resp.ok) {
|
||||
errEl.textContent = data.error || '登录失败'
|
||||
btn.disabled = false
|
||||
btn.textContent = '登 录'
|
||||
return
|
||||
}
|
||||
overlay.classList.add('hide')
|
||||
setTimeout(() => overlay.remove(), 400)
|
||||
if (data.mustChangePassword || data.defaultPassword === '123456') {
|
||||
sessionStorage.setItem('clawpanel_must_change_pw', '1')
|
||||
}
|
||||
resolve()
|
||||
}
|
||||
} catch (err) {
|
||||
errEl.textContent = '网络错误: ' + (err.message || err)
|
||||
btn.disabled = false
|
||||
btn.textContent = '登 录'
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 全局 401 拦截:API 返回 401 时弹出登录
|
||||
window.__clawpanel_show_login = async function() {
|
||||
if (document.getElementById('login-overlay')) return
|
||||
await showLoginOverlay()
|
||||
location.reload()
|
||||
}
|
||||
|
||||
const sidebar = document.getElementById('sidebar')
|
||||
const content = document.getElementById('content')
|
||||
|
||||
@@ -37,6 +162,7 @@ async function boot() {
|
||||
registerRoute('/gateway', () => import('./pages/gateway.js'))
|
||||
registerRoute('/memory', () => import('./pages/memory.js'))
|
||||
registerRoute('/extensions', () => import('./pages/extensions.js'))
|
||||
registerRoute('/security', () => import('./pages/security.js'))
|
||||
registerRoute('/about', () => import('./pages/about.js'))
|
||||
registerRoute('/assistant', () => import('./pages/assistant.js'))
|
||||
registerRoute('/setup', () => import('./pages/setup.js'))
|
||||
@@ -51,6 +177,19 @@ async function boot() {
|
||||
setTimeout(() => splash.remove(), 500)
|
||||
}
|
||||
|
||||
// 默认密码提醒横幅
|
||||
if (sessionStorage.getItem('clawpanel_must_change_pw') === '1') {
|
||||
const banner = document.createElement('div')
|
||||
banner.id = 'pw-change-banner'
|
||||
banner.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:999;background:linear-gradient(135deg,#6366f1,#8b5cf6);color:#fff;padding:10px 20px;display:flex;align-items:center;justify-content:center;gap:12px;font-size:13px;font-weight:500;box-shadow:0 2px 8px rgba(0,0,0,0.15)'
|
||||
banner.innerHTML = `
|
||||
<span>⚠️ 当前使用的是系统生成的默认密码,为了安全请尽快修改</span>
|
||||
<a href="#/security" style="color:#fff;background:rgba(255,255,255,0.2);padding:4px 14px;border-radius:6px;text-decoration:none;font-size:12px;font-weight:600" onclick="document.getElementById('pw-change-banner').remove();sessionStorage.removeItem('clawpanel_must_change_pw')">前往安全设置</a>
|
||||
<button onclick="this.parentElement.remove()" style="background:none;border:none;color:rgba(255,255,255,0.7);cursor:pointer;font-size:16px;padding:0 4px;margin-left:4px">✕</button>
|
||||
`
|
||||
document.body.prepend(banner)
|
||||
}
|
||||
|
||||
// 后台检测状态,检测完再决定是否跳转 setup
|
||||
detectOpenclawStatus().then(() => {
|
||||
// 重新渲染侧边栏(检测完成后 isOpenclawReady 状态已更新)
|
||||
@@ -240,4 +379,9 @@ function showGuardianRecovery() {
|
||||
})
|
||||
}
|
||||
|
||||
boot()
|
||||
// 启动:先检查认证,再加载应用
|
||||
;(async () => {
|
||||
const auth = await checkAuth()
|
||||
if (!auth.ok) await showLoginOverlay(auth.defaultPw)
|
||||
boot()
|
||||
})()
|
||||
|
||||
@@ -42,6 +42,13 @@ const MODES = {
|
||||
}
|
||||
const DEFAULT_MODE = 'execute'
|
||||
|
||||
// ── API 类型 ──
|
||||
const API_TYPES = [
|
||||
{ value: 'openai', label: 'OpenAI 兼容 (最常用)' },
|
||||
{ value: 'anthropic', label: 'Anthropic 原生' },
|
||||
{ value: 'google-gemini', label: 'Google Gemini' },
|
||||
]
|
||||
|
||||
// ── 系统提示词 ──
|
||||
const DEFAULT_NAME = '晴辰助手'
|
||||
const DEFAULT_PERSONALITY = '专业、友善、简洁。善于分析问题,给出可操作的解决方案。'
|
||||
@@ -935,6 +942,7 @@ function loadConfig() {
|
||||
if (!_config.assistantPersonality) _config.assistantPersonality = DEFAULT_PERSONALITY
|
||||
if (!_config.tools) _config.tools = { terminal: false, fileOps: false }
|
||||
if (!_config.mode) _config.mode = DEFAULT_MODE
|
||||
if (!_config.apiType) _config.apiType = 'openai'
|
||||
return _config
|
||||
}
|
||||
|
||||
@@ -1013,19 +1021,39 @@ function autoTitle(session) {
|
||||
|
||||
// ── AI API 调用(自动兼容 Chat Completions + Responses API)──
|
||||
|
||||
function cleanBaseUrl(raw) {
|
||||
function cleanBaseUrl(raw, apiType) {
|
||||
let base = raw.replace(/\/+$/, '')
|
||||
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') {
|
||||
// Anthropic: https://api.anthropic.com/v1
|
||||
if (!base.endsWith('/v1')) base += '/v1'
|
||||
return base
|
||||
}
|
||||
if (type === 'google-gemini') {
|
||||
// Gemini: https://generativelanguage.googleapis.com/v1beta
|
||||
return base
|
||||
}
|
||||
if (!base.endsWith('/v1')) base = base.replace(/\/v1\/.*$/, '/v1')
|
||||
return base
|
||||
}
|
||||
|
||||
function authHeaders() {
|
||||
function authHeaders(apiType, apiKey) {
|
||||
const type = apiType || _config.apiType || 'openai'
|
||||
const key = apiKey || _config.apiKey || ''
|
||||
if (type === 'anthropic') {
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': key,
|
||||
'anthropic-version': '2023-06-01',
|
||||
}
|
||||
}
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${_config.apiKey}`,
|
||||
'Authorization': `Bearer ${key}`,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1051,7 +1079,19 @@ async function callAI(messages, onChunk) {
|
||||
}, TIMEOUT_TOTAL)
|
||||
|
||||
try {
|
||||
// 先尝试 Chat Completions API
|
||||
const apiType = _config.apiType || 'openai'
|
||||
|
||||
if (apiType === 'anthropic') {
|
||||
await callAnthropicMessages(base, allMessages, onChunk)
|
||||
return
|
||||
}
|
||||
|
||||
if (apiType === 'google-gemini') {
|
||||
await callGeminiGenerate(base, allMessages, onChunk)
|
||||
return
|
||||
}
|
||||
|
||||
// OpenAI: 先尝试 Chat Completions API
|
||||
try {
|
||||
await callChatCompletions(base, allMessages, onChunk)
|
||||
return
|
||||
@@ -1064,7 +1104,6 @@ async function callAI(messages, onChunk) {
|
||||
const msg = err.message || ''
|
||||
if (msg.includes('legacy protocol') || msg.includes('/v1/responses') || msg.includes('not supported')) {
|
||||
console.log('[assistant] Chat Completions 不支持此模型,自动切换到 Responses API')
|
||||
// 重新创建 abort controller(上一个可能已被消费)
|
||||
_abortController = new AbortController()
|
||||
await callResponsesAPI(base, allMessages, onChunk)
|
||||
return
|
||||
@@ -1211,6 +1250,133 @@ async function callResponsesAPI(base, messages, onChunk) {
|
||||
})
|
||||
}
|
||||
|
||||
// ── Anthropic Messages API(/v1/messages)──
|
||||
async function callAnthropicMessages(base, messages, onChunk) {
|
||||
const url = base + '/messages'
|
||||
const systemMsg = messages.find(m => m.role === 'system')?.content || ''
|
||||
const chatMessages = messages.filter(m => m.role !== 'system')
|
||||
|
||||
const body = {
|
||||
model: _config.model,
|
||||
max_tokens: 8192,
|
||||
stream: true,
|
||||
temperature: _config.temperature || 0.7,
|
||||
}
|
||||
if (systemMsg) body.system = systemMsg
|
||||
body.messages = chatMessages
|
||||
|
||||
const reqTime = Date.now()
|
||||
_lastDebugInfo = {
|
||||
url, method: 'POST',
|
||||
requestBody: { ...body, messages: body.messages.map(m => ({ role: m.role, content: typeof m.content === 'string' ? m.content.slice(0, 200) + (m.content.length > 200 ? '...' : '') : '[multimodal]' })) },
|
||||
requestTime: new Date(reqTime).toLocaleString('zh-CN'),
|
||||
}
|
||||
|
||||
const resp = await fetchWithRetry(url, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
signal: _abortController.signal,
|
||||
})
|
||||
|
||||
_lastDebugInfo.status = resp.status
|
||||
_lastDebugInfo.contentType = resp.headers.get('content-type') || ''
|
||||
_lastDebugInfo.responseTime = new Date().toLocaleString('zh-CN')
|
||||
_lastDebugInfo.latency = Date.now() - reqTime + 'ms'
|
||||
|
||||
if (!resp.ok) {
|
||||
const errText = await resp.text().catch(() => '')
|
||||
_lastDebugInfo.errorBody = errText.slice(0, 500)
|
||||
let errMsg = `API 错误 ${resp.status}`
|
||||
try {
|
||||
const errJson = JSON.parse(errText)
|
||||
errMsg = errJson.error?.message || errJson.message || errMsg
|
||||
} catch {
|
||||
if (errText) errMsg += `: ${errText.slice(0, 200)}`
|
||||
}
|
||||
throw new Error(errMsg)
|
||||
}
|
||||
|
||||
_lastDebugInfo.streaming = true
|
||||
let chunkCount = 0, contentChunks = 0, thinkingChunks = 0
|
||||
let thinkingBuf = ''
|
||||
|
||||
await readSSEStream(resp, (json) => {
|
||||
chunkCount++
|
||||
if (json.type === 'content_block_delta') {
|
||||
const delta = json.delta
|
||||
if (delta?.type === 'text_delta' && delta.text) {
|
||||
contentChunks++
|
||||
onChunk(delta.text)
|
||||
} else if (delta?.type === 'thinking_delta' && delta.thinking) {
|
||||
thinkingChunks++
|
||||
thinkingBuf += delta.thinking
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
_lastDebugInfo.chunks = { total: chunkCount, content: contentChunks, thinking: thinkingChunks }
|
||||
|
||||
if (contentChunks === 0 && thinkingBuf) {
|
||||
console.warn('[assistant] Anthropic: 无 text 块,使用 thinking 作为回复')
|
||||
onChunk(thinkingBuf)
|
||||
_lastDebugInfo.fallbackToThinking = true
|
||||
}
|
||||
}
|
||||
|
||||
// ── Google Gemini API ──
|
||||
async function callGeminiGenerate(base, messages, onChunk) {
|
||||
const systemMsg = messages.find(m => m.role === 'system')?.content || ''
|
||||
const chatMessages = messages.filter(m => m.role !== 'system')
|
||||
|
||||
// Gemini 格式转换
|
||||
const contents = chatMessages.map(m => ({
|
||||
role: m.role === 'assistant' ? 'model' : 'user',
|
||||
parts: [{ text: typeof m.content === 'string' ? m.content : JSON.stringify(m.content) }],
|
||||
}))
|
||||
|
||||
const body = {
|
||||
contents,
|
||||
generationConfig: { temperature: _config.temperature || 0.7 },
|
||||
}
|
||||
if (systemMsg) {
|
||||
body.systemInstruction = { parts: [{ text: systemMsg }] }
|
||||
}
|
||||
|
||||
const url = `${base}/models/${_config.model}:streamGenerateContent?alt=sse&key=${_config.apiKey}`
|
||||
|
||||
const reqTime = Date.now()
|
||||
_lastDebugInfo = { url: url.replace(_config.apiKey, '***'), method: 'POST', requestTime: new Date(reqTime).toLocaleString('zh-CN') }
|
||||
|
||||
const resp = await fetchWithRetry(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: _abortController.signal,
|
||||
})
|
||||
|
||||
_lastDebugInfo.status = resp.status
|
||||
_lastDebugInfo.latency = Date.now() - reqTime + 'ms'
|
||||
|
||||
if (!resp.ok) {
|
||||
const errText = await resp.text().catch(() => '')
|
||||
let errMsg = `API 错误 ${resp.status}`
|
||||
try { errMsg = JSON.parse(errText).error?.message || errMsg } catch {}
|
||||
throw new Error(errMsg)
|
||||
}
|
||||
|
||||
_lastDebugInfo.streaming = true
|
||||
let chunkCount = 0
|
||||
|
||||
await readSSEStream(resp, (json) => {
|
||||
chunkCount++
|
||||
const text = json.candidates?.[0]?.content?.parts?.[0]?.text
|
||||
if (text) onChunk(text)
|
||||
})
|
||||
|
||||
_lastDebugInfo.chunks = { total: chunkCount }
|
||||
}
|
||||
|
||||
// ── 通用 SSE 流读取 ──
|
||||
async function readSSEStream(resp, onEvent) {
|
||||
const reader = resp.body.getReader()
|
||||
@@ -1380,21 +1546,57 @@ async function confirmToolCall(tc, critical = false) {
|
||||
return result
|
||||
}
|
||||
|
||||
// 将 OpenAI 格式工具定义转为 Anthropic 格式
|
||||
function convertToolsForAnthropic(tools) {
|
||||
return tools.map(t => ({
|
||||
name: t.function.name,
|
||||
description: t.function.description || '',
|
||||
input_schema: t.function.parameters || { type: 'object', properties: {} },
|
||||
}))
|
||||
}
|
||||
|
||||
// 将 OpenAI 格式工具定义转为 Gemini 格式
|
||||
function convertToolsForGemini(tools) {
|
||||
return [{ functionDeclarations: tools.map(t => ({
|
||||
name: t.function.name,
|
||||
description: t.function.description || '',
|
||||
parameters: t.function.parameters || { type: 'object', properties: {} },
|
||||
}))}]
|
||||
}
|
||||
|
||||
// 工具调用执行(共用逻辑)
|
||||
async function executeToolWithSafety(toolName, args, tcForConfirm) {
|
||||
let result = '', approved = true
|
||||
const mode = MODES[currentMode()]
|
||||
const isCritical = toolName === 'run_command' && isCriticalCommand(args.command)
|
||||
if (isCritical) {
|
||||
approved = await confirmToolCall(tcForConfirm || { function: { name: toolName, arguments: JSON.stringify(args) } }, true)
|
||||
if (!approved) result = '用户拒绝了此危险操作'
|
||||
} else if (mode.confirmDanger && DANGEROUS_TOOLS.has(toolName)) {
|
||||
approved = await confirmToolCall(tcForConfirm || { function: { name: toolName, arguments: JSON.stringify(args) } })
|
||||
if (!approved) result = '用户拒绝了此操作'
|
||||
}
|
||||
if (approved) {
|
||||
try { result = await executeTool(toolName, args) }
|
||||
catch (err) { result = `执行失败: ${typeof err === 'string' ? err : err.message || JSON.stringify(err)}` }
|
||||
}
|
||||
return { result, approved }
|
||||
}
|
||||
|
||||
// 带工具调用的 AI 请求(非流式,用于 tool_calls 检测循环)
|
||||
async function callAIWithTools(messages, onStatus, onToolProgress) {
|
||||
if (!_config.baseUrl || !_config.apiKey || !_config.model) {
|
||||
throw new Error('请先配置 AI 模型(点击右上角设置按钮)')
|
||||
}
|
||||
|
||||
const apiType = _config.apiType || 'openai'
|
||||
const base = cleanBaseUrl(_config.baseUrl)
|
||||
const tools = getEnabledTools()
|
||||
let currentMessages = [{ role: 'system', content: buildSystemPrompt() }, ...messages]
|
||||
const toolHistory = [] // 记录工具调用历史
|
||||
const toolHistory = []
|
||||
|
||||
const MAX_AUTO_ROUNDS = 8
|
||||
// 工具调用循环(无硬性上限,超过阈值后询问用户)
|
||||
for (let round = 0; ; round++) {
|
||||
// 超过自动轮次后,询问用户是否继续
|
||||
if (round >= MAX_AUTO_ROUNDS) {
|
||||
const answer = await showAskUserCard({
|
||||
question: `AI 已连续调用工具 ${round} 轮,可能陷入循环。你希望怎么做?`,
|
||||
@@ -1411,6 +1613,120 @@ async function callAIWithTools(messages, onStatus, onToolProgress) {
|
||||
_abortController = new AbortController()
|
||||
onStatus(round === 0 ? 'AI 思考中...' : `AI 处理工具结果 (第${round + 1}轮)...`)
|
||||
|
||||
// ── Anthropic 工具调用 ──
|
||||
if (apiType === 'anthropic') {
|
||||
const systemMsg = currentMessages.find(m => m.role === 'system')?.content || ''
|
||||
const chatMsgs = currentMessages.filter(m => m.role !== 'system')
|
||||
const body = {
|
||||
model: _config.model,
|
||||
max_tokens: 8192,
|
||||
temperature: _config.temperature || 0.7,
|
||||
messages: chatMsgs,
|
||||
}
|
||||
if (systemMsg) body.system = systemMsg
|
||||
if (tools.length > 0) body.tools = convertToolsForAnthropic(tools)
|
||||
|
||||
const resp = await fetchWithRetry(base + '/messages', {
|
||||
method: 'POST', headers: authHeaders(), body: JSON.stringify(body),
|
||||
signal: _abortController.signal,
|
||||
})
|
||||
if (!resp.ok) {
|
||||
const errText = await resp.text().catch(() => '')
|
||||
let errMsg = `API 错误 ${resp.status}`
|
||||
try { errMsg = JSON.parse(errText).error?.message || errMsg } catch {}
|
||||
throw new Error(errMsg)
|
||||
}
|
||||
|
||||
const data = await resp.json()
|
||||
const contentBlocks = data.content || []
|
||||
const toolUses = contentBlocks.filter(b => b.type === 'tool_use')
|
||||
const textContent = contentBlocks.filter(b => b.type === 'text').map(b => b.text).join('')
|
||||
|
||||
if (toolUses.length > 0) {
|
||||
// 将 assistant 消息加入上下文
|
||||
currentMessages.push({ role: 'assistant', content: contentBlocks })
|
||||
|
||||
const toolResults = []
|
||||
for (const tu of toolUses) {
|
||||
const args = tu.input || {}
|
||||
toolHistory.push({ name: tu.name, args, result: null, approved: true, pending: true })
|
||||
onToolProgress(toolHistory)
|
||||
|
||||
const { result, approved } = await executeToolWithSafety(tu.name, args)
|
||||
const last = toolHistory[toolHistory.length - 1]
|
||||
last.result = result; last.approved = approved; last.pending = false
|
||||
onToolProgress(toolHistory)
|
||||
|
||||
toolResults.push({
|
||||
type: 'tool_result',
|
||||
tool_use_id: tu.id,
|
||||
content: typeof result === 'string' ? result : JSON.stringify(result),
|
||||
})
|
||||
}
|
||||
currentMessages.push({ role: 'user', content: toolResults })
|
||||
continue
|
||||
}
|
||||
|
||||
return { content: textContent, toolHistory }
|
||||
}
|
||||
|
||||
// ── Gemini 工具调用 ──
|
||||
if (apiType === 'google-gemini') {
|
||||
const systemMsg = currentMessages.find(m => m.role === 'system')?.content || ''
|
||||
const chatMsgs = currentMessages.filter(m => m.role !== 'system')
|
||||
const contents = chatMsgs.map(m => ({
|
||||
role: m.role === 'assistant' ? 'model' : m.role === 'tool' ? 'function' : 'user',
|
||||
parts: m.functionResponse
|
||||
? [{ functionResponse: m.functionResponse }]
|
||||
: [{ text: typeof m.content === 'string' ? m.content : JSON.stringify(m.content) }],
|
||||
}))
|
||||
const body = { contents, generationConfig: { temperature: _config.temperature || 0.7 } }
|
||||
if (systemMsg) body.systemInstruction = { parts: [{ text: systemMsg }] }
|
||||
if (tools.length > 0) body.tools = convertToolsForGemini(tools)
|
||||
|
||||
const url = `${base}/models/${_config.model}:generateContent?key=${_config.apiKey}`
|
||||
const resp = await fetchWithRetry(url, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body), signal: _abortController.signal,
|
||||
})
|
||||
if (!resp.ok) {
|
||||
const errText = await resp.text().catch(() => '')
|
||||
let errMsg = `API 错误 ${resp.status}`
|
||||
try { errMsg = JSON.parse(errText).error?.message || errMsg } catch {}
|
||||
throw new Error(errMsg)
|
||||
}
|
||||
|
||||
const data = await resp.json()
|
||||
const parts = data.candidates?.[0]?.content?.parts || []
|
||||
const funcCalls = parts.filter(p => p.functionCall)
|
||||
const textParts = parts.filter(p => p.text).map(p => p.text).join('')
|
||||
|
||||
if (funcCalls.length > 0) {
|
||||
currentMessages.push({ role: 'assistant', content: textParts, _geminiParts: parts })
|
||||
|
||||
for (const fc of funcCalls) {
|
||||
const args = fc.functionCall.args || {}
|
||||
toolHistory.push({ name: fc.functionCall.name, args, result: null, approved: true, pending: true })
|
||||
onToolProgress(toolHistory)
|
||||
|
||||
const { result, approved } = await executeToolWithSafety(fc.functionCall.name, args)
|
||||
const last = toolHistory[toolHistory.length - 1]
|
||||
last.result = result; last.approved = approved; last.pending = false
|
||||
onToolProgress(toolHistory)
|
||||
|
||||
currentMessages.push({
|
||||
role: 'tool',
|
||||
content: typeof result === 'string' ? result : JSON.stringify(result),
|
||||
functionResponse: { name: fc.functionCall.name, response: { result: typeof result === 'string' ? result : JSON.stringify(result) } },
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
return { content: textParts, toolHistory }
|
||||
}
|
||||
|
||||
// ── OpenAI 工具调用 ──
|
||||
const body = {
|
||||
model: _config.model,
|
||||
messages: currentMessages,
|
||||
@@ -1438,9 +1754,7 @@ async function callAIWithTools(messages, onStatus, onToolProgress) {
|
||||
|
||||
if (!assistantMsg) throw new Error('AI 未返回有效响应')
|
||||
|
||||
// 检查是否有 tool_calls
|
||||
if (assistantMsg.tool_calls && assistantMsg.tool_calls.length > 0) {
|
||||
// 将 assistant 消息(含 tool_calls)加入上下文
|
||||
currentMessages.push(assistantMsg)
|
||||
|
||||
for (const tc of assistantMsg.tool_calls) {
|
||||
@@ -1448,40 +1762,14 @@ async function callAIWithTools(messages, onStatus, onToolProgress) {
|
||||
try { args = JSON.parse(tc.function.arguments) } catch { args = {} }
|
||||
const toolName = tc.function.name
|
||||
|
||||
// 先显示"执行中"状态的工具块
|
||||
toolHistory.push({ name: toolName, args, result: null, approved: true, pending: true })
|
||||
onToolProgress(toolHistory)
|
||||
|
||||
let result = ''
|
||||
let approved = true
|
||||
|
||||
// 安全围栏:极端危险命令任何模式都必须确认
|
||||
const mode = MODES[currentMode()]
|
||||
const isCritical = toolName === 'run_command' && isCriticalCommand(args.command)
|
||||
if (isCritical) {
|
||||
approved = await confirmToolCall(tc, true)
|
||||
if (!approved) result = '用户拒绝了此危险操作'
|
||||
} else if (mode.confirmDanger && DANGEROUS_TOOLS.has(toolName)) {
|
||||
approved = await confirmToolCall(tc)
|
||||
if (!approved) result = '用户拒绝了此操作'
|
||||
}
|
||||
|
||||
if (approved) {
|
||||
try {
|
||||
result = await executeTool(toolName, args)
|
||||
} catch (err) {
|
||||
result = `执行失败: ${typeof err === 'string' ? err : err.message || JSON.stringify(err)}`
|
||||
}
|
||||
}
|
||||
|
||||
// 更新工具调用历史(完成状态)
|
||||
const { result, approved } = await executeToolWithSafety(toolName, args, tc)
|
||||
const last = toolHistory[toolHistory.length - 1]
|
||||
last.result = result
|
||||
last.approved = approved
|
||||
last.pending = false
|
||||
last.result = result; last.approved = approved; last.pending = false
|
||||
onToolProgress(toolHistory)
|
||||
|
||||
// 添加 tool 结果消息
|
||||
currentMessages.push({
|
||||
role: 'tool',
|
||||
tool_call_id: tc.id,
|
||||
@@ -1489,10 +1777,9 @@ async function callAIWithTools(messages, onStatus, onToolProgress) {
|
||||
})
|
||||
}
|
||||
|
||||
continue // 继续循环,让 AI 处理工具结果
|
||||
continue
|
||||
}
|
||||
|
||||
// 没有 tool_calls,返回最终文本
|
||||
const content = assistantMsg.content || assistantMsg.reasoning_content || ''
|
||||
return { content, toolHistory }
|
||||
}
|
||||
@@ -1681,6 +1968,12 @@ function showSettings() {
|
||||
<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">
|
||||
</div>
|
||||
<div class="form-group" style="width:170px">
|
||||
<label class="form-label">API 类型</label>
|
||||
<select class="form-input" id="ast-apitype">
|
||||
${API_TYPES.map(t => `<option value="${t.value}" ${c.apiType === t.value ? 'selected' : ''}>${t.label}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:10px;align-items:flex-end">
|
||||
<div class="form-group" style="flex:1;margin-bottom:0">
|
||||
@@ -1706,7 +1999,11 @@ 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" style="margin-top:-4px">自动兼容 Chat Completions 和 Responses API</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>
|
||||
<div class="ast-tab-panel" data-panel="tools">
|
||||
<div class="form-hint" style="margin-bottom:10px">工具开关优先级高于模式设置。关闭的工具在任何模式下都不可用。</div>
|
||||
@@ -1752,6 +2049,21 @@ function showSettings() {
|
||||
})
|
||||
})
|
||||
|
||||
// API 类型切换时更新提示文本和 placeholder
|
||||
const apiTypeSelect = overlay.querySelector('#ast-apitype')
|
||||
const apiHintEl = overlay.querySelector('#ast-api-hint')
|
||||
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 resultEl = overlay.querySelector('#ast-test-result')
|
||||
const modelInput = overlay.querySelector('#ast-model')
|
||||
const dropdown = overlay.querySelector('#ast-model-dropdown')
|
||||
@@ -1762,6 +2074,7 @@ 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>'
|
||||
return
|
||||
@@ -1773,93 +2086,78 @@ function showSettings() {
|
||||
btn.disabled = true
|
||||
btn.textContent = '测试中...'
|
||||
resultEl.innerHTML = '<span style="color:var(--text-tertiary)">正在发送测试消息...</span>'
|
||||
const base = cleanBaseUrl(baseUrl)
|
||||
const hdrs = { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + apiKey }
|
||||
const base = cleanBaseUrl(baseUrl, selApiType)
|
||||
const hdrs = authHeaders(selApiType, apiKey)
|
||||
const t0 = Date.now()
|
||||
const reqBody = { model, messages: [{ role: 'user', content: '你好,请用一句话回复' }], max_tokens: 200 }
|
||||
const reqUrl = base + '/chat/completions'
|
||||
|
||||
let respStatus = 0, respBody = '', reply = '', usedApi = 'Chat Completions', fallback = false
|
||||
let respStatus = 0, respBody = '', reply = '', usedApi = '', reqUrl = '', reqBody = {}
|
||||
|
||||
try {
|
||||
const resp = await fetch(reqUrl, {
|
||||
method: 'POST', headers: hdrs,
|
||||
body: JSON.stringify(reqBody),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
})
|
||||
respStatus = resp.status
|
||||
respBody = await resp.text()
|
||||
|
||||
if (!resp.ok) {
|
||||
// 检查是否需要切到 Responses API
|
||||
if (respBody.includes('legacy protocol') || respBody.includes('/v1/responses') || respBody.includes('not supported')) {
|
||||
fallback = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!fallback) {
|
||||
// 尝试从各种可能的格式中提取回复
|
||||
if (selApiType === 'anthropic') {
|
||||
usedApi = 'Anthropic Messages'
|
||||
reqUrl = base + '/messages'
|
||||
reqBody = { model, messages: [{ role: 'user', content: '你好,请用一句话回复' }], max_tokens: 200 }
|
||||
const resp = await fetch(reqUrl, { method: 'POST', headers: hdrs, body: JSON.stringify(reqBody), signal: AbortSignal.timeout(30000) })
|
||||
respStatus = resp.status; respBody = await resp.text()
|
||||
try {
|
||||
const data = JSON.parse(respBody)
|
||||
const msg = data.choices?.[0]?.message
|
||||
reply = msg?.content
|
||||
|| msg?.reasoning_content
|
||||
|| data.choices?.[0]?.text
|
||||
|| data.output?.text
|
||||
|| data.result?.output?.text
|
||||
|| data.data?.choices?.[0]?.message?.content
|
||||
|| ''
|
||||
// 如果 content 为空但有 reasoning_content,标记为推理内容
|
||||
if (!msg?.content && msg?.reasoning_content) {
|
||||
reply = '[推理内容] ' + reply
|
||||
}
|
||||
reply = data.content?.filter(b => b.type === 'text').map(b => b.text).join('') || ''
|
||||
} catch {}
|
||||
} else if (selApiType === 'google-gemini') {
|
||||
usedApi = 'Gemini'
|
||||
reqUrl = `${base}/models/${model}:generateContent?key=***`
|
||||
reqBody = { contents: [{ role: 'user', parts: [{ text: '你好,请用一句话回复' }] }] }
|
||||
const realUrl = `${base}/models/${model}:generateContent?key=${apiKey}`
|
||||
const resp = await fetch(realUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(reqBody), signal: AbortSignal.timeout(30000) })
|
||||
respStatus = resp.status; respBody = await resp.text()
|
||||
try {
|
||||
const data = JSON.parse(respBody)
|
||||
reply = data.candidates?.[0]?.content?.parts?.[0]?.text || ''
|
||||
} catch {}
|
||||
} else {
|
||||
// OpenAI: Chat Completions + Responses fallback
|
||||
usedApi = 'Chat Completions'
|
||||
reqUrl = base + '/chat/completions'
|
||||
reqBody = { model, messages: [{ role: 'user', content: '你好,请用一句话回复' }], max_tokens: 200 }
|
||||
const resp = await fetch(reqUrl, { method: 'POST', headers: hdrs, body: JSON.stringify(reqBody), signal: AbortSignal.timeout(30000) })
|
||||
respStatus = resp.status; respBody = await resp.text()
|
||||
|
||||
let fallback = false
|
||||
if (!resp.ok && (respBody.includes('legacy protocol') || respBody.includes('/v1/responses') || respBody.includes('not supported'))) {
|
||||
fallback = true
|
||||
}
|
||||
|
||||
if (!fallback) {
|
||||
try {
|
||||
const data = JSON.parse(respBody)
|
||||
const msg = data.choices?.[0]?.message
|
||||
reply = msg?.content || msg?.reasoning_content || data.choices?.[0]?.text || data.output?.text || ''
|
||||
if (!msg?.content && msg?.reasoning_content) reply = '[推理内容] ' + reply
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (fallback) {
|
||||
usedApi = 'Responses'
|
||||
reqUrl = base + '/responses'
|
||||
reqBody = { model, input: [{ role: 'user', content: '你好,请用一句话回复' }], max_output_tokens: 200 }
|
||||
try {
|
||||
const resp2 = await fetch(reqUrl, { method: 'POST', headers: hdrs, body: JSON.stringify(reqBody), signal: AbortSignal.timeout(30000) })
|
||||
respStatus = resp2.status; respBody = await resp2.text()
|
||||
try { const d = JSON.parse(respBody); reply = d.output_text || d.output?.[0]?.content?.[0]?.text || '' } catch {}
|
||||
} catch (err2) {
|
||||
resultEl.innerHTML = buildTestResult({ success: false, elapsed: Date.now() - t0, usedApi, reqUrl, reqBody, respStatus: 0, respBody: '', error: err2.message })
|
||||
btn.disabled = false; btn.textContent = '测试'; return
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const elapsed = Date.now() - t0
|
||||
resultEl.innerHTML = buildTestResult({
|
||||
success: false, elapsed, usedApi,
|
||||
reqUrl, reqBody, respStatus: 0, respBody: '', error: err.message,
|
||||
})
|
||||
btn.disabled = false; btn.textContent = '测试对话'; return
|
||||
resultEl.innerHTML = buildTestResult({ success: false, elapsed: Date.now() - t0, usedApi, reqUrl, reqBody, respStatus: 0, respBody: '', error: err.message })
|
||||
btn.disabled = false; btn.textContent = '测试'; return
|
||||
}
|
||||
|
||||
// Responses API fallback
|
||||
if (fallback) {
|
||||
usedApi = 'Responses'
|
||||
const reqUrl2 = base + '/responses'
|
||||
const reqBody2 = { model, input: [{ role: 'user', content: '你好,请用一句话回复' }], max_output_tokens: 200 }
|
||||
try {
|
||||
const resp2 = await fetch(reqUrl2, {
|
||||
method: 'POST', headers: hdrs,
|
||||
body: JSON.stringify(reqBody2),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
})
|
||||
respStatus = resp2.status
|
||||
respBody = await resp2.text()
|
||||
try {
|
||||
const data2 = JSON.parse(respBody)
|
||||
reply = data2.output_text || data2.output?.[0]?.content?.[0]?.text || ''
|
||||
} catch {}
|
||||
} catch (err) {
|
||||
const elapsed = Date.now() - t0
|
||||
resultEl.innerHTML = buildTestResult({
|
||||
success: false, elapsed, usedApi,
|
||||
reqUrl: reqUrl2, reqBody: reqBody2, respStatus: 0, respBody: '', error: err.message,
|
||||
})
|
||||
btn.disabled = false; btn.textContent = '测试对话'; return
|
||||
}
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - t0
|
||||
resultEl.innerHTML = buildTestResult({
|
||||
success: !!reply, elapsed, usedApi,
|
||||
reqUrl: fallback ? base + '/responses' : reqUrl,
|
||||
reqBody: fallback ? { model, input: [{ role: 'user', content: '你好,请用一句话回复' }], max_output_tokens: 200 } : reqBody,
|
||||
respStatus, respBody, reply,
|
||||
})
|
||||
resultEl.innerHTML = buildTestResult({ success: !!reply, elapsed: Date.now() - t0, usedApi, reqUrl, reqBody, respStatus, respBody, reply })
|
||||
btn.disabled = false
|
||||
btn.textContent = '测试对话'
|
||||
btn.textContent = '测试'
|
||||
}
|
||||
|
||||
// 获取模型列表
|
||||
@@ -1874,27 +2172,55 @@ function showSettings() {
|
||||
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)
|
||||
const resp = await fetch(base + '/models', {
|
||||
headers: { 'Authorization': 'Bearer ' + apiKey },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
})
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text().catch(() => '')
|
||||
let msg = 'HTTP ' + resp.status
|
||||
try { msg = JSON.parse(text).error?.message || msg } catch {}
|
||||
resultEl.innerHTML = '<span style="color:var(--error)">✗ ' + escHtml(msg) + '</span>'
|
||||
return
|
||||
const base = cleanBaseUrl(baseUrl, selApiType)
|
||||
const hdrs = authHeaders(selApiType, apiKey)
|
||||
let models = []
|
||||
|
||||
if (selApiType === 'anthropic') {
|
||||
// Anthropic: GET /v1/models
|
||||
const resp = await fetch(base + '/models', { headers: hdrs, signal: AbortSignal.timeout(10000) })
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text().catch(() => '')
|
||||
let msg = 'HTTP ' + resp.status
|
||||
try { msg = JSON.parse(text).error?.message || msg } catch {}
|
||||
resultEl.innerHTML = '<span style="color:var(--error)">✗ ' + escHtml(msg) + '</span>'
|
||||
return
|
||||
}
|
||||
const data = await resp.json()
|
||||
models = (data.data || []).map(m => m.id).filter(Boolean).sort()
|
||||
} else if (selApiType === 'google-gemini') {
|
||||
// Gemini: GET /models?key=xxx
|
||||
const resp = await fetch(base + '/models?key=' + apiKey, { signal: AbortSignal.timeout(10000) })
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text().catch(() => '')
|
||||
let msg = 'HTTP ' + resp.status
|
||||
try { msg = JSON.parse(text).error?.message || msg } catch {}
|
||||
resultEl.innerHTML = '<span style="color:var(--error)">✗ ' + escHtml(msg) + '</span>'
|
||||
return
|
||||
}
|
||||
const data = await resp.json()
|
||||
models = (data.models || []).map(m => m.name?.replace('models/', '') || m.name).filter(Boolean).sort()
|
||||
} else {
|
||||
// OpenAI: GET /v1/models
|
||||
const resp = await fetch(base + '/models', { headers: hdrs, signal: AbortSignal.timeout(10000) })
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text().catch(() => '')
|
||||
let msg = 'HTTP ' + resp.status
|
||||
try { msg = JSON.parse(text).error?.message || msg } catch {}
|
||||
resultEl.innerHTML = '<span style="color:var(--error)">✗ ' + escHtml(msg) + '</span>'
|
||||
return
|
||||
}
|
||||
const data = await resp.json()
|
||||
models = (data.data || []).map(m => m.id).filter(Boolean).sort()
|
||||
}
|
||||
const data = await resp.json()
|
||||
const models = (data.data || []).map(m => m.id).filter(Boolean).sort()
|
||||
|
||||
if (models.length === 0) {
|
||||
resultEl.innerHTML = '<span style="color:var(--warning)">未发现可用模型</span>'
|
||||
return
|
||||
}
|
||||
resultEl.innerHTML = '<span style="color:var(--success)">✓ 发现 ' + models.length + ' 个模型,点击下方列表选择</span>'
|
||||
// 显示下拉列表
|
||||
dropdown.innerHTML = models.map(m =>
|
||||
'<div class="ast-model-option" data-model="' + escHtml(m) + '">' + escHtml(m) + '</div>'
|
||||
).join('')
|
||||
@@ -1903,7 +2229,7 @@ function showSettings() {
|
||||
resultEl.innerHTML = '<span style="color:var(--error)">✗ ' + escHtml(err.message) + '</span>'
|
||||
} finally {
|
||||
btn.disabled = false
|
||||
btn.textContent = '获取模型列表'
|
||||
btn.textContent = '拉取'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1935,6 +2261,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.tools.terminal = overlay.querySelector('#ast-tool-terminal').checked
|
||||
_config.tools.fileOps = overlay.querySelector('#ast-tool-fileops').checked
|
||||
|
||||
@@ -292,7 +292,8 @@ function testWebSocket(page) {
|
||||
api.readOpenclawConfig().then(config => {
|
||||
const port = config?.gateway?.port || 18789
|
||||
const token = config?.gateway?.auth?.token || ''
|
||||
const url = `ws://127.0.0.1:${port}/ws?token=${encodeURIComponent(token)}`
|
||||
const wsHost = window.__TAURI_INTERNALS__ ? `127.0.0.1:${port}` : location.host
|
||||
const url = `ws://${wsHost}/ws?token=${encodeURIComponent(token)}`
|
||||
|
||||
addLog(`📡 连接地址: ${url}`)
|
||||
addLog(`🔑 Token: ${token ? token.substring(0, 20) + '...' : '(空)'}`)
|
||||
@@ -512,7 +513,8 @@ async function fixPairing(page) {
|
||||
const config = await api.readOpenclawConfig()
|
||||
const port = config?.gateway?.port || 18789
|
||||
const token = config?.gateway?.auth?.token || ''
|
||||
const url = `ws://127.0.0.1:${port}/ws?token=${encodeURIComponent(token)}`
|
||||
const wsHost = window.__TAURI_INTERNALS__ ? `127.0.0.1:${port}` : location.host
|
||||
const url = `ws://${wsHost}/ws?token=${encodeURIComponent(token)}`
|
||||
|
||||
const ws = new WebSocket(url)
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { showModal, showConfirm } from '../components/modal.js'
|
||||
// API 接口类型选项
|
||||
const API_TYPES = [
|
||||
{ value: 'openai-completions', label: 'OpenAI 兼容 (最常用)' },
|
||||
{ value: 'anthropic', label: 'Anthropic 原生' },
|
||||
{ value: 'anthropic-messages', label: 'Anthropic 原生' },
|
||||
{ value: 'openai-responses', label: 'OpenAI Responses' },
|
||||
{ value: 'google-gemini', label: 'Google Gemini' },
|
||||
]
|
||||
@@ -17,7 +17,7 @@ const API_TYPES = [
|
||||
// 服务商快捷预设
|
||||
const PROVIDER_PRESETS = [
|
||||
{ key: 'openai', label: 'OpenAI 官方', baseUrl: 'https://api.openai.com/v1', api: 'openai-completions' },
|
||||
{ key: 'anthropic', label: 'Anthropic 官方', baseUrl: 'https://api.anthropic.com', api: 'anthropic' },
|
||||
{ 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' },
|
||||
]
|
||||
|
||||
293
src/pages/security.js
Normal file
293
src/pages/security.js
Normal file
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* 安全设置页面 — 访问密码管理 & 无视风险模式
|
||||
* 支持 Web 部署模式和 Tauri 桌面端
|
||||
*/
|
||||
import { toast } from '../components/toast.js'
|
||||
|
||||
const isTauri = !!window.__TAURI_INTERNALS__
|
||||
let _tauriApi = null
|
||||
|
||||
async function getTauriApi() {
|
||||
if (!_tauriApi) _tauriApi = (await import('../lib/tauri-api.js')).api
|
||||
return _tauriApi
|
||||
}
|
||||
|
||||
async function apiCall(cmd, args = {}) {
|
||||
if (isTauri) {
|
||||
// 桌面端:通过 Tauri IPC 读写 clawpanel.json
|
||||
const api = await getTauriApi()
|
||||
const cfg = await api.readPanelConfig()
|
||||
|
||||
if (cmd === 'auth_status') {
|
||||
const isDefault = cfg.accessPassword === '123456'
|
||||
const result = { hasPassword: !!cfg.accessPassword, mustChangePassword: isDefault, ignoreRisk: !!cfg.ignoreRisk }
|
||||
if (isDefault) result.defaultPassword = '123456'
|
||||
return result
|
||||
}
|
||||
if (cmd === 'auth_change_password') {
|
||||
if (cfg.accessPassword && args.oldPassword !== cfg.accessPassword) throw new Error('当前密码错误')
|
||||
const weakErr = checkPasswordStrengthLocal(args.newPassword)
|
||||
if (weakErr) throw new Error(weakErr)
|
||||
if (args.newPassword === cfg.accessPassword) throw new Error('新密码不能与旧密码相同')
|
||||
cfg.accessPassword = args.newPassword
|
||||
delete cfg.mustChangePassword
|
||||
delete cfg.ignoreRisk
|
||||
await api.writePanelConfig(cfg)
|
||||
sessionStorage.setItem('clawpanel_authed', '1')
|
||||
return { success: true }
|
||||
}
|
||||
if (cmd === 'auth_ignore_risk') {
|
||||
if (args.enable) {
|
||||
delete cfg.accessPassword
|
||||
delete cfg.mustChangePassword
|
||||
cfg.ignoreRisk = true
|
||||
sessionStorage.removeItem('clawpanel_authed')
|
||||
} else {
|
||||
delete cfg.ignoreRisk
|
||||
}
|
||||
await api.writePanelConfig(cfg)
|
||||
return { success: true }
|
||||
}
|
||||
throw new Error('未知命令: ' + cmd)
|
||||
}
|
||||
// Web 模式
|
||||
const resp = await fetch(`/__api/${cmd}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(args),
|
||||
})
|
||||
const data = await resp.json()
|
||||
if (!resp.ok) throw new Error(data.error || `HTTP ${resp.status}`)
|
||||
return data
|
||||
}
|
||||
|
||||
function checkPasswordStrengthLocal(pw) {
|
||||
if (!pw || pw.length < 6) return '密码至少 6 位'
|
||||
if (pw.length > 64) return '密码不能超过 64 位'
|
||||
if (/^\d+$/.test(pw)) return '密码不能是纯数字'
|
||||
const weak = ['123456', '654321', 'password', 'admin', 'qwerty', 'abc123', '111111', '000000', 'letmein', 'welcome', 'clawpanel', 'openclaw']
|
||||
if (weak.includes(pw.toLowerCase())) return '密码太常见,请换一个更安全的密码'
|
||||
return null
|
||||
}
|
||||
|
||||
function strengthLevel(pw) {
|
||||
if (!pw) return { level: 0, text: '', color: '' }
|
||||
if (pw.length < 6) return { level: 1, text: '太短', color: 'var(--error)' }
|
||||
if (/^\d+$/.test(pw)) return { level: 1, text: '纯数字太弱', color: 'var(--error)' }
|
||||
let score = 0
|
||||
if (pw.length >= 8) score++
|
||||
if (pw.length >= 12) score++
|
||||
if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) score++
|
||||
if (/\d/.test(pw)) score++
|
||||
if (/[^a-zA-Z0-9]/.test(pw)) score++
|
||||
if (score <= 1) return { level: 2, text: '一般', color: 'var(--warning)' }
|
||||
if (score <= 3) return { level: 3, text: '良好', color: 'var(--primary)' }
|
||||
return { level: 4, text: '强', color: 'var(--success)' }
|
||||
}
|
||||
|
||||
export async function render() {
|
||||
const page = document.createElement('div')
|
||||
page.className = 'page'
|
||||
|
||||
page.innerHTML = `
|
||||
<div class="page-header"><h1>安全设置</h1></div>
|
||||
<div id="security-content">
|
||||
<div class="config-section loading-placeholder" style="height:120px"></div>
|
||||
</div>
|
||||
`
|
||||
|
||||
loadStatus(page)
|
||||
return page
|
||||
}
|
||||
|
||||
async function loadStatus(page) {
|
||||
const container = page.querySelector('#security-content')
|
||||
try {
|
||||
const status = await apiCall('auth_status')
|
||||
renderContent(container, status)
|
||||
} catch (e) {
|
||||
container.innerHTML = `<div class="config-section"><p style="color:var(--error)">加载失败: ${e.message}</p></div>`
|
||||
}
|
||||
}
|
||||
|
||||
function renderContent(container, status) {
|
||||
let html = ''
|
||||
|
||||
// 当前状态
|
||||
const stateIcon = status.hasPassword ? '✅' : (status.ignoreRisk ? '⚠️' : '⚠️')
|
||||
const stateText = status.hasPassword
|
||||
? (status.mustChangePassword ? '使用默认密码(需修改)' : '已设置自定义密码')
|
||||
: (status.ignoreRisk ? '无视风险模式(无密码)' : '未设置密码')
|
||||
const stateColor = status.hasPassword && !status.mustChangePassword ? 'var(--success)' : 'var(--warning)'
|
||||
|
||||
html += `
|
||||
<div class="config-section">
|
||||
<div class="config-section-title">访问密码状态</div>
|
||||
<div style="display:flex;align-items:center;gap:8px;padding:12px 16px;background:var(--bg-tertiary);border-radius:var(--radius-sm);border-left:3px solid ${stateColor}">
|
||||
<span style="font-size:20px">${stateIcon}</span>
|
||||
<div>
|
||||
<div style="font-weight:600;color:var(--text-primary)">${stateText}</div>
|
||||
<div style="font-size:var(--font-size-xs);color:var(--text-tertiary);margin-top:2px">
|
||||
${status.hasPassword
|
||||
? (isTauri ? '每次打开应用需输入密码' : '远程访问需输入密码才能进入面板')
|
||||
: (isTauri ? '任何人打开应用即可使用' : '任何人都可以直接访问面板')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// 修改密码区域
|
||||
html += `
|
||||
<div class="config-section">
|
||||
<div class="config-section-title">${status.hasPassword ? '修改密码' : '设置密码'}</div>
|
||||
<form id="form-change-pw" style="max-width:400px">
|
||||
${status.hasPassword ? `
|
||||
<div style="margin-bottom:12px">
|
||||
<label style="display:block;font-size:var(--font-size-xs);color:var(--text-tertiary);margin-bottom:4px">当前密码</label>
|
||||
<input type="password" id="sec-old-pw" class="form-input" placeholder="输入当前密码" autocomplete="current-password" style="width:100%"
|
||||
${status.defaultPassword ? `value="${status.defaultPassword}"` : ''}>
|
||||
${status.defaultPassword ? '<div style="font-size:11px;color:var(--text-tertiary);margin-top:4px">已自动填充默认密码,直接设置新密码即可</div>' : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
<div style="margin-bottom:12px">
|
||||
<label style="display:block;font-size:var(--font-size-xs);color:var(--text-tertiary);margin-bottom:4px">新密码</label>
|
||||
<input type="password" id="sec-new-pw" class="form-input" placeholder="至少 6 位,不能纯数字" autocomplete="new-password" style="width:100%">
|
||||
<div id="pw-strength" style="margin-top:6px;display:flex;align-items:center;gap:8px;min-height:20px"></div>
|
||||
</div>
|
||||
<div style="margin-bottom:16px">
|
||||
<label style="display:block;font-size:var(--font-size-xs);color:var(--text-tertiary);margin-bottom:4px">确认新密码</label>
|
||||
<input type="password" id="sec-confirm-pw" class="form-input" placeholder="再次输入新密码" autocomplete="new-password" style="width:100%">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm">${status.hasPassword ? '确认修改' : '设置密码'}</button>
|
||||
<span id="change-pw-msg" style="margin-left:12px;font-size:var(--font-size-xs)"></span>
|
||||
</form>
|
||||
</div>
|
||||
`
|
||||
|
||||
// 无视风险模式
|
||||
html += `
|
||||
<div class="config-section">
|
||||
<div class="config-section-title" style="display:flex;align-items:center;gap:6px">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
|
||||
无视风险模式
|
||||
</div>
|
||||
<div style="padding:12px 16px;background:${status.ignoreRisk ? 'rgba(239,68,68,0.08)' : 'var(--bg-tertiary)'};border-radius:var(--radius-sm);border:1px solid ${status.ignoreRisk ? 'rgba(239,68,68,0.2)' : 'var(--border-primary)'}">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px">
|
||||
<div>
|
||||
<div style="font-weight:500;color:var(--text-primary)">关闭密码保护</div>
|
||||
<div style="font-size:var(--font-size-xs);color:var(--text-secondary);margin-top:4px;line-height:1.5">
|
||||
开启后任何人都可以直接访问面板,无需输入密码。<br>
|
||||
<strong style="color:var(--error)">仅建议在受信任的内网环境中使用。</strong>
|
||||
</div>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="toggle-ignore-risk" ${status.ignoreRisk ? 'checked' : ''}>
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="ignore-risk-confirm" style="display:none;margin-top:12px;padding:12px 16px;background:rgba(239,68,68,0.06);border-radius:var(--radius-sm);border:1px solid rgba(239,68,68,0.15)">
|
||||
<p style="font-size:var(--font-size-sm);color:var(--error);font-weight:600;margin-bottom:8px">确认关闭密码保护?</p>
|
||||
<p style="font-size:var(--font-size-xs);color:var(--text-secondary);margin-bottom:12px;line-height:1.5">
|
||||
关闭后,<strong>任何能访问此服务器 IP 和端口的人</strong>都可以直接进入管理面板,查看和修改你的 AI 配置。
|
||||
</p>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="btn btn-sm" id="btn-confirm-ignore" style="background:var(--error);color:#fff;border:none">我了解风险,确认关闭</button>
|
||||
<button class="btn btn-secondary btn-sm" id="btn-cancel-ignore">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
container.innerHTML = html
|
||||
bindSecurityEvents(container, status)
|
||||
}
|
||||
|
||||
function bindSecurityEvents(container, status) {
|
||||
// 密码强度实时显示
|
||||
const newPwInput = container.querySelector('#sec-new-pw')
|
||||
const strengthEl = container.querySelector('#pw-strength')
|
||||
if (newPwInput && strengthEl) {
|
||||
newPwInput.addEventListener('input', () => {
|
||||
const s = strengthLevel(newPwInput.value)
|
||||
if (!newPwInput.value) { strengthEl.innerHTML = ''; return }
|
||||
const bars = [1,2,3,4].map(i =>
|
||||
`<div style="width:32px;height:4px;border-radius:2px;background:${i <= s.level ? s.color : 'var(--border-primary)'}"></div>`
|
||||
).join('')
|
||||
strengthEl.innerHTML = `${bars}<span style="font-size:11px;color:${s.color};font-weight:500">${s.text}</span>`
|
||||
})
|
||||
}
|
||||
|
||||
// 修改密码表单
|
||||
const form = container.querySelector('#form-change-pw')
|
||||
if (form) {
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault()
|
||||
const oldPw = container.querySelector('#sec-old-pw')?.value || ''
|
||||
const newPw = container.querySelector('#sec-new-pw')?.value || ''
|
||||
const confirmPw = container.querySelector('#sec-confirm-pw')?.value || ''
|
||||
const msgEl = container.querySelector('#change-pw-msg')
|
||||
const btn = form.querySelector('button[type="submit"]')
|
||||
|
||||
if (newPw !== confirmPw) { msgEl.textContent = '两次输入的密码不一致'; msgEl.style.color = 'var(--error)'; return }
|
||||
|
||||
btn.disabled = true
|
||||
btn.textContent = '提交中...'
|
||||
msgEl.textContent = ''
|
||||
try {
|
||||
await apiCall('auth_change_password', { oldPassword: oldPw, newPassword: newPw })
|
||||
msgEl.textContent = '密码修改成功'
|
||||
msgEl.style.color = 'var(--success)'
|
||||
toast('密码已更新', 'success')
|
||||
// 清除默认密码横幅
|
||||
sessionStorage.removeItem('clawpanel_must_change_pw')
|
||||
const banner = document.getElementById('pw-change-banner')
|
||||
if (banner) banner.remove()
|
||||
setTimeout(() => loadStatus(container.closest('.page')), 1000)
|
||||
} catch (err) {
|
||||
msgEl.textContent = err.message
|
||||
msgEl.style.color = 'var(--error)'
|
||||
btn.disabled = false
|
||||
btn.textContent = status.hasPassword ? '确认修改' : '设置密码'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 无视风险模式开关
|
||||
const toggle = container.querySelector('#toggle-ignore-risk')
|
||||
const confirmBox = container.querySelector('#ignore-risk-confirm')
|
||||
if (toggle && confirmBox) {
|
||||
toggle.addEventListener('change', () => {
|
||||
if (toggle.checked) {
|
||||
// 想开启无视风险 → 显示确认框
|
||||
confirmBox.style.display = 'block'
|
||||
toggle.checked = false // 先不改,等用户确认
|
||||
} else {
|
||||
// 想关闭无视风险 → 直接关闭,刷新页面引导设密码
|
||||
handleIgnoreRisk(container, false)
|
||||
}
|
||||
})
|
||||
|
||||
container.querySelector('#btn-confirm-ignore')?.addEventListener('click', () => {
|
||||
handleIgnoreRisk(container, true)
|
||||
})
|
||||
container.querySelector('#btn-cancel-ignore')?.addEventListener('click', () => {
|
||||
confirmBox.style.display = 'none'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function handleIgnoreRisk(container, enable) {
|
||||
try {
|
||||
await apiCall('auth_ignore_risk', { enable })
|
||||
if (enable) {
|
||||
toast('已开启无视风险模式,密码保护已关闭', 'warning')
|
||||
} else {
|
||||
toast('无视风险模式已关闭,请设置新密码', 'info')
|
||||
}
|
||||
setTimeout(() => loadStatus(container.closest('.page')), 500)
|
||||
} catch (e) {
|
||||
toast('操作失败: ' + e.message, 'error')
|
||||
}
|
||||
}
|
||||
@@ -186,6 +186,61 @@ function renderSteps(page, { node, cliOk, config }) {
|
||||
}
|
||||
|
||||
function renderInstallSection() {
|
||||
const isWin = navigator.platform?.startsWith('Win') || navigator.userAgent?.includes('Windows')
|
||||
const isMac = navigator.platform?.startsWith('Mac') || navigator.userAgent?.includes('Macintosh')
|
||||
const isDesktop = !!window.__TAURI_INTERNALS__
|
||||
|
||||
let envHint = ''
|
||||
if (isDesktop) {
|
||||
envHint = `
|
||||
<div style="margin-top:var(--space-sm);padding:10px 12px;background:var(--bg-tertiary);border-radius:var(--radius-sm);border-left:3px solid var(--warning);font-size:var(--font-size-xs);color:var(--text-secondary);line-height:1.7">
|
||||
<strong style="color:var(--text-primary)">找不到已安装的 OpenClaw?</strong>
|
||||
<p style="margin:6px 0 2px">ClawPanel 桌面版只能管理<strong>本机</strong>安装的 OpenClaw。以下环境中的安装无法被检测到:</p>
|
||||
<ul style="margin:4px 0 8px 16px;padding:0">
|
||||
${isWin ? `
|
||||
<li><strong>WSL (Windows 子系统)</strong> — OpenClaw 装在 WSL 里,Windows 侧无法访问</li>
|
||||
<li><strong>Docker 容器</strong> — 容器内的安装与宿主机隔离</li>
|
||||
` : ''}
|
||||
${isMac ? `
|
||||
<li><strong>Docker 容器</strong> — 容器内的安装与宿主机隔离</li>
|
||||
<li><strong>远程服务器</strong> — 安装在其他机器上</li>
|
||||
` : ''}
|
||||
${!isWin && !isMac ? `
|
||||
<li><strong>Docker 容器</strong> — 容器内的安装与宿主机隔离</li>
|
||||
` : ''}
|
||||
</ul>
|
||||
<details style="cursor:pointer">
|
||||
<summary style="font-weight:600;color:var(--primary);margin-bottom:6px">
|
||||
在对应环境中安装管理面板
|
||||
</summary>
|
||||
<div style="margin-top:8px">
|
||||
${isWin ? `
|
||||
<div style="margin-bottom:10px">
|
||||
<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://claw.qt.cool/deploy.sh | bash</code>
|
||||
<div style="margin-top:4px;opacity:0.7">部署后在浏览器访问 WSL 的 IP 即可管理。</div>
|
||||
</div>
|
||||
` : ''}
|
||||
<div style="margin-bottom:10px">
|
||||
<div style="font-weight:600;margin-bottom:4px">Docker 容器中使用:</div>
|
||||
<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://claw.qt.cool/deploy.sh | bash</code>
|
||||
</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://claw.qt.cool/deploy.sh | bash</code>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<div style="margin-top:6px;opacity:0.7">
|
||||
或者,你也可以在本机重新安装 OpenClaw(使用下方的「一键安装」)。
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
|
||||
return `
|
||||
<p style="color:var(--text-secondary);font-size:var(--font-size-sm);margin-bottom:var(--space-sm)">
|
||||
选择版本后点击安装,将自动执行 npm 全局安装。
|
||||
@@ -215,6 +270,7 @@ function renderInstallSection() {
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" id="btn-install">一键安装</button>
|
||||
${envHint}
|
||||
`
|
||||
}
|
||||
|
||||
|
||||
@@ -329,3 +329,87 @@ mark {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* === 登录覆盖层 === */
|
||||
#login-overlay {
|
||||
position: fixed; inset: 0; z-index: 99998;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: var(--bg-primary, #f8f9fb);
|
||||
transition: opacity 0.4s ease;
|
||||
}
|
||||
#login-overlay.hide { opacity: 0; pointer-events: none; }
|
||||
.login-card {
|
||||
width: 360px; max-width: 90vw; padding: 40px 32px;
|
||||
background: var(--bg-card, #fff);
|
||||
border: 1px solid var(--border, #e4e4e7);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.08);
|
||||
text-align: center;
|
||||
}
|
||||
.login-card .login-logo {
|
||||
width: 48px; height: 48px; margin: 0 auto 16px;
|
||||
color: var(--primary, #6366f1);
|
||||
}
|
||||
.login-card .login-title {
|
||||
font-size: 20px; font-weight: 600; margin-bottom: 4px;
|
||||
color: var(--text-primary, #18181b);
|
||||
}
|
||||
.login-card .login-desc {
|
||||
font-size: 13px; color: var(--text-secondary, #71717a);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.login-card .login-input {
|
||||
width: 100%; padding: 10px 14px; font-size: 14px;
|
||||
border: 1px solid var(--border, #e4e4e7);
|
||||
border-radius: 8px; outline: none;
|
||||
background: var(--bg-secondary, #f4f4f5);
|
||||
color: var(--text-primary, #18181b);
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.login-card .login-input:focus {
|
||||
border-color: var(--primary, #6366f1);
|
||||
box-shadow: 0 0 0 3px rgba(99,102,241,0.12);
|
||||
}
|
||||
.login-card .login-btn {
|
||||
width: 100%; margin-top: 16px; padding: 10px 0;
|
||||
font-size: 14px; font-weight: 500;
|
||||
border: none; border-radius: 8px; cursor: pointer;
|
||||
background: var(--primary, #6366f1);
|
||||
color: #fff; transition: opacity 0.2s;
|
||||
}
|
||||
.login-card .login-btn:hover { opacity: 0.9; }
|
||||
.login-card .login-btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.login-card .login-error {
|
||||
margin-top: 12px; font-size: 13px;
|
||||
color: var(--error, #ef4444);
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
/* === Toggle 开关 === */
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 44px; height: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.toggle-switch input { opacity: 0; width: 0; height: 0; }
|
||||
.toggle-slider {
|
||||
position: absolute; inset: 0;
|
||||
cursor: pointer; border-radius: 24px;
|
||||
background: var(--border-primary, #d4d4d8);
|
||||
transition: background 0.25s;
|
||||
}
|
||||
.toggle-slider::before {
|
||||
content: '';
|
||||
position: absolute; left: 2px; top: 2px;
|
||||
width: 20px; height: 20px; border-radius: 50%;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.15);
|
||||
transition: transform 0.25s;
|
||||
}
|
||||
.toggle-switch input:checked + .toggle-slider {
|
||||
background: var(--error, #ef4444);
|
||||
}
|
||||
.toggle-switch input:checked + .toggle-slider::before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
@@ -61,6 +61,26 @@
|
||||
border-top: 1px solid var(--border-secondary);
|
||||
}
|
||||
|
||||
.sidebar-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 6px 12px 2px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.sidebar-link {
|
||||
color: var(--text-tertiary);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.sidebar-link:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
.sidebar-version {
|
||||
color: var(--text-tertiary);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.nav-section {
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user