mirror of
https://github.com/foxhui/WebAI2API.git
synced 2026-06-16 21:03:59 +08:00
feat: 增加拟人鼠标轨迹选择,Token 允许留空 (closes #12)
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
# 禁用滤镜和阴影
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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 抗指纹:注入固定噪点偏移
|
||||
|
||||
@@ -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 提交文件');
|
||||
|
||||
|
||||
@@ -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
@@ -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 = {};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 个字符,或留空');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 +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};
|
||||
Vendored
+1
@@ -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};
|
||||
-1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
-2
File diff suppressed because one or more lines are too long
Vendored
+2
File diff suppressed because one or more lines are too long
Vendored
-1
@@ -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
@@ -1 +0,0 @@
|
||||
.ant-input-number[data-v-bd32923f]{width:100%}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
.ant-input-number[data-v-5b571484]{width:100%}
|
||||
Vendored
+1
@@ -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};
|
||||
Vendored
-1
@@ -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};
|
||||
Vendored
+1
@@ -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};
|
||||
-1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user