Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 98 additions & 53 deletions public/info.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
}
}
]
}
98 changes: 97 additions & 1 deletion src/analyze.ts
Original file line number Diff line number Diff line change
@@ -1 +1,97 @@
// TODO
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<string>();

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 };
}
53 changes: 37 additions & 16 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
notepadIdFilePath,
} from "./maimemo";
import { translateByLLM } from "./translate";
import { parseMarkedInput } from "./analyze";
import { BobQuery, BobTranslationErrorType } from "./types";

export function supportLanguages() {
Expand All @@ -19,6 +20,12 @@ export function translate(query: BobQuery) {
canAddSentence: _canAddSentence,
bigModelApiKey,
openaiApiKey,
markWordsEnabled,
wordMarkerPrefix,
wordMarkerSuffix,
maxMarkedWordTokens,
overrideCanAddSentenceWhenMarked,
stripMarkersBeforeTranslate,
} = $option;

if (detectFrom !== "en") {
Expand All @@ -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) {
Expand All @@ -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;
Expand Down
7 changes: 5 additions & 2 deletions src/translate.ts
Original file line number Diff line number Diff line change
@@ -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?: {
Expand Down Expand Up @@ -52,7 +55,7 @@ async function translateByOpenAI(sentence: string) {
return $http
.request<OpenAIResponse>({
method: "POST",
url: openaiApiEndpoint,
url: getOpenAIEndpoint(),
header: {
Authorization: `Bearer ${$option.openaiApiKey}`,
"Content-Type": "application/json",
Expand Down
Loading