feat: 增加拟人鼠标轨迹选择,Token 允许留空 (closes #12)

This commit is contained in:
foxhui
2026-01-24 03:24:49 +08:00
Unverified
parent f7bcddc91b
commit a85b731ce1
43 changed files with 210 additions and 63 deletions
+14
View File
@@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.5.0] - 2026-01-23
### ✨ Added
- **鼠标轨迹**
- 增加三种鼠标轨迹选择(使用项目维护的、使用 Camoufox 内置、不适用拟人轨迹)
### 🐛 Fixed
- **Token 留空问题**
- 修复 WebUI 留空Token后无法重启的问题(允许 Token 留空)
### ❌ Removed
- **热门模型ID**
- 竞技场删除了 gemini-3-pro-image-preview-2k,因此项目同步删除
## [3.4.9] - 2026-01-22
### 🔄 Changed
+15 -2
View File
@@ -4,7 +4,7 @@ logLevel: info
server:
# 监听端口
port: 3000
# 鉴权 API Token (可使用 npm run genkey 生成)
# 鉴权 API Token 至少为 10 个字符 (可使用 npm run genkey 生成)
# 该配置会对 API 接口和 WebUI 生效
auth: sk-change-me-to-your-secure-key
# 流式请求心跳设置 (自动对 stream: true 的请求发送心跳防止超时)
@@ -64,9 +64,16 @@ backend:
# gemini (Google Gemini 图片、视频生成)
# gemini_text (Google Gemini 文本生成)
# zai_is (zAI 图片生成)
# zai_is_text (zAI 文本生成)
# nanobananafree_ai (NanoBananaFree 图片生成)
# zenmux_ai_text (ZenMux 文本生成)
# chatgpt (ChatGPT 图片生成)
# chatgpt_text (ChatGPT 文本生成)
# sora (Sora 视频生成)
# deepseek_text (DeepSeek 文本生成)
# doubao (豆包 图片生成)
# doubao_text (豆包 文本生成)
# test (浏览器检测,仅供调试使用)
type: lmarena # 适配器类型
# ------------------------------------------------
@@ -150,6 +157,12 @@ browser:
# 是否启用无头模式
headless: false
# 拟人鼠标轨迹模式
# - false: 禁用拟人轨迹,使用 Playwright 原生点击(性能最好,但会被自动化检测)
# - true: 使用项目优化的 ghost-cursor(更拟人化,如不会点击正中心,但性能稍差)
# - "camou": 使用 Camoufox 内置轨迹(性能与拟人化的平衡)
humanizeCursor: true
# 站点隔离 (fission.autostart)
# 开启保持 Firefox 默认开启状态
# 关闭此项可显著降低内存占用,防止低配服务器崩溃
@@ -163,7 +176,7 @@ browser:
# 禁用网页动画
# 作用:移除 transition 和 animation
# 收益:显著降低 CPU 持续占用
# 风险:低。几乎不影响浏览器指纹
# 风险:低。几乎不影响浏览器指纹,但可能导致部分网页布局异常
animation: false
# 禁用滤镜和阴影
+14 -6
View File
@@ -59,7 +59,7 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) {
await gotoWithCheck(page, TARGET_URL);
// 1. 等待输入框加载
await waitForInput(page, textareaSelector, { click: false });
await waitForInput(page, textareaSelector, { click: true });
// 2. 选择模型
if (modelId) {
@@ -81,13 +81,22 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) {
document.execCommand('insertText', false, text);
}, searchText);
// 等待下拉选项出现后再按回车
// 等待过滤完成:第一个选项包含目标模型的主 ID
// searchText 可能是 codeName(含括号说明),但过滤后的选项应该包含 modelId
try {
await page.waitForSelector('[role="option"]', { timeout: 5000 });
await page.waitForFunction(
(targetId) => {
const firstOption = document.querySelector('[role="option"]');
return firstOption && firstOption.textContent?.includes(targetId);
},
modelId,
{ timeout: 5000 }
);
} catch {
// 超时也继续,可能选项已经存在
// 超时也继续,可能列表结构不同
logger.debug('适配器', `等待模型选项过滤超时,继续执行`, meta);
}
await sleep(200, 300);
await sleep(300, 500);
await page.keyboard.press('Enter');
}
@@ -196,7 +205,6 @@ export const manifest = {
// 模型列表
models: [
{ id: 'gemini-3-pro-image-preview-2k', imagePolicy: 'optional' },
{ id: 'gemini-3-pro-image-preview', codeName: 'gemini-3-pro-image-preview (nano-banana-pro)', imagePolicy: 'optional' },
{ id: 'hunyuan-image-3.0', imagePolicy: 'forbidden' },
{ id: 'vidu-q2-image', imagePolicy: 'optional' },
+13 -4
View File
@@ -65,13 +65,22 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) {
document.execCommand('insertText', false, text);
}, searchText);
// 等待下拉选项出现后再按回车
// 等待过滤完成:第一个选项包含目标模型的主 ID
// searchText 可能是 codeName(含括号说明),但过滤后的选项应该包含 modelId
try {
await page.waitForSelector('[role="option"]', { timeout: 5000 });
await page.waitForFunction(
(targetId) => {
const firstOption = document.querySelector('[role="option"]');
return firstOption && firstOption.textContent?.includes(targetId);
},
modelId,
{ timeout: 5000 }
);
} catch {
// 超时也继续,可能选项已经存在
// 超时也继续,可能列表结构不同
logger.debug('适配器', `等待模型选项过滤超时,继续执行`, meta);
}
await sleep(200, 300);
await sleep(300, 500);
await page.keyboard.press('Enter');
}
+1
View File
@@ -295,6 +295,7 @@ export async function initBrowserBase(config, options = {}) {
block_webrtc: true,
exclude_addons: ['UBO'],
geoip: true,
humanize: browserConfig.humanizeCursor === 'camou',
config: {
forceScopeAccess: true,
// Canvas 抗指纹:注入固定噪点偏移
+22 -8
View File
@@ -229,6 +229,9 @@ export async function safeClick(page, target, options = {}) {
const timeout = options.timeout || TIMEOUTS.ELEMENT_CLICK;
const waitStable = options.waitStable !== false; // 默认 true
const selector = typeof target === 'string' ? target : '元素';
// humanizeCursorMode: false=禁用, true=ghost-cursor, "camou"=Camoufox内置
// 只有 true 时才使用 ghost-cursor,其他情况都使用原生点击
const useGhostCursor = page?._humanizeCursorMode === true && page?.cursor;
const doClick = async () => {
let el;
@@ -260,9 +263,8 @@ export async function safeClick(page, target, options = {}) {
await waitForElementStable(el);
logger.debug('浏览器', `[safeClick] 元素已稳定`);
}
// 使用 ghost-cursor 点击
if (page.cursor) {
// 使用自维护 ghost-cursor 拟人鼠标轨迹 (仅当 humanizeCursor=true)
if (useGhostCursor) {
const box = await el.boundingBox();
logger.debug('浏览器', `[safeClick] boundingBox: ${JSON.stringify(box)}`);
if (box) {
@@ -279,9 +281,11 @@ export async function safeClick(page, target, options = {}) {
return;
}
// 降级逻辑
logger.debug('浏览器', `[safeClick] 无 cursor,使用原生 click`);
await el.click({ clickCount });
// 使用原生点击 (humanizeCursor=false 或 "camou")
const mode = page?._humanizeCursorMode;
logger.debug('浏览器', `[safeClick] humanizeCursor=${mode} 使用原生点击`);
// force: true 跳过可操作性检查(遮挡检测等),避免在复杂页面卡住
await el.click({ clickCount, force: true });
};
// 带超时的执行(移除了重试机制)
@@ -673,8 +677,18 @@ export async function uploadFilesViaChooser(page, triggerTarget, filePaths, opti
const clickCount = clickAction === 'dblclick' ? 2 : 1;
await safeClick(page, triggerTarget, { bias: 'button', clickCount });
// 等待 filechooser 事件并设置文件
const fileChooser = await fileChooserPromise;
// 等待 filechooser 事件并设置文件(带异常保护)
let fileChooser;
try {
fileChooser = await fileChooserPromise;
} catch (e) {
// filechooser 超时通常意味着点击没有触发文件选择器
// 抛出可识别的错误让上层决定是否重试
const error = new Error(`文件选择器等待超时: ${e.message}`);
error.code = 'UPLOAD_FILECHOOSER_TIMEOUT';
throw error;
}
await fileChooser.setFiles(filePaths);
logger.debug('浏览器', '已通过 filechooser 提交文件');
+21 -4
View File
@@ -116,7 +116,12 @@ export class Worker {
this.browser = sharedBrowser;
this.page = await sharedBrowser.newPage();
this.page.authState = { isHandlingAuth: false };
this.page.cursor = createCursor(this.page);
const humanizeCursorMode = this.globalConfig?.browser?.humanizeCursor;
this.page._humanizeCursorMode = humanizeCursorMode;
// true 表示使用项目维护的 ghost-cursor
if (humanizeCursorMode === true) {
this.page.cursor = createCursor(this.page);
}
// 保存参数用于重新初始化
this._targetUrl = targetUrl;
@@ -165,7 +170,11 @@ export class Worker {
async _recreatePage() {
this.page = await this.browser.newPage();
this.page.authState = { isHandlingAuth: false };
this.page.cursor = createCursor(this.page);
const humanizeCursorMode = this.globalConfig?.browser?.humanizeCursor;
this.page._humanizeCursorMode = humanizeCursorMode;
if (humanizeCursorMode === true) {
this.page.cursor = createCursor(this.page);
}
await this._navigateToTarget(this._targetUrl || 'about:blank');
if (this._navigationHandler) {
@@ -195,7 +204,11 @@ export class Worker {
this.browser = base.context;
this.page = base.page;
this.page.authState = { isHandlingAuth: false };
this.page.cursor = createCursor(this.page);
const humanizeCursorMode = this.globalConfig?.browser?.humanizeCursor;
this.page._humanizeCursorMode = humanizeCursorMode;
if (humanizeCursorMode === true) {
this.page.cursor = createCursor(this.page);
}
if (navigationHandler) {
this.page.on('framenavigated', async () => {
@@ -244,7 +257,11 @@ export class Worker {
sharedWorker.browser = this.browser;
sharedWorker.page = await this.browser.newPage();
sharedWorker.page.authState = { isHandlingAuth: false };
sharedWorker.page.cursor = createCursor(sharedWorker.page);
const sharedCursorMode = this.globalConfig?.browser?.humanizeCursor;
sharedWorker.page._humanizeCursorMode = sharedCursorMode;
if (sharedCursorMode === true) {
sharedWorker.page.cursor = createCursor(sharedWorker.page);
}
await sharedWorker._navigateToTarget(sharedWorker._targetUrl || 'about:blank');
sharedWorker._registerPageCloseHandler(); // 重新注册标签页关闭处理器
sharedWorker.initialized = true;
+13 -4
View File
@@ -231,11 +231,14 @@ export function loadConfig() {
if (typeof port !== 'number' || !Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error(`server.port 必须是 1-65535 范围内的整数,当前值: ${port}`);
}
// Auth Token 校验:允许留空,但输出安全警告
if (!config.server.auth) {
throw new Error('配置文件缺少必需字段: server.auth');
}
if (typeof config.server.auth !== 'string' || config.server.auth.length < 10) {
throw new Error('server.auth 必须是至少 10 个字符的字符串 (建议使用 npm run genkey 生成)');
logger.warn('配置器', 'server.auth 未配置!API 和 WebUI 将无需认证即可访问!');
logger.warn('配置器', '请勿在公网环境中留空 auth,建议使用 npm run genkey 生成密钥');
} else if (config.server.auth === 'sk-change-me-to-your-secure-key') {
logger.warn('配置器', '检测到默认密钥!请勿在公网环境中使用默认密钥');
} else if (typeof config.server.auth !== 'string' || config.server.auth.length < 10) {
logger.warn('配置器', 'server.auth 长度少于 10 个字符,安全性较低,建议使用 npm run genkey 生成密钥');
}
// 设置 keepalive 配置默认值
@@ -249,6 +252,12 @@ export function loadConfig() {
}
}
// 设置 browser 配置默认值
if (!config.browser) config.browser = {};
if (config.browser.humanizeCursor === undefined) {
config.browser.humanizeCursor = true;
}
// 设置 Pool 配置默认值
if (!config.backend) config.backend = {};
if (!config.backend.pool) config.backend.pool = {};
+2
View File
@@ -83,6 +83,7 @@ export function getBrowserConfig() {
path: browser.path || '',
headless: browser.headless || false,
fission: browser.fission !== false, // 默认 true
humanizeCursor: browser.humanizeCursor ?? true, // false | true | 'camou'
cssInject: {
animation: cssInject.animation || false,
filter: cssInject.filter || false,
@@ -112,6 +113,7 @@ export function saveBrowserConfig(data) {
if (data.path !== undefined) config.browser.path = data.path;
if (data.headless !== undefined) config.browser.headless = data.headless;
if (data.fission !== undefined) config.browser.fission = data.fission;
if (data.humanizeCursor !== undefined) config.browser.humanizeCursor = data.humanizeCursor;
// CSS 性能优化配置
if (data.cssInject) {
+3 -3
View File
@@ -22,12 +22,12 @@ export function validateServerConfig(data) {
}
}
// Auth Token 校验
// Auth Token 校验:允许留空,但非空时必须至少 10 个字符
if (data.authToken !== undefined) {
if (typeof data.authToken !== 'string') {
errors.push('authToken 必须是字符串');
} else if (data.authToken.length < 10) {
errors.push('authToken 必须至少 10 个字符');
} else if (data.authToken.length > 0 && data.authToken.length < 10) {
errors.push('authToken 如果设置则必须至少 10 个字符,或留空');
}
}
+4
View File
@@ -30,6 +30,10 @@ export function createAuthMiddleware(authToken) {
* @returns {boolean} 是否通过鉴权
*/
return function authMiddleware(req, res) {
// 如果 authToken 为空,跳过认证(允许所有请求)
if (!authToken) {
return true;
}
if (!checkAuth(req, authToken)) {
sendApiError(res, { code: ERROR_CODES.UNAUTHORIZED });
return false;
-5
View File
@@ -58,11 +58,6 @@ const PORT = config.server?.port || 3000;
/** @type {string} 认证令牌 */
const AUTH_TOKEN = config.server?.auth;
// 检测默认密钥
if (AUTH_TOKEN === 'sk-change-me-to-your-secure-key') {
logger.warn('服务器', '检测到默认密钥!如果在公网环境下请修改默认密钥');
}
/** @type {string} 心跳模式 */
const KEEPALIVE_MODE = config.server?.keepalive?.mode || 'comment';
-1
View File
@@ -1 +0,0 @@
import{c as i,I as u}from"./index-BgSomuu7.js";var l={icon:{tag:"svg",attrs:{viewBox:"64 64 896 896",focusable:"false"},children:[{tag:"path",attrs:{d:"M928 140H96c-17.7 0-32 14.3-32 32v496c0 17.7 14.3 32 32 32h380v112H304c-8.8 0-16 7.2-16 16v48c0 4.4 3.6 8 8 8h432c4.4 0 8-3.6 8-8v-48c0-8.8-7.2-16-16-16H548V700h380c17.7 0 32-14.3 32-32V172c0-17.7-14.3-32-32-32zm-40 488H136V212h752v416z"}}]},name:"desktop",theme:"outlined"};function c(r){for(var t=1;t<arguments.length;t++){var e=arguments[t]!=null?Object(arguments[t]):{},n=Object.keys(e);typeof Object.getOwnPropertySymbols=="function"&&(n=n.concat(Object.getOwnPropertySymbols(e).filter(function(a){return Object.getOwnPropertyDescriptor(e,a).enumerable}))),n.forEach(function(a){s(r,a,e[a])})}return r}function s(r,t,e){return t in r?Object.defineProperty(r,t,{value:e,enumerable:!0,configurable:!0,writable:!0}):r[t]=e,r}var o=function(t,e){var n=c({},t,e.attrs);return i(u,c({},n,{icon:l}),null)};o.displayName="DesktopOutlined";o.inheritAttrs=!1;export{o as D};
+1
View File
@@ -0,0 +1 @@
import{c as i,I as u}from"./index.js";var l={icon:{tag:"svg",attrs:{viewBox:"64 64 896 896",focusable:"false"},children:[{tag:"path",attrs:{d:"M928 140H96c-17.7 0-32 14.3-32 32v496c0 17.7 14.3 32 32 32h380v112H304c-8.8 0-16 7.2-16 16v48c0 4.4 3.6 8 8 8h432c4.4 0 8-3.6 8-8v-48c0-8.8-7.2-16-16-16H548V700h380c17.7 0 32-14.3 32-32V172c0-17.7-14.3-32-32-32zm-40 488H136V212h752v416z"}}]},name:"desktop",theme:"outlined"};function c(r){for(var t=1;t<arguments.length;t++){var e=arguments[t]!=null?Object(arguments[t]):{},n=Object.keys(e);typeof Object.getOwnPropertySymbols=="function"&&(n=n.concat(Object.getOwnPropertySymbols(e).filter(function(a){return Object.getOwnPropertyDescriptor(e,a).enumerable}))),n.forEach(function(a){s(r,a,e[a])})}return r}function s(r,t,e){return t in r?Object.defineProperty(r,t,{value:e,enumerable:!0,configurable:!0,writable:!0}):r[t]=e,r}var o=function(t,e){var n=c({},t,e.attrs);return i(u,c({},n,{icon:l}),null)};o.displayName="DesktopOutlined";o.inheritAttrs=!1;export{o as D};
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
View File
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
import{_ as b,k as w,l as k,o as c,b as C,d as S,w as n,c as o,g as e,f as a,h as i}from"./index-BgSomuu7.js";const B={style:{"margin-bottom":"8px"}},T={style:{"margin-bottom":"8px"}},z={style:{"margin-bottom":"8px"}},U={style:{display:"flex","justify-content":"flex-end","margin-top":"24px"}},M={style:{"margin-bottom":"8px"}},j={style:{"margin-bottom":"8px"}},q={style:{display:"flex","justify-content":"flex-end","margin-top":"24px"}},L={__name:"server",setup(N){const d=w(),s=k({port:5173,authToken:"",keepaliveMode:"comment",queueBuffer:2,imageLimit:5});c(async()=>{await d.fetchServerConfig(),Object.assign(s,d.serverConfig)});const m=async()=>{await d.saveServerConfig(s)};return(V,t)=>{const p=a("a-input-number"),r=a("a-col"),y=a("a-input-password"),u=a("a-select-option"),g=a("a-select"),f=a("a-row"),v=a("a-button"),x=a("a-card"),_=a("a-layout");return S(),C(_,{style:{background:"transparent"}},{default:n(()=>[o(x,{title:"服务器设置",bordered:!1,style:{width:"100%"}},{default:n(()=>[o(f,{gutter:[16,16]},{default:n(()=>[o(r,{xs:24,md:12},{default:n(()=>[e("div",B,[t[5]||(t[5]=e("div",{style:{"font-weight":"600","margin-bottom":"4px"}},"监听端口",-1)),t[6]||(t[6]=e("div",{style:{"font-size":"12px",color:"#8c8c8c","margin-bottom":"8px"}}," 设置服务器监听的端口号,默认为 5173 ",-1)),o(p,{value:s.port,"onUpdate:value":t[0]||(t[0]=l=>s.port=l),min:1,max:65535,placeholder:"请输入端口号",style:{width:"100%"}},null,8,["value"])])]),_:1}),o(r,{xs:24,md:12},{default:n(()=>[e("div",T,[t[7]||(t[7]=e("div",{style:{"font-weight":"600","margin-bottom":"4px"}},"鉴权 Token",-1)),t[8]||(t[8]=e("div",{style:{"font-size":"12px",color:"#8c8c8c","margin-bottom":"8px"}}," 用于 API 请求鉴权的密钥,留空则不启用鉴权 ",-1)),o(y,{value:s.authToken,"onUpdate:value":t[1]||(t[1]=l=>s.authToken=l),placeholder:"请输入 Token",type:"password"},null,8,["value"])])]),_:1}),o(r,{xs:24,md:12},{default:n(()=>[e("div",z,[t[11]||(t[11]=e("div",{style:{"font-weight":"600","margin-bottom":"4px"}},"心跳包类型",-1)),t[12]||(t[12]=e("div",{style:{"font-size":"12px",color:"#8c8c8c","margin-bottom":"8px"}}," 选择 SSE 流式响应的心跳包格式 ",-1)),o(g,{value:s.keepaliveMode,"onUpdate:value":t[2]||(t[2]=l=>s.keepaliveMode=l),style:{width:"100%"},placeholder:"请选择心跳包类型"},{default:n(()=>[o(u,{value:"comment"},{default:n(()=>[...t[9]||(t[9]=[i("Comment - 注释格式",-1)])]),_:1}),o(u,{value:"content"},{default:n(()=>[...t[10]||(t[10]=[i("Content - 内容格式",-1)])]),_:1})]),_:1},8,["value"])])]),_:1})]),_:1}),e("div",U,[o(v,{type:"primary",onClick:m},{default:n(()=>[...t[13]||(t[13]=[i(" 保存设置 ",-1)])]),_:1})])]),_:1}),o(x,{title:"队列设置",bordered:!1,style:{width:"100%","margin-top":"10px"}},{default:n(()=>[o(f,{gutter:[16,16]},{default:n(()=>[o(r,{xs:24,md:12},{default:n(()=>[e("div",M,[t[14]||(t[14]=e("div",{style:{"font-weight":"600","margin-bottom":"4px"}},"队列缓冲区大小",-1)),t[15]||(t[15]=e("div",{style:{"font-size":"12px",color:"#8c8c8c","margin-bottom":"8px"}},[i(" 非流式请求的额外排队数(设为 0 则不限制非流式请求数量)"),e("br"),i(" 实际队列上限 = Workers数量 + 缓冲区大小 ")],-1)),o(p,{value:s.queueBuffer,"onUpdate:value":t[3]||(t[3]=l=>s.queueBuffer=l),min:0,max:100,placeholder:"默认为 2",style:{width:"100%"}},null,8,["value"])])]),_:1}),o(r,{xs:24,md:12},{default:n(()=>[e("div",j,[t[16]||(t[16]=e("div",{style:{"font-weight":"600","margin-bottom":"4px"}},"图片数量上限",-1)),t[17]||(t[17]=e("div",{style:{"font-size":"12px",color:"#8c8c8c","margin-bottom":"8px"}},[i(" 单次请求最多支持的图片附件数量"),e("br"),i(" 网页最多支持10个附件,超出会被丢弃 ")],-1)),o(p,{value:s.imageLimit,"onUpdate:value":t[4]||(t[4]=l=>s.imageLimit=l),min:1,max:10,placeholder:"默认为 5",style:{width:"100%"}},null,8,["value"])])]),_:1})]),_:1}),e("div",q,[o(v,{type:"primary",onClick:m},{default:n(()=>[...t[18]||(t[18]=[i(" 保存设置 ",-1)])]),_:1})])]),_:1})]),_:1})}}},A=b(L,[["__scopeId","data-v-bd32923f"]]);export{A as default};
-1
View File
@@ -1 +0,0 @@
.ant-input-number[data-v-bd32923f]{width:100%}
+1
View File
@@ -0,0 +1 @@
.ant-input-number[data-v-5b571484]{width:100%}
+1
View File
@@ -0,0 +1 @@
import{_ as k,k as w,l as c,o as T,b as C,d as S,w as a,c as o,g as e,f as l,h as i,m as B,M}from"./index.js";const U={style:{"margin-bottom":"8px"}},z={style:{"margin-bottom":"8px"}},I={style:{"margin-bottom":"8px"}},j={style:{display:"flex","justify-content":"flex-end","margin-top":"24px"}},q={style:{"margin-bottom":"8px"}},L={style:{"margin-bottom":"8px"}},N={style:{display:"flex","justify-content":"flex-end","margin-top":"24px"}},V={__name:"server",setup(A){const d=w(),n=c({port:5173,authToken:"",keepaliveMode:"comment",queueBuffer:2,imageLimit:5});T(async()=>{await d.fetchServerConfig(),Object.assign(n,d.serverConfig)});const m=async()=>{await d.saveServerConfig(n)},u=async()=>{if(n.authToken&&n.authToken.length>0&&n.authToken.length<10){B.error("鉴权 Token 如果设置则必须至少 10 个字符,或留空");return}if(!n.authToken){M.confirm({title:"安全警告",content:"您正在将鉴权 Token 留空,这意味着 API 和 WebUI 将无需认证即可访问。请勿在公网环境中使用此配置!确定要继续吗?",okText:"确定留空",okType:"danger",cancelText:"取消",onOk:m});return}await m()};return(O,t)=>{const p=l("a-input-number"),r=l("a-col"),y=l("a-input-password"),f=l("a-select-option"),_=l("a-select"),v=l("a-row"),x=l("a-button"),g=l("a-card"),b=l("a-layout");return S(),C(b,{style:{background:"transparent"}},{default:a(()=>[o(g,{title:"服务器设置",bordered:!1,style:{width:"100%"}},{default:a(()=>[o(v,{gutter:[16,16]},{default:a(()=>[o(r,{xs:24,md:12},{default:a(()=>[e("div",U,[t[5]||(t[5]=e("div",{style:{"font-weight":"600","margin-bottom":"4px"}},"监听端口",-1)),t[6]||(t[6]=e("div",{style:{"font-size":"12px",color:"#8c8c8c","margin-bottom":"8px"}}," 设置服务器监听的端口号,默认为 5173 ",-1)),o(p,{value:n.port,"onUpdate:value":t[0]||(t[0]=s=>n.port=s),min:1,max:65535,placeholder:"请输入端口号",style:{width:"100%"}},null,8,["value"])])]),_:1}),o(r,{xs:24,md:12},{default:a(()=>[e("div",z,[t[7]||(t[7]=e("div",{style:{"font-weight":"600","margin-bottom":"4px"}},"鉴权 Token",-1)),t[8]||(t[8]=e("div",{style:{"font-size":"12px",color:"#8c8c8c","margin-bottom":"8px"}}," 用于 API 请求鉴权的密钥,留空则不启用鉴权 ",-1)),o(y,{value:n.authToken,"onUpdate:value":t[1]||(t[1]=s=>n.authToken=s),placeholder:"请输入 Token",type:"password"},null,8,["value"])])]),_:1}),o(r,{xs:24,md:12},{default:a(()=>[e("div",I,[t[11]||(t[11]=e("div",{style:{"font-weight":"600","margin-bottom":"4px"}},"心跳包类型",-1)),t[12]||(t[12]=e("div",{style:{"font-size":"12px",color:"#8c8c8c","margin-bottom":"8px"}}," 选择 SSE 流式响应的心跳包格式 ",-1)),o(_,{value:n.keepaliveMode,"onUpdate:value":t[2]||(t[2]=s=>n.keepaliveMode=s),style:{width:"100%"},placeholder:"请选择心跳包类型"},{default:a(()=>[o(f,{value:"comment"},{default:a(()=>[...t[9]||(t[9]=[i("Comment - 注释格式",-1)])]),_:1}),o(f,{value:"content"},{default:a(()=>[...t[10]||(t[10]=[i("Content - 内容格式",-1)])]),_:1})]),_:1},8,["value"])])]),_:1})]),_:1}),e("div",j,[o(x,{type:"primary",onClick:u},{default:a(()=>[...t[13]||(t[13]=[i(" 保存设置 ",-1)])]),_:1})])]),_:1}),o(g,{title:"队列设置",bordered:!1,style:{width:"100%","margin-top":"10px"}},{default:a(()=>[o(v,{gutter:[16,16]},{default:a(()=>[o(r,{xs:24,md:12},{default:a(()=>[e("div",q,[t[14]||(t[14]=e("div",{style:{"font-weight":"600","margin-bottom":"4px"}},"队列缓冲区大小",-1)),t[15]||(t[15]=e("div",{style:{"font-size":"12px",color:"#8c8c8c","margin-bottom":"8px"}},[i(" 非流式请求的额外排队数(设为 0 则不限制非流式请求数量)"),e("br"),i(" 实际队列上限 = Workers数量 + 缓冲区大小 ")],-1)),o(p,{value:n.queueBuffer,"onUpdate:value":t[3]||(t[3]=s=>n.queueBuffer=s),min:0,max:100,placeholder:"默认为 2",style:{width:"100%"}},null,8,["value"])])]),_:1}),o(r,{xs:24,md:12},{default:a(()=>[e("div",L,[t[16]||(t[16]=e("div",{style:{"font-weight":"600","margin-bottom":"4px"}},"图片数量上限",-1)),t[17]||(t[17]=e("div",{style:{"font-size":"12px",color:"#8c8c8c","margin-bottom":"8px"}},[i(" 单次请求最多支持的图片附件数量"),e("br"),i(" 网页最多支持10个附件,超出会被丢弃 ")],-1)),o(p,{value:n.imageLimit,"onUpdate:value":t[4]||(t[4]=s=>n.imageLimit=s),min:1,max:10,placeholder:"默认为 5",style:{width:"100%"}},null,8,["value"])])]),_:1})]),_:1}),e("div",N,[o(x,{type:"primary",onClick:u},{default:a(()=>[...t[18]||(t[18]=[i(" 保存设置 ",-1)])]),_:1})])]),_:1})]),_:1})}}},W=k(V,[["__scopeId","data-v-5b571484"]]);export{W as default};
-1
View File
@@ -1 +0,0 @@
import{y as i,k as a,v as r}from"./index-BgSomuu7.js";const u=i("system",{state:()=>({status:"",version:"1.0.0",systemVersion:"",uptime:0,cpuUsage:0,memoryUsage:{total:0,used:0,free:0},safeMode:{enabled:!1,reason:null},stats:{totalRequests:0,successRate:0,activeWorkers:0,totalWorkers:0,avgResponseTime:0,success:0,failed:0}}),actions:{async fetchStatus(){const t=a();try{const e=await fetch("/admin/status",{headers:t.getHeaders()});if(e.ok){const s=await e.json();this.$patch(s)}}catch(e){console.error("Failed to fetch system status:",e)}},async fetchStats(){const t=a();try{const e=await fetch("/admin/stats",{headers:t.getHeaders()});if(e.ok){const s=await e.json();this.stats=s}}catch(e){console.error("Failed to fetch stats:",e)}},async restartService(t={}){const e=a(),{loginMode:s,workerName:n}=t;try{const o=await(await fetch("/admin/restart",{method:"POST",headers:{...e.getHeaders(),"Content-Type":"application/json"},body:JSON.stringify({loginMode:s,workerName:n})})).json();return o.success?(r.success(o.message||"服务重启中..."),!0):(r.error("重启失败"),!1)}catch{return r.error("重启请求失败"),!1}},async stopService(){const t=a();try{const s=await(await fetch("/admin/stop",{method:"POST",headers:t.getHeaders()})).json();return s.success?(r.success(s.message||"服务停止中..."),!0):(r.error("停止失败"),!1)}catch{return r.error("停止请求失败"),!1}}}});export{u};
+1
View File
@@ -0,0 +1 @@
import{y as i,k as a,m as r}from"./index.js";const u=i("system",{state:()=>({status:"",version:"1.0.0",systemVersion:"",uptime:0,cpuUsage:0,memoryUsage:{total:0,used:0,free:0},safeMode:{enabled:!1,reason:null},stats:{totalRequests:0,successRate:0,activeWorkers:0,totalWorkers:0,avgResponseTime:0,success:0,failed:0}}),actions:{async fetchStatus(){const t=a();try{const e=await fetch("/admin/status",{headers:t.getHeaders()});if(e.ok){const s=await e.json();this.$patch(s)}}catch(e){console.error("Failed to fetch system status:",e)}},async fetchStats(){const t=a();try{const e=await fetch("/admin/stats",{headers:t.getHeaders()});if(e.ok){const s=await e.json();this.stats=s}}catch(e){console.error("Failed to fetch stats:",e)}},async restartService(t={}){const e=a(),{loginMode:s,workerName:n}=t;try{const o=await(await fetch("/admin/restart",{method:"POST",headers:{...e.getHeaders(),"Content-Type":"application/json"},body:JSON.stringify({loginMode:s,workerName:n})})).json();return o.success?(r.success(o.message||"服务重启中..."),!0):(r.error("重启失败"),!1)}catch{return r.error("重启请求失败"),!1}},async stopService(){const t=a();try{const s=await(await fetch("/admin/stop",{method:"POST",headers:t.getHeaders()})).json();return s.success?(r.success(s.message||"服务停止中..."),!0):(r.error("停止失败"),!1)}catch{return r.error("停止请求失败"),!1}}}});export{u};
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -6,8 +6,8 @@
<link rel="icon" type="image/png" href="/favicon.png">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WebAI2API</title>
<script type="module" crossorigin src="/assets/index-BgSomuu7.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BVr8U7Bl.css">
<script type="module" crossorigin src="/assets/index.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index.css">
</head>
<body>
+31 -7
View File
@@ -9,6 +9,7 @@ const formData = reactive({
path: '',
headless: false,
fission: true,
humanizeCursor: false, // false | true | 'camou'
// CSS 性能优化
cssAnimation: false,
cssFilter: false,
@@ -29,6 +30,8 @@ onMounted(async () => {
formData.path = cfg.path || '';
formData.headless = cfg.headless || false;
formData.fission = cfg.fission !== false; // 默认 true
// humanizeCursor: false=禁用, true=ghost-cursor, 'camou'=Camoufox内置
formData.humanizeCursor = cfg.humanizeCursor ?? false;
// CSS 性能优化
if (cfg.cssInject) {
@@ -59,6 +62,7 @@ const handleSave = async () => {
font: formData.cssFont
},
fission: formData.fission,
humanizeCursor: formData.humanizeCursor,
proxy: {
enable: formData.proxyEnable,
type: formData.proxyType,
@@ -120,6 +124,27 @@ const handleSave = async () => {
</span>
</div>
</a-col>
<!-- 拟人鼠标轨迹 -->
<a-col :xs="24" :md="24">
<div style="margin-bottom: 8px;">
<div style="font-weight: 600; margin-bottom: 4px;">拟人鼠标轨迹模式</div>
<div style="font-size: 12px; color: #8c8c8c; margin-bottom: 8px;">
控制鼠标点击的拟人化程度影响性能和反爬检测风险
</div>
<a-segmented v-model:value="formData.humanizeCursor" block :options="[
{ label: '禁用 (性能最佳)', value: false },
{ label: 'Ghost-Cursor (更拟人)', value: true },
{ label: 'Camoufox内置 (平衡)', value: 'camou' }
]" />
<div style="font-size: 11px; color: #8c8c8c; margin-top: 6px;">
<span v-if="formData.humanizeCursor === false">使用 Playwright 原生点击性能最好但可能被检测为自动化</span>
<span v-else-if="formData.humanizeCursor === true">使用项目优化的 ghost-cursor
模拟人类鼠标轨迹如不会点击正中心性能稍差</span>
<span v-else>使用 Camoufox 内置的 humanize 功能性能与拟人化的平衡选择</span>
</div>
</div>
</a-col>
</a-row>
<!-- 全局代理设置折叠面板 -->
@@ -196,12 +221,8 @@ const handleSave = async () => {
<!-- CSS 性能优化 -->
<a-collapse-panel key="cssInject" header="CSS 性能优化注入">
<a-alert
message="⚡ 适用于无 GPU 的服务器环境,通过禁用网页特效来降低 CPU 压力"
type="info"
show-icon
style="margin-bottom: 16px;"
/>
<a-alert message="⚡ 适用于无 GPU 的服务器环境,通过禁用网页特效来降低 CPU 压力" type="info" show-icon
style="margin-bottom: 16px;" />
<!-- 禁用动画 -->
<div style="margin-bottom: 16px; padding: 12px; background: #fafafa; border-radius: 6px;">
@@ -211,7 +232,10 @@ const handleSave = async () => {
<div style="font-size: 12px; color: #8c8c8c;">
移除 transition animation显著降低 CPU 持续占用
</div>
<a-tag color="green" style="margin-top: 6px;">风险</a-tag>
<a-tag color="green" style="margin-top: 6px;">风险</a-tag>
<span style="font-size: 11px; color: #389e0d; margin-left: 8px;">
几乎不影响浏览器指纹但可能导致部分网页布局异常
</span>
</div>
<a-switch v-model:checked="formData.cssAnimation" />
</div>
+28 -2
View File
@@ -1,6 +1,7 @@
<script setup>
import { onMounted, reactive } from 'vue';
import { useSettingsStore } from '@/stores/settings';
import { Modal, message } from 'ant-design-vue';
const settingsStore = useSettingsStore();
@@ -18,10 +19,35 @@ onMounted(async () => {
Object.assign(formData, settingsStore.serverConfig);
});
// 保存设置
const handleSave = async () => {
// 实际保存逻辑
const doSave = async () => {
await settingsStore.saveServerConfig(formData);
};
// 保存设置 (带校验和确认弹窗)
const handleSave = async () => {
// 前端校验:Token 长度在 1-9 之间时提示
if (formData.authToken && formData.authToken.length > 0 && formData.authToken.length < 10) {
message.error('鉴权 Token 如果设置则必须至少 10 个字符,或留空');
return;
}
// Token 留空时弹出确认框
if (!formData.authToken) {
Modal.confirm({
title: '安全警告',
content: '您正在将鉴权 Token 留空,这意味着 API 和 WebUI 将无需认证即可访问。请勿在公网环境中使用此配置!确定要继续吗?',
okText: '确定留空',
okType: 'danger',
cancelText: '取消',
onOk: doSave
});
return;
}
// 正常保存
await doSave();
};
</script>
<template>
+3 -1
View File
@@ -61,9 +61,11 @@ export const useSettingsStore = defineStore('settings', {
return { success: true, data };
} else {
console.error('Request failed:', res.status, data);
// 后端返回格式: { error: { message: "..." } } 或 { message: "..." }
const errorMessage = data.error?.message || data.message || `请求未成功: ${res.status} ${res.statusText}`;
Modal.error({
title: '保存失败',
content: data.message || `请求未成功: ${res.status} ${res.statusText}`,
content: errorMessage,
okText: '好的'
});
return { success: false, data };
+9
View File
@@ -4,6 +4,15 @@ import path from 'path'
export default defineConfig({
plugins: [vue()],
build: {
rollupOptions: {
output: {
entryFileNames: 'assets/[name].js',
chunkFileNames: 'assets/[name].js',
assetFileNames: 'assets/[name].[ext]'
}
}
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src')