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:
晴天
2026-03-06 22:46:40 +08:00
parent 80197bdc60
commit 921c371934
23 changed files with 2017 additions and 238 deletions

View File

@@ -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>
`

View File

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

View File

@@ -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()
})()

View File

@@ -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

View File

@@ -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)

View File

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

View File

@@ -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}
`
}

View File

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

View File

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