From 2676bc2470eabcae6a5db806679907515ae12f49 Mon Sep 17 00:00:00 2001 From: 1138743695 <49817310+1138743695@users.noreply.github.com> Date: Fri, 8 Aug 2025 17:21:07 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=A0=87=E8=AE=B0=E6=A8=A1=E5=BC=8F(>>?= =?UTF-8?q?)=E4=B8=8E=E8=AE=BE=E7=BD=AE=E9=A1=B9=E6=98=8E=E6=96=87?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=EF=BC=9B=E8=87=AA=E5=AE=9A=E4=B9=89=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B/Base=20URL=20=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/info.json | 151 ++++++++++++++++++++++++++++++----------------- src/analyze.ts | 98 +++++++++++++++++++++++++++++- src/index.ts | 53 ++++++++++++----- src/translate.ts | 7 ++- src/types.ts | 8 +++ 5 files changed, 245 insertions(+), 72 deletions(-) diff --git a/public/info.json b/public/info.json index 3b1276e..36c85ed 100644 --- a/public/info.json +++ b/public/info.json @@ -22,10 +22,7 @@ "identifier": "notepadId", "type": "text", "title": "墨墨云词本 ID", - "desc": "请前往 https://open.maimemo.com/#/operations/maimemo.openapi.notepad.v1.NotepadService.ListNotepads 查询已有云词本。如果不填,则默认创建一个新词本", - "textConfig": { - "type": "secure" - } + "desc": "请前往 https://open.maimemo.com/#/operations/maimemo.openapi.notepad.v1.NotepadService.ListNotepads 查询已有云词本。如果不填,则默认创建一个新词本" }, { "identifier": "canAddSentence", @@ -47,72 +44,120 @@ "defaultValue": "false", "isKeyOption": true }, + { + "identifier": "markWordsEnabled", + "type": "menu", + "title": "启用标记模式(>>)", + "desc": "在原句中用 >> 标注需加入词库的词或短语,例如:I like >>apple and >>banana.", + "menuValues": [ + { "value": "true", "title": "是" }, + { "value": "false", "title": "否" } + ], + "defaultValue": "true" + }, + { + "identifier": "wordMarkerPrefix", + "type": "text", + "title": "标记前缀", + "desc": "用于标注目标词/短语的前缀符号,默认 >>", + "textConfig": { + "type": "visible", + "placeholderText": ">>" + }, + "defaultValue": ">>" + }, + { + "identifier": "wordMarkerSuffix", + "type": "text", + "title": "标记后缀(可选)", + "desc": "若填写(如 <<),可用于包裹短语:>>New York<<", + "defaultValue": "", + "textConfig": { + "type": "visible", + "placeholderText": "<<" + } + }, + { + "identifier": "maxMarkedWordTokens", + "type": "text", + "title": "标记短语最大词数", + "desc": "限制短语的最大词数,默认 4", + "defaultValue": "4", + "textConfig": { + "type": "visible", + "placeholderText": "4" + } + }, + { + "identifier": "overrideCanAddSentenceWhenMarked", + "type": "menu", + "title": "命中标记时强制添加例句", + "desc": "当文本中存在标记词时,忽略“添加例句到生词”的开关,强制添加例句并翻译。", + "menuValues": [ + { "value": "true", "title": "是" }, + { "value": "false", "title": "否" } + ], + "defaultValue": "true" + }, + { + "identifier": "stripMarkersBeforeTranslate", + "type": "menu", + "title": "翻译前移除标记符", + "desc": "将 >> 与可选的后缀标记从句子中移除后再发送给翻译模型", + "menuValues": [ + { "value": "true", "title": "是" }, + { "value": "false", "title": "否" } + ], + "defaultValue": "true" + }, { "identifier": "openaiApiKey", "type": "text", "title": "OpenAI API 密钥", - "desc": "可前往 https://platform.openai.com/api-keys 查询已有 API 密钥。在配置了 OpenAI API 密钥后,将优先使用 OpenAI 而不是智谱" + "desc": "可前往 https://platform.openai.com/api-keys 查询已有 API 密钥。在配置了 OpenAI API 密钥后,将优先使用 OpenAI 而不是智谱", + "textConfig": { + "type": "secure" + } + }, + { + "identifier": "openaiBaseUrl", + "type": "text", + "title": "OpenAI Base URL", + "desc": "可选:自定义 OpenAI 兼容接口的 Base URL,例如企业代理或自建兼容服务,默认为 https://api.openai.com,示例:https://xxxxx.com/", + "textConfig": { + "type": "visible", + "placeholderText": "https://xxxxx.com/" + } }, { "identifier": "openaiModel", - "type": "menu", + "type": "text", "title": "OpenAI 模型", - "menuValues": [ - { - "value": "gpt-4.1-mini", - "title": "GPT-4.1 mini" - }, - { - "value": "gpt-4.1", - "title": "GPT-4.1" - } - ], - "defaultValue": "gpt-4.1-mini" + "desc": "可手动输入自定义模型名(例如 gpt-4o、gpt-4.1-mini 或兼容服务的模型标识)。未填写时将使用默认值。", + "defaultValue": "gpt-4.1-mini", + "textConfig": { + "type": "visible", + "placeholderText": "gpt-4.1-mini" + } }, { "identifier": "bigModelApiKey", "type": "text", - "title": "智谱 API 密钥" + "title": "智谱 API 密钥", + "textConfig": { + "type": "secure" + } }, { "identifier": "bigModelModel", - "type": "menu", + "type": "text", "title": "智谱语言模型", + "desc": "可手动输入智谱模型名称(例如 GLM-4-Flash、GLM-4-Air、GLM-4-Plus 等)。未填写时将使用默认值。", "defaultValue": "GLM-4-Flash", - "menuValues": [ - { - "title": "高智能旗舰[GLM-4-Plus]", - "value": "GLM-4-Plus" - }, - { - "title": "超长输入[GLM-4-Long]", - "value": "GLM-4-Long" - }, - { - "title": "极速推理[GLM-4-AirX]", - "value": "GLM-4-AirX" - }, - { - "title": "高性价比[GLM-4-Air]", - "value": "GLM-4-Air" - }, - { - "title": "免费调用[GLM-4-Flash]", - "value": "GLM-4-Flash" - }, - { - "title": "高速低价[GLM-4-FlashX]", - "value": "GLM-4-FlashX" - }, - { - "title": "Agent模型[GLM-4-AllTools]", - "value": "GLM-4-AllTools" - }, - { - "title": "旧版旗舰[GLM-4]", - "value": "GLM-4" - } - ] + "textConfig": { + "type": "visible", + "placeholderText": "GLM-4-Flash" + } } ] } diff --git a/src/analyze.ts b/src/analyze.ts index 0ffdd02..f90d1ef 100644 --- a/src/analyze.ts +++ b/src/analyze.ts @@ -1 +1,97 @@ -// TODO \ No newline at end of file +export interface MarkerParseOptions { + prefix: string; + suffix?: string; + maxTokens: number; + stripMarkers: boolean; +} + +export interface MarkerParseResult { + words: string[]; + cleanedSentence: string; +} + +function escapeRegexLiteral(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function normalizeWhitespace(value: string): string { + return value.replace(/\s+/g, " ").trim(); +} + +function trimEdgePunctuation(value: string): string { + // Remove leading/trailing common punctuation while keeping inner hyphen/apostrophe + return value.replace(/^[\s.,;:!?"'()\[\]{}<>]+|[\s.,;:!?"'()\[\]{}<>]+$/g, ""); +} + +function countTokens(value: string): number { + return value + .trim() + .split(/\s+/) + .filter(Boolean).length; +} + +export function parseMarkedInput( + text: string, + options: MarkerParseOptions +): MarkerParseResult { + const { prefix, suffix = "", maxTokens, stripMarkers } = options; + + const escapedPrefix = escapeRegexLiteral(prefix); + const escapedSuffix = escapeRegexLiteral(suffix); + + const words: string[] = []; + const seen = new Set(); + + if (suffix) { + // Phrase mode: >> ... << + const phrasePattern = new RegExp( + `${escapedPrefix}\\s*([\\s\\S]+?)\\s*${escapedSuffix}`, + "g" + ); + let match: RegExpExecArray | null; + while ((match = phrasePattern.exec(text)) !== null) { + const raw = match[1]; + const cleaned = trimEdgePunctuation(raw); + if (!cleaned) continue; + if (countTokens(cleaned) > maxTokens) continue; + const key = cleaned.toLowerCase(); + if (!seen.has(key)) { + seen.add(key); + words.push(cleaned); + } + } + } else { + // Word mode: >>word + const wordPattern = new RegExp(`${escapedPrefix}\\s*(\\S+)`, "g"); + let match: RegExpExecArray | null; + while ((match = wordPattern.exec(text)) !== null) { + const raw = match[1]; + const cleaned = trimEdgePunctuation(raw); + if (!cleaned) continue; + if (countTokens(cleaned) > maxTokens) continue; + const key = cleaned.toLowerCase(); + if (!seen.has(key)) { + seen.add(key); + words.push(cleaned); + } + } + } + + let cleanedSentence = text; + if (stripMarkers) { + if (suffix) { + const replacePattern = new RegExp( + `${escapedPrefix}\\s*([\\s\\S]+?)\\s*${escapedSuffix}`, + "g" + ); + cleanedSentence = cleanedSentence.replace(replacePattern, (_m, p1) => p1); + } else { + const removePrefixPattern = new RegExp(`${escapedPrefix}\\s*`, "g"); + cleanedSentence = cleanedSentence.replace(removePrefixPattern, ""); + } + } + + cleanedSentence = normalizeWhitespace(cleanedSentence); + + return { words, cleanedSentence }; +} diff --git a/src/index.ts b/src/index.ts index e1b7a76..341cf1d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ import { notepadIdFilePath, } from "./maimemo"; import { translateByLLM } from "./translate"; +import { parseMarkedInput } from "./analyze"; import { BobQuery, BobTranslationErrorType } from "./types"; export function supportLanguages() { @@ -19,6 +20,12 @@ export function translate(query: BobQuery) { canAddSentence: _canAddSentence, bigModelApiKey, openaiApiKey, + markWordsEnabled, + wordMarkerPrefix, + wordMarkerSuffix, + maxMarkedWordTokens, + overrideCanAddSentenceWhenMarked, + stripMarkersBeforeTranslate, } = $option; if (detectFrom !== "en") { @@ -31,26 +38,35 @@ export function translate(query: BobQuery) { return; } - const wordNum = text.trim().split(/\s+/); - const maybeSentence = wordNum.length > 2; const canAddSentence = _canAddSentence === "true"; - if (maybeSentence && !canAddSentence) { - onCompletion({ - error: { - type: BobTranslationErrorType.NotFound, - message: "未检测到单词", - }, + let words: string[] = []; + let sentence = ""; + let usedMarkerMode = false; + + const enableMarker = (markWordsEnabled ?? "true") !== "false"; + if (enableMarker) { + const parsed = parseMarkedInput(text, { + prefix: (wordMarkerPrefix || ">>").trim() || ">>", + suffix: (wordMarkerSuffix || "").trim(), + maxTokens: Math.max(1, parseInt(maxMarkedWordTokens || "4", 10) || 4), + stripMarkers: (stripMarkersBeforeTranslate ?? "true") !== "false", }); - return; + if (parsed.words.length > 0) { + words = parsed.words; + sentence = parsed.cleanedSentence; + usedMarkerMode = true; + } } - const paragraphs = text.split("\n").filter((line) => !!line.trim()); - const words = paragraphs[0] - .split(",") - .map((word) => word.trim()) - .filter((word) => !!word && word.split(/\s+/).length < 3); - const sentence = paragraphs[1]?.trim?.() || ""; + if (!usedMarkerMode) { + const paragraphs = text.split("\n").filter((line) => !!line.trim()); + words = paragraphs[0] + .split(/[,,]/) + .map((word) => word.trim()) + .filter((word) => !!word && word.split(/\s+/).length <= 4); + sentence = paragraphs[1]?.trim?.() || ""; + } let notepadId = _notepadId; if (!maimemoToken) { @@ -76,7 +92,12 @@ export function translate(query: BobQuery) { // Create sample sentence for words let finished = false; let partMessage = ""; - if (canAddSentence) { + let shouldAddSentence = canAddSentence; + if (usedMarkerMode && (overrideCanAddSentenceWhenMarked ?? "true") !== "false") { + shouldAddSentence = true; + } + + if (shouldAddSentence) { if (!sentence) { partMessage = "例句创建失败(未检测到例句)"; finished = true; diff --git a/src/translate.ts b/src/translate.ts index bd4c87c..0b4e925 100644 --- a/src/translate.ts +++ b/src/translate.ts @@ -1,7 +1,10 @@ const bigModelApiEndpoint = "https://open.bigmodel.cn/api/paas/v4/chat/completions"; -const openaiApiEndpoint = "https://api.openai.com/v1/responses"; +function getOpenAIEndpoint() { + const base = ($option.openaiBaseUrl || "https://api.openai.com").replace(/\/$/, ""); + return `${base}/v1/responses`; +} interface BigModelCompletionResponse { choices?: { @@ -52,7 +55,7 @@ async function translateByOpenAI(sentence: string) { return $http .request({ method: "POST", - url: openaiApiEndpoint, + url: getOpenAIEndpoint(), header: { Authorization: `Bearer ${$option.openaiApiKey}`, "Content-Type": "application/json", diff --git a/src/types.ts b/src/types.ts index da112fd..e9a89d2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -6,6 +6,14 @@ interface PluginOption { notepadId?: string; openaiApiKey?: string; openaiModel: string; + openaiBaseUrl?: string; + // marker-based input options + markWordsEnabled?: string; + wordMarkerPrefix?: string; + wordMarkerSuffix?: string; + maxMarkedWordTokens?: string; + overrideCanAddSentenceWhenMarked?: string; + stripMarkersBeforeTranslate?: string; } interface BobTranslationResult {