diff --git a/components/Main.vue b/components/Main.vue index a4f1bce..3930b79 100644 --- a/components/Main.vue +++ b/components/Main.vue @@ -398,6 +398,27 @@ + + + + + 自定义请求体 + + + + + + + + 请输入合法的 JSON 对象,否则该配置将被忽略 + + + + @@ -1079,6 +1100,17 @@ const isValidAzureEndpoint = (endpoint: string) => { return hasHttps && hasAzureDomain && hasChatCompletions; }; +// 自定义请求体(JSON)校验:留空视为合法(不启用),否则必须是 JSON 对象 +const isValidCustomBody = (str: string | undefined): boolean => { + if (!str || !str.trim()) return true; + try { + const v = JSON.parse(str); + return v !== null && typeof v === 'object' && !Array.isArray(v); + } catch { + return false; + } +}; + const handleExport = async () => { const configStr = await storage.getItem('local:config'); if (!configStr) { @@ -1117,6 +1149,19 @@ const handleExport = async () => { } } + // Clean empty customBody entries + if (cleanedConfig.customBody) { + for (const service in cleanedConfig.customBody) { + const value = cleanedConfig.customBody[service]; + if (!value || !String(value).trim()) { + delete cleanedConfig.customBody[service]; + } + } + if (Object.keys(cleanedConfig.customBody).length === 0) { + delete cleanedConfig.customBody; + } + } + exportData.value = JSON.stringify(cleanedConfig, null, 2); showExportBox.value = !showExportBox.value; showImportBox.value = false; diff --git a/entrypoints/utils/model.ts b/entrypoints/utils/model.ts index 915e58c..3d9915e 100644 --- a/entrypoints/utils/model.ts +++ b/entrypoints/utils/model.ts @@ -25,6 +25,7 @@ export class Config { key: string; model: IMapping; customModel: IMapping; // 自定义模型名称 + customBody: IMapping; // 自定义请求体(JSON 字符串,按服务存储),会合并进请求体 proxy: IMapping; // 代理地址 custom: string; // 本地服务地址 extra: IExtra; // 额外信息(内包信息) @@ -70,6 +71,7 @@ export class Config { this.key = ''; this.model = {}; this.customModel = {}; + this.customBody = {}; this.proxy = {}; this.custom = defaultOption.custom; this.extra = {}; diff --git a/entrypoints/utils/template.ts b/entrypoints/utils/template.ts index 865b492..739c525 100644 --- a/entrypoints/utils/template.ts +++ b/entrypoints/utils/template.ts @@ -2,6 +2,29 @@ import {customModelString, defaultOption, services} from "./option"; import {config} from "@/entrypoints/utils/config"; +// 将用户自定义的 JSON 请求体合并进 payload(顶层浅合并,用户字段优先)。 +// 主要用于向请求体补充额外参数(例如 {"thinking": {"type": "disabled"}} 这类控制字段)。 +export function mergeCustomBody(payload: Record, raw?: string | null): Record { + if (!raw || !raw.trim()) return payload; + try { + const extra = JSON.parse(raw); + // 仅接受 JSON 对象(排除数组、null 及基本类型) + if (extra && typeof extra === 'object' && !Array.isArray(extra)) { + Object.assign(payload, extra); + } else { + console.warn('[FluentRead] 自定义请求体必须是 JSON 对象,已忽略:', raw); + } + } catch (e) { + console.warn('[FluentRead] 自定义请求体不是合法的 JSON,已忽略:', raw, e); + } + return payload; +} + +// 读取当前服务的自定义请求体(JSON 字符串) +function currentCustomBody(): string | undefined { + return config.customBody?.[config.service]; +} + // openai 格式的消息模板(通用模板) export function commonMsgTemplate(origin: string) { // 检测是否使用自定义模型 @@ -14,14 +37,16 @@ export function commonMsgTemplate(origin: string) { let user = (config.user_role[config.service] || defaultOption.user_role) .replace('{{to}}', config.to).replace('{{origin}}', origin); - return JSON.stringify({ + const payload: any = { 'model': model, "temperature": 1.0, 'messages': [ {'role': 'system', 'content': system}, {'role': 'user', 'content': user}, ] - }) + }; + + return JSON.stringify(mergeCustomBody(payload, currentCustomBody())) } // deepseek @@ -49,7 +74,7 @@ export function deepseekMsgTemplate(origin: string) { payload.temperature = 0.7; } - return JSON.stringify(payload); + return JSON.stringify(mergeCustomBody(payload, currentCustomBody())); } // gemini @@ -57,11 +82,13 @@ export function geminiMsgTemplate(origin: string) { let user = (config.user_role[config.service] || defaultOption.user_role) .replace('{{to}}', config.to).replace('{{origin}}', origin); - return JSON.stringify({ + const payload: any = { "contents": [ {"role": "user", "parts": [{"text": user}]}, ] - }) + }; + + return JSON.stringify(mergeCustomBody(payload, currentCustomBody())) } // claude @@ -75,7 +102,7 @@ export function claudeMsgTemplate(origin: string) { let user = (config.user_role[config.service] || defaultOption.user_role) .replace('{{to}}', config.to).replace('{{origin}}', origin); - return JSON.stringify({ + const payload: any = { model: model, max_tokens: 4096, stream: false, @@ -83,7 +110,9 @@ export function claudeMsgTemplate(origin: string) { messages: [ {role: "user", content: user}, ] - }) + }; + + return JSON.stringify(mergeCustomBody(payload, currentCustomBody())) } // 通义千问 @@ -94,14 +123,15 @@ export function tongyiMsgTemplate(origin: string) { let user = (config.user_role[config.service] || defaultOption.user_role) .replace('{{to}}', config.to).replace('{{origin}}', origin); - return JSON.stringify({ + const payload: any = { "model": model, "enable_thinking": false, "messages": [ {"role": "system", "content": system}, {"role": "user", "content": user}, ] - }) + }; + return JSON.stringify(mergeCustomBody(payload, currentCustomBody())) } // 翻译模型qwen-mt-plus和qwen-mt-turbo的格式和通用的不同 const mtModelTemplate = () => { @@ -115,7 +145,7 @@ export function tongyiMsgTemplate(origin: string) { ] let targetItem = langMap.find(i => i.value === config.to) || langMap[0] let targetLang = targetItem.target || targetItem.value - return JSON.stringify({ + const payload: any = { "model": model, "messages": [ {"role": "user", "content": origin}, @@ -124,7 +154,8 @@ export function tongyiMsgTemplate(origin: string) { "source_lang": "auto", "target_lang": targetLang } - }) + }; + return JSON.stringify(mergeCustomBody(payload, currentCustomBody())) } return model.startsWith("qwen-mt") ? mtModelTemplate() : normalTemplate() @@ -135,13 +166,15 @@ export function yiyanMsgTemplate(origin: string) { let user = (config.user_role[config.service] || defaultOption.user_role) .replace('{{to}}', config.to).replace('{{origin}}', origin); - return JSON.stringify({ + const payload: any = { 'temperature': 0.7, 'disable_search': true, // 禁用搜索 'messages': [ {"role": "user", "content": user}, ], - }) + }; + + return JSON.stringify(mergeCustomBody(payload, currentCustomBody())) } export function minimaxTemplate(origin: string) { @@ -150,7 +183,7 @@ export function minimaxTemplate(origin: string) { let user = (config.user_role[config.service] || defaultOption.user_role) .replace('{{to}}', config.to).replace('{{origin}}', origin); - return JSON.stringify({ + const payload: any = { model: "MiniMax-Text-01", stream: false, temperature: 0.7, @@ -158,7 +191,9 @@ export function minimaxTemplate(origin: string) { {role: 'system', content: system}, {role: 'user', content: user}, ] - }) + }; + + return JSON.stringify(mergeCustomBody(payload, currentCustomBody())) } export function cozeTemplate(origin: string) { @@ -167,10 +202,12 @@ export function cozeTemplate(origin: string) { let user = (config.user_role[config.service] || defaultOption.user_role) .replace('{{to}}', config.to).replace('{{origin}}', origin); - return JSON.stringify({ + const payload: any = { bot_id: config.robot_id[config.service], user: "FluentRead", query: system + user, stream: false - }); + }; + + return JSON.stringify(mergeCustomBody(payload, currentCustomBody())); } diff --git a/package.json b/package.json index e8b17fb..7b5a1fb 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,8 @@ "zip": "wxt zip", "zip:firefox": "wxt zip -b firefox", "compile": "vue-tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", "postinstall": "wxt prepare", "docs:dev": "vitepress dev docs", "docs:build": "vitepress build docs", @@ -37,10 +39,16 @@ "vite": "^5.4.11", "vite-plugin-imagemin": "^0.6.1", "vitepress": "^1.6.3", + "vitest": "^2.1.9", "vue": "^3.5.13", "vue-tsc": "^2.2.0", "vuepress": "2.0.0-rc.19", "wxt": "^0.20.18" }, + "pnpm": { + "overrides": { + "vite": "5.4.11" + } + }, "packageManager": "pnpm@9.12.1+sha1.7084e7df42ee1d221994c2c2599277a2a937f050" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 54b3497..51c779b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + vite: 5.4.11 + importers: .: @@ -52,7 +55,7 @@ importers: specifier: ^5.7.3 version: 5.7.3 vite: - specifier: ^5.4.11 + specifier: 5.4.11 version: 5.4.11(@types/node@22.10.7) vite-plugin-imagemin: specifier: ^0.6.1 @@ -60,6 +63,9 @@ importers: vitepress: specifier: ^1.6.3 version: 1.6.3(@algolia/client-search@5.20.0)(@types/node@22.10.7)(async-validator@4.2.5)(postcss@8.5.1)(search-insights@2.17.3)(typescript@5.7.3) + vitest: + specifier: ^2.1.9 + version: 2.1.9(@types/node@22.10.7) vue: specifier: ^3.5.13 version: 3.5.13(typescript@5.7.3) @@ -882,9 +888,38 @@ packages: resolution: {integrity: sha512-cxh314tzaWwOLqVes2gnnCtvBDcM1UMdn+iFR+UjAn411dPT3tOmqrJjbMd7koZpMAmBM/GqeV4n9ge7JSiJJQ==} engines: {node: ^18.0.0 || >=20.0.0} peerDependencies: - vite: ^5.0.0 || ^6.0.0 + vite: 5.4.11 vue: ^3.2.25 + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + peerDependencies: + msw: ^2.4.9 + vite: 5.4.11 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + + '@vitest/runner@2.1.9': + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + '@volar/language-core@2.4.11': resolution: {integrity: sha512-lN2C1+ByfW9/JRPpqScuZt/4OrUUse57GLI6TbLgTIqBVemdl1wNcZ1qYGEo2+Gw8coYLgCy7SuKqn6IrQcQgg==} @@ -1136,6 +1171,10 @@ packages: resolution: {integrity: sha512-1OvF9IbWwaeiM9VhzYXVQacMibxpXOMYVNIvMtKRyX9SImBXpKcFr8XvFDeEslCyuH/t6KRt7HEO94AlP8Iatw==} engines: {node: '>=12'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + async-mutex@0.5.0: resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==} @@ -1290,6 +1329,10 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + chalk@1.1.3: resolution: {integrity: sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==} engines: {node: '>=0.10.0'} @@ -1312,6 +1355,10 @@ packages: character-entities-legacy@3.0.0: resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -1571,6 +1618,10 @@ packages: resolution: {integrity: sha512-e48kc2IjU+2Zw8cTb6VZcJQ3lgVbS4uuB1TfCHbiZIP/haNXm+SVyhu+87jts5/3ROpd82GSVCoNs/z8l4ZOaQ==} engines: {node: '>=4'} + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + deep-extend@0.6.0: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} @@ -1926,6 +1977,10 @@ packages: resolution: {integrity: sha512-8iA79xD3uAch729dUG8xaaBBFGaEa0wdD2VkYLFHwlqosEj/jT66AzcreRDSgV7ehnNLBW2WR5jIXwGKjVdTLg==} engines: {node: '>=4'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + exsolve@1.0.8: resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} @@ -2668,6 +2723,9 @@ packages: resolution: {integrity: sha512-RPNliZOFkqFumDhvYqOaNY4Uz9oJM2K9tC6JWsJJsNdhuONW4LQHRBpb0qf4pJApVffI5N39SwzWZJuEhfd7eQ==} engines: {node: '>=0.10.0'} + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lowercase-keys@1.0.0: resolution: {integrity: sha512-RPlX0+PHuvxVDZ7xX+EBVAp4RsVxP/TdDSN2mJYdiq1Lc4Hz7EUSjUI7RZrKKlmrIzVhf6Jo2stj7++gVarS0A==} engines: {node: '>=0.10.0'} @@ -3101,9 +3159,16 @@ packages: pathe@0.2.0: resolution: {integrity: sha512-sTitTPYnn23esFR3RlqYBWn4c45WGeLcsKzQiUpXJAyfcWkolvlYpV8FLo7JishK946oQwMFUCHXQ9AjGPKExw==} + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + pend@1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} @@ -3424,6 +3489,9 @@ packages: shiki@2.1.0: resolution: {integrity: sha512-yvKPdNGLXZv7WC4bl7JBbU3CEcUxnBanvMez8MG3gZXKpClGL4bHqFyLhTx+2zUvbjClUANs/S22HXb7aeOgmA==} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} @@ -3520,6 +3588,12 @@ packages: resolution: {integrity: sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==} deprecated: 'Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility' + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + stdin-discarder@0.1.0: resolution: {integrity: sha512-xhV7w8S+bUwlPTb4bAOUQhv8/cSS5offJuX8GQGq32ONF0ZtDWKfkdomM3HMRA+LhX6um/FZ0COqlwsjD53LeQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -3683,10 +3757,28 @@ packages: resolution: {integrity: sha512-G7r3AhovYtr5YKOWQkta8RKAPb+J9IsO4uVmzjl8AZwfhs8UcUwTiD6gcJYSgOtzyjvQKrKYn41syHbUWMkafA==} engines: {node: '>=0.10.0'} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyexec@1.0.2: resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} engines: {node: '>=18'} + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + titleize@3.0.0: resolution: {integrity: sha512-KxVu8EYHDPBdUYdKZdKtU2aj2XfEx9AfjXxE/Aj0vT06w2icA09Vus1rh6eSu1y01akYg6BjIK/hxyLJINoMLQ==} engines: {node: '>=12'} @@ -3832,6 +3924,11 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + vite-node@3.2.4: resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -3840,7 +3937,7 @@ packages: vite-plugin-imagemin@0.6.1: resolution: {integrity: sha512-cP7LDn8euPrji7WYtDoNQpJEB9nkMxJHm/A+QZnvMrrCSuyo/clpMy/T1v7suDXPBavsDiDdFdVQB5p7VGD2cg==} peerDependencies: - vite: '>=2.0.0' + vite: 5.4.11 vite@5.4.11: resolution: {integrity: sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==} @@ -3873,78 +3970,41 @@ packages: terser: optional: true - vite@5.4.14: - resolution: {integrity: sha512-EK5cY7Q1D8JNhSaPKVK4pwBFvaTmZxEnoKXLG/U9gmdDcihQGNzFlgIvaxezFR4glP1LsuiedwMBqCXH3wZccA==} - engines: {node: ^18.0.0 || >=20.0.0} + vitepress@1.6.3: + resolution: {integrity: sha512-fCkfdOk8yRZT8GD9BFqusW3+GggWYZ/rYncOfmgcDtP3ualNHCAg+Robxp2/6xfH1WwPHtGpPwv7mbA3qomtBw==} hasBin: true peerDependencies: - '@types/node': ^18.0.0 || >=20.0.0 - less: '*' - lightningcss: ^1.21.0 - sass: '*' - sass-embedded: '*' - stylus: '*' - sugarss: '*' - terser: ^5.4.0 + markdown-it-mathjax3: ^4 + postcss: ^8 peerDependenciesMeta: - '@types/node': - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: + markdown-it-mathjax3: optional: true - terser: + postcss: optional: true - vite@5.4.19: - resolution: {integrity: sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==} + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} engines: {node: ^18.0.0 || >=20.0.0} hasBin: true peerDependencies: + '@edge-runtime/vm': '*' '@types/node': ^18.0.0 || >=20.0.0 - less: '*' - lightningcss: ^1.21.0 - sass: '*' - sass-embedded: '*' - stylus: '*' - sugarss: '*' - terser: ^5.4.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' peerDependenciesMeta: - '@types/node': - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: + '@edge-runtime/vm': optional: true - stylus: + '@types/node': optional: true - sugarss: + '@vitest/browser': optional: true - terser: + '@vitest/ui': optional: true - - vitepress@1.6.3: - resolution: {integrity: sha512-fCkfdOk8yRZT8GD9BFqusW3+GggWYZ/rYncOfmgcDtP3ualNHCAg+Robxp2/6xfH1WwPHtGpPwv7mbA3qomtBw==} - hasBin: true - peerDependencies: - markdown-it-mathjax3: ^4 - postcss: ^8 - peerDependenciesMeta: - markdown-it-mathjax3: + happy-dom: optional: true - postcss: + jsdom: optional: true vscode-uri@3.0.8: @@ -4030,6 +4090,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + widest-line@5.0.0: resolution: {integrity: sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==} engines: {node: '>=18'} @@ -4816,10 +4881,45 @@ snapshots: vite: 5.4.11(@types/node@22.10.7) vue: 3.5.13(typescript@5.7.3) - '@vitejs/plugin-vue@5.2.1(vite@5.4.14(@types/node@22.10.7))(vue@3.5.13(typescript@5.7.3))': + '@vitest/expect@2.1.9': dependencies: - vite: 5.4.14(@types/node@22.10.7) - vue: 3.5.13(typescript@5.7.3) + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 + + '@vitest/mocker@2.1.9(vite@5.4.11(@types/node@22.10.7))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.17 + optionalDependencies: + vite: 5.4.11(@types/node@22.10.7) + + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/runner@2.1.9': + dependencies: + '@vitest/utils': 2.1.9 + pathe: 1.1.2 + + '@vitest/snapshot@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.17 + pathe: 1.1.2 + + '@vitest/spy@2.1.9': + dependencies: + tinyspy: 3.0.2 + + '@vitest/utils@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 '@volar/language-core@2.4.11': dependencies: @@ -5146,6 +5246,8 @@ snapshots: array-union@3.0.1: {} + assertion-error@2.0.1: {} + async-mutex@0.5.0: dependencies: tslib: 2.8.1 @@ -5331,6 +5433,14 @@ snapshots: ccount@2.0.1: {} + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + chalk@1.1.3: dependencies: ansi-styles: 2.2.1 @@ -5352,6 +5462,8 @@ snapshots: character-entities-legacy@3.0.0: {} + check-error@2.1.3: {} + chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -5622,6 +5734,8 @@ snapshots: pify: 2.3.0 strip-dirs: 2.1.0 + deep-eql@5.0.2: {} + deep-extend@0.6.0: {} default-browser-id@3.0.0: @@ -6031,6 +6145,8 @@ snapshots: dependencies: pify: 2.3.0 + expect-type@1.3.0: {} + exsolve@1.0.8: {} ext-list@2.2.2: @@ -6784,6 +6900,8 @@ snapshots: currently-unhandled: 0.4.1 signal-exit: 3.0.7 + loupe@3.2.1: {} + lowercase-keys@1.0.0: {} lowercase-keys@1.0.1: {} @@ -7239,8 +7357,12 @@ snapshots: pathe@0.2.0: {} + pathe@1.1.2: {} + pathe@2.0.3: {} + pathval@2.0.1: {} + pend@1.2.0: {} perfect-debounce@1.0.0: {} @@ -7586,6 +7708,8 @@ snapshots: '@shikijs/vscode-textmate': 10.0.1 '@types/hast': 3.0.4 + siginfo@2.0.0: {} + signal-exit@3.0.7: {} signal-exit@4.1.0: {} @@ -7672,6 +7796,10 @@ snapshots: stable@0.1.8: {} + stackback@0.0.2: {} + + std-env@3.10.0: {} + stdin-discarder@0.1.0: dependencies: bl: 5.1.0 @@ -7829,8 +7957,18 @@ snapshots: timed-out@4.0.1: {} + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + tinyexec@1.0.2: {} + tinypool@1.1.1: {} + + tinyrainbow@1.2.0: {} + + tinyspy@3.0.2: {} + titleize@3.0.0: {} tmp@0.2.5: {} @@ -7982,13 +8120,31 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.2 + vite-node@2.1.9(@types/node@22.10.7): + dependencies: + cac: 6.7.14 + debug: 4.4.1 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.11(@types/node@22.10.7) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vite-node@3.2.4(@types/node@22.10.7): dependencies: cac: 6.7.14 debug: 4.4.1 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 5.4.19(@types/node@22.10.7) + vite: 5.4.11(@types/node@22.10.7) transitivePeerDependencies: - '@types/node' - less @@ -8038,24 +8194,6 @@ snapshots: '@types/node': 22.10.7 fsevents: 2.3.3 - vite@5.4.14(@types/node@22.10.7): - dependencies: - esbuild: 0.21.5 - postcss: 8.5.1 - rollup: 4.30.1 - optionalDependencies: - '@types/node': 22.10.7 - fsevents: 2.3.3 - - vite@5.4.19(@types/node@22.10.7): - dependencies: - esbuild: 0.21.5 - postcss: 8.5.1 - rollup: 4.30.1 - optionalDependencies: - '@types/node': 22.10.7 - fsevents: 2.3.3 - vitepress@1.6.3(@algolia/client-search@5.20.0)(@types/node@22.10.7)(async-validator@4.2.5)(postcss@8.5.1)(search-insights@2.17.3)(typescript@5.7.3): dependencies: '@docsearch/css': 3.8.2 @@ -8065,7 +8203,7 @@ snapshots: '@shikijs/transformers': 2.1.0 '@shikijs/types': 2.1.0 '@types/markdown-it': 14.1.2 - '@vitejs/plugin-vue': 5.2.1(vite@5.4.14(@types/node@22.10.7))(vue@3.5.13(typescript@5.7.3)) + '@vitejs/plugin-vue': 5.2.1(vite@5.4.11(@types/node@22.10.7))(vue@3.5.13(typescript@5.7.3)) '@vue/devtools-api': 7.7.1 '@vue/shared': 3.5.13 '@vueuse/core': 12.5.0(typescript@5.7.3) @@ -8074,7 +8212,7 @@ snapshots: mark.js: 8.11.1 minisearch: 7.1.1 shiki: 2.1.0 - vite: 5.4.14(@types/node@22.10.7) + vite: 5.4.11(@types/node@22.10.7) vue: 3.5.13(typescript@5.7.3) optionalDependencies: postcss: 8.5.1 @@ -8105,6 +8243,41 @@ snapshots: - typescript - universal-cookie + vitest@2.1.9(@types/node@22.10.7): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.11(@types/node@22.10.7)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.1 + expect-type: 1.3.0 + magic-string: 0.30.17 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.11(@types/node@22.10.7) + vite-node: 2.1.9(@types/node@22.10.7) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.10.7 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vscode-uri@3.0.8: {} vue-demi@0.14.10(vue@3.5.13(typescript@5.7.3)): @@ -8200,6 +8373,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + widest-line@5.0.0: dependencies: string-width: 7.2.0 @@ -8275,7 +8453,7 @@ snapshots: publish-browser-extension: 2.3.0 scule: 1.3.0 unimport: 3.14.6(rollup@4.30.1) - vite: 5.4.19(@types/node@22.10.7) + vite: 5.4.11(@types/node@22.10.7) vite-node: 3.2.4(@types/node@22.10.7) web-ext-run: 0.2.4 transitivePeerDependencies: diff --git a/tests/template.test.ts b/tests/template.test.ts new file mode 100644 index 0000000..cfdef36 --- /dev/null +++ b/tests/template.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +// template.ts 顶层会 import config(其内部使用了 wxt 注入的 storage 全局), +// 在纯 Node 测试环境下并不存在,因此用一个可变的 mock 对象替换该模块。 +const { mockConfig } = vi.hoisted(() => ({ + mockConfig: { + service: 'openai', + to: 'zh-Hans', + model: {} as Record, + customModel: {} as Record, + system_role: {} as Record, + user_role: {} as Record, + customBody: {} as Record, + }, +})); + +vi.mock('@/entrypoints/utils/config', () => ({ config: mockConfig })); + +import { mergeCustomBody, commonMsgTemplate } from '@/entrypoints/utils/template'; + +beforeEach(() => { + // 每个用例前重置 mock 配置,避免相互污染 + mockConfig.service = 'openai'; + mockConfig.to = 'zh-Hans'; + mockConfig.model = { openai: 'gpt-4o' }; + mockConfig.customModel = {}; + mockConfig.system_role = { openai: 'You are a translator.' }; + mockConfig.user_role = { openai: 'Translate to {{to}}: {{origin}}' }; + mockConfig.customBody = {}; +}); + +describe('mergeCustomBody(纯函数)', () => { + it('raw 为空字符串时,payload 原样返回', () => { + const payload = { model: 'x', temperature: 1 }; + expect(mergeCustomBody(payload, '')).toEqual({ model: 'x', temperature: 1 }); + }); + + it('raw 为纯空白时,payload 原样返回', () => { + const payload = { a: 1 }; + expect(mergeCustomBody(payload, ' \n\t ')).toEqual({ a: 1 }); + }); + + it('raw 为 undefined / null 时,payload 原样返回', () => { + expect(mergeCustomBody({ a: 1 }, undefined)).toEqual({ a: 1 }); + expect(mergeCustomBody({ a: 1 }, null)).toEqual({ a: 1 }); + }); + + it('合法 JSON 对象会被合并进 payload', () => { + const payload: any = { model: 'm', messages: [] }; + mergeCustomBody(payload, '{"max_tokens": 1024}'); + expect(payload.max_tokens).toBe(1024); + expect(payload.model).toBe('m'); + }); + + it('用户字段优先:覆盖同名的默认字段', () => { + const payload: any = { temperature: 1.0 }; + mergeCustomBody(payload, '{"temperature": 0.6}'); + expect(payload.temperature).toBe(0.6); + }); + + it('支持嵌套对象(如 thinking 这类控制字段)', () => { + const payload: any = { model: 'm' }; + mergeCustomBody(payload, '{"thinking": {"type": "disabled"}}'); + expect(payload.thinking).toEqual({ type: 'disabled' }); + }); + + it('非法 JSON 被安全忽略,payload 不变', () => { + const payload = { model: 'x' }; + expect(mergeCustomBody(payload, '{not valid json')).toEqual({ model: 'x' }); + }); + + it('JSON 数组被忽略(必须是对象)', () => { + const payload = { model: 'x' }; + expect(mergeCustomBody(payload, '[1,2,3]')).toEqual({ model: 'x' }); + }); + + it('JSON 基本类型被忽略(数字 / 字符串 / 布尔 / null)', () => { + expect(mergeCustomBody({ a: 1 }, '123')).toEqual({ a: 1 }); + expect(mergeCustomBody({ a: 1 }, '"hello"')).toEqual({ a: 1 }); + expect(mergeCustomBody({ a: 1 }, 'true')).toEqual({ a: 1 }); + expect(mergeCustomBody({ a: 1 }, 'null')).toEqual({ a: 1 }); + }); + + it('原地修改并返回同一个 payload 引用', () => { + const payload = { a: 1 }; + const result = mergeCustomBody(payload, '{"b": 2}'); + expect(result).toBe(payload); + }); +}); + +describe('commonMsgTemplate(集成)', () => { + it('未配置自定义请求体时,生成标准 OpenAI 请求体', () => { + const body = JSON.parse(commonMsgTemplate('hello')); + expect(body).toEqual({ + model: 'gpt-4o', + temperature: 1.0, + messages: [ + { role: 'system', content: 'You are a translator.' }, + { role: 'user', content: 'Translate to zh-Hans: hello' }, + ], + }); + }); + + it('选择“自定义模型”时使用 customModel 的值', () => { + mockConfig.model = { openai: '自定义模型' }; + mockConfig.customModel = { openai: 'gpt-4o-mini' }; + const body = JSON.parse(commonMsgTemplate('hello')); + expect(body.model).toBe('gpt-4o-mini'); + }); + + it('非法的自定义请求体被忽略,标准请求体保持完整', () => { + mockConfig.customBody = { openai: '{oops' }; + const body = JSON.parse(commonMsgTemplate('hello')); + expect(body.model).toBe('gpt-4o'); + expect(body.thinking).toBeUndefined(); + expect(body.messages).toHaveLength(2); + }); + + it('仅对当前服务生效:其他服务的自定义请求体不会被应用', () => { + // 当前服务是 openai,却给另一个服务配置了自定义请求体 + mockConfig.customBody = { gemini: '{"thinking": {"type": "disabled"}}' }; + const body = JSON.parse(commonMsgTemplate('hello')); + expect(body.thinking).toBeUndefined(); + }); +}); + +// 重点:确保 thinking 等额外字段能够正确注入请求体顶层(issue #213) +describe('自定义请求体注入 thinking 字段(issue #213)', () => { + it('关闭思考:{"thinking": {"type": "disabled"}} 注入到请求体顶层', () => { + mockConfig.customBody = { openai: '{"thinking": {"type": "disabled"}}' }; + const body = JSON.parse(commonMsgTemplate('你好世界')); + expect(body.thinking).toEqual({ type: 'disabled' }); + // 同时不破坏原有字段 + expect(body.model).toBe('gpt-4o'); + expect(body.messages[1].content).toBe('Translate to zh-Hans: 你好世界'); + }); + + it('开启思考:{"thinking": {"type": "enabled"}} 注入到请求体顶层', () => { + mockConfig.customBody = { openai: '{"thinking": {"type": "enabled"}}' }; + const body = JSON.parse(commonMsgTemplate('hi')); + expect(body.thinking).toEqual({ type: 'enabled' }); + }); + + it('可同时覆盖 temperature 并注入 thinking', () => { + mockConfig.customBody = { + openai: '{"thinking": {"type": "disabled"}, "temperature": 0.6}', + }; + const body = JSON.parse(commonMsgTemplate('hi')); + expect(body.thinking).toEqual({ type: 'disabled' }); + expect(body.temperature).toBe(0.6); + }); + + it('带格式(缩进/换行)的 JSON 也能正确解析', () => { + mockConfig.customBody = { + openai: `{ + "thinking": { "type": "disabled" } + }`, + }; + const body = JSON.parse(commonMsgTemplate('hi')); + expect(body.thinking).toEqual({ type: 'disabled' }); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..2e6a4a2 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vitest/config'; +import { resolve } from 'path'; + +// 独立的 Vitest 配置(与 wxt 构建配置互不影响) +export default defineConfig({ + resolve: { + alias: { + // 与 wxt 一致:'@' 指向项目根目录 + '@': resolve(__dirname, '.'), + }, + }, + test: { + environment: 'node', + include: ['tests/**/*.test.ts'], + }, +});