diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e8b7499..dac50c7 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -422,13 +422,13 @@ covered by the `styles.css` manifest. filters, navigation, dismissible topbar drag guidance strip with localStorage persistence, the 3-step empty-state onboarding blueprint (`renderRecipeEmptyGuide`), and background catalog-wide model readiness resolution (`resolveCatalogRecipeReadiness`), `ui_recipe_cards.js` owns cards and card actions (including `grab` drag affordance, cover `可拖拽` badge, harmonized multi-state model readiness pill with `getRecipeReadiness` synchronizing available, missing, and pending matches with detail overview, and direct canvas drag-and-drop), `ui_recipe_dialogs.js` owns save/edit dialogs, and `ui_recipe_media.js` owns shared cover helpers. Detail sessions synchronize detected model availability back to `owner.recipeRecords` via `syncRecipeReferencesToCatalog`. - `ui_prompt_composer.js` owns the standalone Prompt Studio drawer (dock side, width, Esc); - `ui_prompt_workbench.js` fills it: the top bar (tags or text view, Chinese meanings) and + `ui_prompt_workbench.js` fills it: the top bar (tags or text view, the meanings' language) and saving a box as a card. `ui_prompt_target.js` decides what is edited, the prompt boxes of the prompt node last selected on the canvas or a positive / negative draft, follows the selection and the boxes' text, and owns the studio's Undo; `ui_prompt_box_editor.js` is one - box as tags (select, weight, edit, remove, reorder, drop, type, translate Chinese) or text. + box as tags (select, weight, edit, remove, reorder, drop, type, translate to English) or text. `prompt_tags.js` splits and joins a prompt's tags keeping their separators, and - `prompt_gloss.js` looks up and keeps the tags' Chinese meanings. `ui_prompt_source_deck.js` + `prompt_gloss.js` looks up and keeps the tags' meanings in the picked language. `ui_prompt_source_deck.js` owns the card list (three built-in cards and the saved prompts), search, new-card form, library sync and renaming / deleting a saved prompt; `prompt_material_source.js` reads saved prompts as cards and saves one; `ui_prompt_card_popover.js` owns the card preview (hover diff --git a/CHANGELOG.md b/CHANGELOG.md index eba0e25..5c3fab0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,7 +25,7 @@ - **Prompts on Current node (当前节点列出提示词框)**: Current node now lists the selected node's prompt boxes, each marked positive or negative by its wiring, and **Edit in Prompt Studio** opens them in the studio, where they are edited and translated. The separate Prompt Translator window is gone. - **No more toolbox (工具箱拆掉了)**: every tool now opens from the page it belongs to. Workflow share codes (import / export) are the ⇅ button at the top right of **Workflows** (where a disabled import button used to sit); **Prompt Studio** opens from **Prompts** on the rail (Prompt Notes became **Combos**, a page of their own); **Model Sources** is at the end of the type chips on **Models**, and Model Check has **Where to download** for the open workflow's models; translation is on the prompt boxes in **Current node**. The rail keeps scan, Model Check and Current node. - **No more Material Library page (素材页拆掉了)**: it held four unrelated things, so each went where it is used. **Prompts** on the rail opens Prompt Studio beside the canvas, where your saved prompts are (and can be renamed, deleted and dragged onto the canvas); the browser comes back when you close the studio, and the second keyboard shortcut (Ctrl+Shift+L) now opens the studio too. Saved node values are in **Current node** when you select a node of their kind, which can now delete them. Recent results are the gallery's ☆, combos have their own page, and whole workflows are recipes. The recipe page's buttons that copied parameter sets into the library are gone (Current node lists recipe sets already), and saving nodes from an image's details says where they went. Nothing is deleted: your saved prompts and values stay in the same files. -- **Prompt Studio edits the node itself (提示词工坊直接改节点)**: no more assembly board, "write to node" step or positive / negative tabs. Select a prompt node and its prompt boxes appear in the studio, positive and negative together, as tags: click one to make it stronger or weaker, double-click to edit it, drag to reorder, × to remove. Every change goes straight into the node, and the studio's **Undo** (or Ctrl+Z on the canvas) takes it back; line breaks in your prompt stay where they were. Type in a box and press Enter to add tags: Chinese is translated to English first, and tags the box has already are not added twice; a box that holds Chinese offers to translate it. **Chinese meaning** puts each tag's meaning under it, **Text** shows the whole text instead. With no prompt node selected you write a draft, which can be saved or dragged onto the canvas by its ⠿ handle; the studio keeps editing the last prompt node until you select another one or press **Use draft**. A clicked card goes into the box of its role (a negative card never lands in a positive box). The six starter cards are down to three (quality, common negative, hands and anatomy), your saved prompts are under **My prompts** with their first words showing, and the studio no longer reads every saved prompt again every 30 seconds, only new ones. +- **Prompt Studio edits the node itself (提示词工坊直接改节点)**: no more assembly board, "write to node" step or positive / negative tabs. Select a prompt node and its prompt boxes appear in the studio, positive and negative together, as tags: click one to make it stronger or weaker, double-click to edit it, drag to reorder, × to remove. Every change goes straight into the node, and the studio's **Undo** (or Ctrl+Z on the canvas) takes it back; line breaks in your prompt stay where they were. Type in a box and press Enter to add tags: Chinese, Japanese, Korean or any other language is translated to English first, and tags the box has already are not added twice; a box that holds words that are not English offers to translate them. The meaning menu in the top bar puts each tag's meaning under it in Simplified or Traditional Chinese, Japanese, Korean, Russian, Spanish, French, German or Portuguese; **Text** shows the whole text instead. With no prompt node selected you write a draft, which can be saved or dragged onto the canvas by its ⠿ handle; the studio keeps editing the last prompt node until you select another one or press **Use draft**. A clicked card goes into the box of its role (a negative card never lands in a positive box). The six starter cards are down to three (quality, common negative, hands and anatomy), your saved prompts are under **My prompts** with their first words showing, and the studio no longer reads every saved prompt again every 30 seconds, only new ones. - **Saved prompts are managed in Prompt Studio (存过的提示词在提示词工坊里管理)**: the studio's library cards for prompts you saved used to be read-only ("managed by the source material"). Their preview now has **Rename** and **Delete** (the whole saved prompt, positive and negative together, to the Recycle Bin), and any card can be dragged out of the studio onto the canvas: onto a prompt box it fills that box (outlined by role, with the text previewed before you let go, one Ctrl+Z or Undo in the message to take it back), onto empty canvas it makes a prompt node. - **Keep a result from the gallery (图库里点 ☆ 收藏)**: every image in the gallery has a ☆. It asks what to keep, and says where each goes: **The whole workflow** (a Workflow Recipe on the Workflows page, with the picture as its cover), **Its combo** (the main model, LoRAs and positive prompt, on the Combos page, ready for Use; models this computer does not have are left out and the message says how many) or **Just the prompts** (positive and negative, in your saved prompts). No name is asked: it is named after the prompt. Kept images show ★; a row already kept shows ✓ and opens it. The image workbench's **Keep** opens the same menu (its name-and-tags form is gone). The Material Library's Recent shelf, which did the same for whole workflows only, is gone: the gallery lists the same images. Deleting an image from the gallery asks in the usual dialog instead of over the picture. - **Model sources on the Models page (模型来源进了模型页)**: the Sources chip at the end of the model types now shows, in place of the cards, where each model is downloaded, for **This workflow** or **All models**, filtered by with / without a link. Every link can be typed or pasted straight into its row (any site, not only Civitai), found automatically, opened and saved with one button; a link is kept in the model's own information (the same as the editor's download link) and, for this workflow's models, in the workflow too, so it travels when the workflow is shared. The list can go on the canvas as a note or to the clipboard. Model Check's **Where to download** opens it on This workflow. The separate pop-up, its lock-then-edit links and its four ways of saving are gone. diff --git a/README.md b/README.md index 9e75788..873fbda 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Every page has an icon on the rail at the left of the window. | **🖼️ Gallery** | Your ComfyUI `output` folder. Search by prompt, model, LoRA, seed, file name or model hash; open an image to see the parameters it was made with, or drag it onto the canvas to get its workflow back. **☆** keeps a good one as a whole workflow, a combo (model, LoRAs, prompt) or just its prompts. | | **🪡 Workflows** | Workflow recipes: save a whole workflow or a part of one together with its models, cover, notes and parameters. Each card shows whether the models are on this computer; drag it onto the canvas to load it. Versions can be compared and restored. | | **🧩 Combos** | A main model, a few LoRAs and a prompt you like, kept together. **Use** puts them into the open workflow (it replaces the model loader and the LoRAs after it and fills the positive prompt, listing the changes first) or, when the canvas has no model loader, builds a new group of nodes. | -| **✍️ Prompts** | Opens Prompt Studio beside the canvas. Select a prompt node and its prompt boxes appear as tags, edited in place: click a tag to make it stronger or weaker, double-click to edit, drag to reorder, type to add (Chinese is translated to English), and show each tag's Chinese meaning. Click a card (a few common ones, plus your saved prompts) to add it, or drag it straight onto a prompt box on the canvas (outlined green for positive, red for negative) or onto empty canvas for a new prompt node. With no prompt node selected you write a draft. | +| **✍️ Prompts** | Opens Prompt Studio beside the canvas. Select a prompt node and its prompt boxes appear as tags, edited in place: click a tag to make it stronger or weaker, double-click to edit, drag to reorder, type to add (Chinese, Japanese and other languages are translated to English), and show each tag's meaning in Chinese, Japanese, Korean and other languages. Click a card (a few common ones, plus your saved prompts) to add it, or drag it straight onto a prompt box on the canvas (outlined green for positive, red for negative) or onto empty canvas for a new prompt node. With no prompt node selected you write a draft. | | **🎙️ Voices / 🎧 Audio** | With [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS) installed: import GPT-SoVITS characters, pick a reference clip per emotion, fix pronunciations, write a script under Voice-over on the same page and generate it right there; generated audio is listed in the audio gallery. Without it, the page explains how to install it and nothing else depends on it. | Tools on the rail: @@ -101,7 +101,7 @@ The first time you open it, start on **Home**: **Scan model folders** fills in c | **🖼️ 图库** | 读取 ComfyUI 的 `output` 文件夹。
可以按提示词、模型、LoRA、seed、文件名或模型哈希搜索。
点开图片能看到生成参数;拖到画布上可以还原当时的工作流。
觉得好就点 **☆**:存成整个工作流、搭配(模型 + LoRA + 提示词),或者只存提示词。 | | **🪡 工作流** | 工作流配方:把完整工作流或其中一段,连同模型、封面、备注和参数一起存下来。
卡片会显示这台电脑上模型是否齐全;拖到画布上即可载入。
可以比较、恢复历史版本。 | | **🧩 搭配** | 把常用的主模型、几个 LoRA 和一段提示词存成一个搭配。
按 **用上**:画布上有模型加载节点就替换(连同后面的 LoRA 和正向提示词,改之前先列出来),没有就新建一组节点。 | -| **✍️ 提示词** | 在画布旁边打开提示词工坊。选中一个提示词节点,它的提示词框就以词块的样子出现在工坊里,直接改:点一下调权重,双击改字,拖动换位置,输入就加(中文自动译成英文),还能在每个词下面显示中文意思。
点词卡(几张常用的,加上你存的)加进去,或者直接拖到画布的提示词框里(正面绿框、负面红框),拖到空白处就新建一个提示词节点。没选中提示词节点时写的是草稿。 | +| **✍️ 提示词** | 在画布旁边打开提示词工坊。选中一个提示词节点,它的提示词框就以词块的样子出现在工坊里,直接改:点一下调权重,双击改字,拖动换位置,输入就加(中文、日文等会自动译成英文),还能在每个词下面显示它的中文、日文、韩文等语言的意思。
点词卡(几张常用的,加上你存的)加进去,或者直接拖到画布的提示词框里(正面绿框、负面红框),拖到空白处就新建一个提示词节点。没选中提示词节点时写的是草稿。 | | **🎙️ 角色语音 / 🎧 音频库** | 装了 [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS) 后可用:
导入 GPT-SoVITS 角色,给每种情绪选参考音频,修正读音;切到“配音”写台词并直接生成。
生成的音频都在音频库里。没装时,页面会说明怎么安装,其他功能不受影响。 | 图标栏上的工具: diff --git a/api/translation_routes.py b/api/translation_routes.py index c6bf49b..33e097f 100644 --- a/api/translation_routes.py +++ b/api/translation_routes.py @@ -9,9 +9,17 @@ import urllib.request from aiohttp import web +_DEEPL_TARGETS = { + "zh": "ZH", "zh-CN": "ZH", "zh-TW": "ZH-HANT", "en": "EN", "ja": "JA", "ko": "KO", + "fr": "FR", "de": "DE", "es": "ES", "ru": "RU", "pt": "PT-BR", +} + + def _translate_with_deepl(text, tl, deepl_key): - deepl_map = { "zh-CN": "ZH", "en": "EN", "ja": "JA", "ko": "KO", "fr": "FR", "de": "DE", "es": "ES", "ru": "RU" } - d_tl = deepl_map.get(tl, "EN") + """None for a target DeepL does not take: the other providers are asked instead.""" + d_tl = _DEEPL_TARGETS.get(tl) + if not d_tl: + return None url = "https://api-free.deepl.com/v2/translate" if ":fx" in deepl_key else "https://api.deepl.com/v2/translate" payload = urllib.parse.urlencode({ "auth_key": deepl_key, @@ -50,8 +58,9 @@ def _translate_with_google(text, tl): def _translate_with_mymemory(text, tl): has_cn = any('\u4e00' <= char <= '\u9fa5' for char in text) - sl = 'zh-CN' if has_cn else 'en' - pair_tl = 'en' if has_cn else ('zh-CN' if tl in ('zh', 'zh-CN') else tl) + # Text that is not plain English and not Chinese (kana, hangul, accents...) is left to its detection. + sl = 'zh-CN' if has_cn else ('en' if text.isascii() else 'Autodetect') + pair_tl = 'zh-CN' if tl in ('zh', 'zh-CN') else tl url = f"https://api.mymemory.translated.net/get?q={urllib.parse.quote(text)}&langpair={sl}|{pair_tl}" req = urllib.request.Request(url, headers={ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' diff --git a/docs/architecture/frontend.md b/docs/architecture/frontend.md index 68b91c5..6b62f5e 100644 --- a/docs/architecture/frontend.md +++ b/docs/architecture/frontend.md @@ -254,12 +254,17 @@ view has focus. `prompt_tags.js` splits a box on top-level commas and line breaks and keeps each separator, so editing one tag leaves the rest of the text, line breaks included, as it was; a weight is `(tag:w)`. Inserting skips tags the box has already (same words, any -weight). Typed or edited text with Chinese is translated to English before it is -written; a box that holds Chinese offers to translate those tags, and the result is -written only if the box still holds the text the request was made from. Chinese -meanings (`prompt_gloss.js`, Chinese interface only, off until switched on, remembered -in local storage with the view) are asked for in batches and kept for the session; a -failed lookup leaves that box's meanings blank instead of retrying. +weight). Typed or edited text with letters that are not plain English (`needsEnglish`: +Chinese, kana, hangul, Cyrillic, accented Latin) is translated to English before it is +written; a box that holds such tags offers to translate them, a translation the box +has already removes the tag instead, and the result is written only if the box still +holds the text the request was made from. Meanings (`prompt_gloss.js`) are shown in the +language picked in the top bar (one of `GLOSS_LANGUAGES`, none by default, remembered in +local storage with the view; an older "Chinese on" setting reads as Simplified Chinese). +They are asked for in batches, kept for the session per language, and a failed lookup +leaves that box's meanings in that language blank instead of retrying. The backend +skips DeepL for a target it does not take and lets MyMemory detect a source that is +neither English nor Chinese. The cards are three built-in ones and the saved prompts (prompt-kind material files, `prompt_material_source.js`). The saved list is asked for on open, every 30 seconds @@ -284,7 +289,7 @@ drawer the drag passes through to the boxes. | `ui_prompt_card_popover.js` | Card preview: hover corridor, pin, copy/add/rename/delete actions | | `prompt_card_drag.js` | A card or box dragged onto the canvas: box fill or new prompt node, passing through the drawer | | `prompt_tags.js` | Pure tag split/join, weights, insert/remove/replace/move keeping separators | -| `prompt_gloss.js` | Session cache and batched lookup of tags' Chinese meanings | +| `prompt_gloss.js` | Meaning languages, session cache and batched lookup of tags' meanings | | `prompt_material_source.js` | Saved prompts as cards (paginated list, text read once) and saving a card | | `prompt_composition.js` | Composing a saved plan's text, prompt titles and categories; no DOM | | `ui_lifecycle.js` | View-scoped listeners, AbortSignal, cleanup callbacks and resizing | diff --git a/web/modules/browser_entry.js b/web/modules/browser_entry.js index 9a61014..31885f9 100644 --- a/web/modules/browser_entry.js +++ b/web/modules/browser_entry.js @@ -251,7 +251,7 @@ export function createBrowserEntry({ translate, getCurrentLanguage }) { async function setup() { removeLegacyTriggerPosition(); - const cssUrl = '/extensions/Anomalous_Model_Browser/styles.css?v=20261004-studio-1'; + const cssUrl = '/extensions/Anomalous_Model_Browser/styles.css?v=20261004-langs-1'; if (!document.querySelector('link[href^="/extensions/Anomalous_Model_Browser/styles.css"]')) { const link = document.createElement('link'); link.rel = 'stylesheet'; diff --git a/web/modules/locales.js b/web/modules/locales.js index 074e8e2..a3cc6ad 100644 --- a/web/modules/locales.js +++ b/web/modules/locales.js @@ -1045,8 +1045,9 @@ export const i18n = { promptStudioViewTags: "词块", promptStudioViewText: "整段", promptStudioViewHint: "按词块看,还是看整段文字", - promptStudioGloss: "中文意思", - promptStudioGlossHint: "在每个词下面显示中文意思(通过翻译服务查询)", + promptStudioGlossOff: "不显示意思", + promptStudioGlossIn: "意思:{language}", + promptStudioGlossHint: "在每个词下面显示它的意思(通过翻译服务查询)", promptStudioEditing: "正在改:{node}", promptStudioDraft: "草稿", promptStudioDraftHint: "在画布上选中一个提示词节点,这里就直接改它。草稿可以存下,或按住 ⠿ 拖到画布上。", @@ -1059,11 +1060,11 @@ export const i18n = { promptStudioWrongBox: "{card}词卡不能放进{box}提示词框", promptStudioNoBoxFor: "这个节点没有能放{card}提示词的框", promptStudioAddPlaceholder: "输入提示词,回车加入", - promptStudioAddHint: "可以一次输入好几个,用逗号隔开;中文会自动译成英文", + promptStudioAddHint: "可以一次输入好几个,用逗号隔开;中文、日文等不是英文的会自动译成英文", promptStudioTranslating: "翻译中…", promptStudioTranslateFailed: "没能翻译,请稍后再试", - promptStudioHasChinese: "这里有中文,多数模型只认英文。", - promptStudioToEnglish: "把中文译成英文", + promptStudioHasForeign: "这里有不是英文的词,多数模型只认英文。", + promptStudioToEnglish: "译成英文", promptStudioCopy: "复制", promptStudioCopied: "已复制", promptStudioSave: "存下", @@ -2955,8 +2956,9 @@ export const i18n = { promptStudioViewTags: "Tags", promptStudioViewText: "Text", promptStudioViewHint: "Show the prompt as tags or as the whole text", - promptStudioGloss: "Chinese meaning", - promptStudioGlossHint: "Show each tag's Chinese meaning (looked up with the translation service)", + promptStudioGlossOff: "No meanings", + promptStudioGlossIn: "Meaning in {language}", + promptStudioGlossHint: "Show each tag's meaning under it (looked up with the translation service)", promptStudioEditing: "Editing {node}", promptStudioDraft: "Draft", promptStudioDraftHint: "Select a prompt node on the canvas to edit it here. A draft can be saved, or dragged onto the canvas by its ⠿ handle.", @@ -2969,11 +2971,11 @@ export const i18n = { promptStudioWrongBox: "A {card} card can't go into a {box} prompt box", promptStudioNoBoxFor: "This node has no box for a {card} prompt", promptStudioAddPlaceholder: "Type a prompt and press Enter", - promptStudioAddHint: "Several at once, separated by commas; Chinese is translated to English", + promptStudioAddHint: "Several at once, separated by commas; text in other languages is translated to English", promptStudioTranslating: "Translating…", promptStudioTranslateFailed: "Could not translate; try again later", - promptStudioHasChinese: "There is Chinese here; most models only read English.", - promptStudioToEnglish: "Translate the Chinese to English", + promptStudioHasForeign: "Some words here are not English; most models only read English.", + promptStudioToEnglish: "Translate to English", promptStudioCopy: "Copy", promptStudioCopied: "Copied", promptStudioSave: "Save", diff --git a/web/modules/prompt_gloss.js b/web/modules/prompt_gloss.js index da756f0..e2e81a2 100644 --- a/web/modules/prompt_gloss.js +++ b/web/modules/prompt_gloss.js @@ -1,42 +1,50 @@ /** - * The Chinese meaning under each tag in Prompt Studio. Tags without one yet are sent to the - * translation service (translation_service.js) a line each, in batches of a few hundred - * characters; a batch whose answer does not come back line for line asks tag by tag. Meanings - * are kept for the session. Chinese tags have none. No DOM here. + * The meaning under each tag in Prompt Studio, in a language the user picks. Tags without one + * yet are sent to the translation service (translation_service.js) a line each, in batches of + * a few hundred characters; a batch whose answer does not come back line for line asks tag by + * tag. Meanings are kept for the session, per language. Tags that are not English have none. + * No DOM here. */ -import { hasChinese, translatePromptText } from './translation_service.js'; +import { needsEnglish, translatePromptText } from './translation_service.js'; import { tagWeight } from './prompt_tags.js'; +/** Languages a meaning can be shown in: the translation code and the language's own name. */ +export const GLOSS_LANGUAGES = [ + ['zh-CN', '简体中文'], ['zh-TW', '繁體中文'], ['ja', '日本語'], ['ko', '한국어'], ['ru', 'Русский'], + ['es', 'Español'], ['fr', 'Français'], ['de', 'Deutsch'], ['pt', 'Português'], +]; + const BATCH_CHARS = 800; const MAX_KEPT = 3000; -const meanings = new Map(); // tag words, lower case -> meaning +const meanings = new Map(); // `${lang}:${tag words, lower case}` -> meaning const coreOf = tag => tagWeight(tag).core.trim(); +const keyOf = (core, lang) => `${lang}:${core.toLowerCase()}`; -/** The meaning of `tag`: a string, '' when it needs none, undefined when not known yet. */ -export function glossOf(tag) { +/** The meaning of `tag` in `lang`: a string, '' when it needs none, undefined when not known yet. */ +export function glossOf(tag, lang) { const core = coreOf(tag); - if (!core || hasChinese(core)) return ''; - return meanings.get(core.toLowerCase()); + if (!core || needsEnglish(core)) return ''; + return meanings.get(keyOf(core, lang)); } -function keep(core, meaning) { +function keep(core, lang, meaning) { if (meanings.size >= MAX_KEPT) meanings.delete(meanings.keys().next().value); - meanings.set(core.toLowerCase(), meaning.trim()); + meanings.set(keyOf(core, lang), meaning.trim()); } -async function askOneByOne(cores, signal) { +async function askOneByOne(cores, lang, signal) { for (const core of cores) { - const result = await translatePromptText(core, { targetLang: 'zh-CN', signal }); + const result = await translatePromptText(core, { targetLang: lang, signal }); if (!result.ok) return false; - keep(core, result.translated); + keep(core, lang, result.translated); } return true; } -/** Looks up the tags with no meaning yet; false when the service could not be reached. */ -export async function fetchGlosses(tags, signal) { - const wanted = [...new Set(tags.map(coreOf))].filter(core => glossOf(core) === undefined); +/** Looks up the tags with no meaning in `lang` yet; false when the service could not be reached. */ +export async function fetchGlosses(tags, lang, signal) { + const wanted = [...new Set(tags.map(coreOf))].filter(core => glossOf(core, lang) === undefined); let batch = []; let size = 0; const flush = async () => { @@ -44,11 +52,11 @@ export async function fetchGlosses(tags, signal) { const cores = batch; batch = []; size = 0; - const result = await translatePromptText(cores.join('\n'), { targetLang: 'zh-CN', signal }); + const result = await translatePromptText(cores.join('\n'), { targetLang: lang, signal }); if (!result.ok) return false; const lines = result.translated.split(/\r?\n/); - if (lines.length !== cores.length) return askOneByOne(cores, signal); - cores.forEach((core, index) => keep(core, lines[index])); + if (lines.length !== cores.length) return askOneByOne(cores, lang, signal); + cores.forEach((core, index) => keep(core, lang, lines[index])); return true; }; for (const core of wanted) { @@ -57,4 +65,4 @@ export async function fetchGlosses(tags, signal) { size += core.length + 1; } return flush(); -} +} \ No newline at end of file diff --git a/web/modules/translation_service.js b/web/modules/translation_service.js index 18cd5da..eaebc6b 100644 --- a/web/modules/translation_service.js +++ b/web/modules/translation_service.js @@ -1,53 +1,21 @@ /** - * translation_service.js - * Atomic prompt translation service for Anomalous Model Browser. - * Handles DeepL / Google Translate backend bridge, smart language detection, - * in-memory caching, and prompt tag splitting. + * Prompt translation: the backend /anomalous/translate bridge (DeepL when a key is set, then + * Google, then MyMemory) with an in-memory cache, and which text needs translating to English + * before it goes into a prompt. */ const translationCache = new Map(); +// A letter that is not plain English: Chinese, kana, hangul, Cyrillic, accented Latin… +const NON_ENGLISH_LETTER = /(?![A-Za-z])\p{L}/u; -/** - * Checks if the given text contains any Chinese characters. - * @param {string} text - * @returns {boolean} - */ -export function hasChinese(text) { - if (!text || typeof text !== 'string') return false; - return /[\u4e00-\u9fa5]/.test(text); -} - -/** - * Splits a prompt string into trimmed comma-separated tags or phrases. - * Supports English/Chinese commas, enumeration marks (顿号 、), semicolons, pipes, and newlines. - * @param {string} text - * @returns {string[]} - */ -export function splitPromptTags(text) { - if (!text || typeof text !== 'string') return []; - return text - .split(/[,,、;;||\n\r]+/) - .map(t => t.trim()) - .filter(Boolean); -} - -/** - * Normalizes punctuation and formatting of a prompt text into standard comma-separated tags. - * Converts Chinese commas, enumeration marks (顿号), semicolons, pipes, and newlines into clean `, `. - * @param {string} text - * @param {string} [delimiter=', '] - * @returns {string} - */ -export function normalizePromptFormatting(text, delimiter = ', ') { - const tags = splitPromptTags(text); - return tags.join(delimiter); +/** Whether `text` has letters other than English ones, so it goes to English before a prompt. */ +export function needsEnglish(text) { + return typeof text === 'string' && NON_ENGLISH_LETTER.test(text); } /** * Translates prompt text using the backend /anomalous/translate endpoint. - * Automatically selects target language if omitted: - * - If text contains Chinese -> translates to English ('en') - * - If text is English/Latin -> translates to Simplified Chinese ('zh-CN') + * Without a target, text that needs English goes to English and other text to Chinese. * * @param {string} text - Raw prompt text to translate * @param {Object} [options] @@ -63,7 +31,7 @@ export async function translatePromptText(text, options = {}) { return { ok: true, translated: '', targetLang: options.targetLang || 'en' }; } - const targetLang = options.targetLang || (hasChinese(raw) ? 'en' : 'zh-CN'); + const targetLang = options.targetLang || (needsEnglish(raw) ? 'en' : 'zh-CN'); const cacheKey = `${targetLang}:::${raw}`; if (!options.bypassCache && translationCache.has(cacheKey)) { @@ -114,11 +82,4 @@ export async function translatePromptText(text, options = {}) { error: err.message || 'Translation request failed', }; } -} - -/** - * Clears the in-memory translation cache. - */ -export function clearTranslationCache() { - translationCache.clear(); -} +} \ No newline at end of file diff --git a/web/modules/ui_prompt_box_editor.js b/web/modules/ui_prompt_box_editor.js index 388b172..fa4d11b 100644 --- a/web/modules/ui_prompt_box_editor.js +++ b/web/modules/ui_prompt_box_editor.js @@ -1,10 +1,11 @@ /** * One prompt box in Prompt Studio (ui_prompt_target.js makes one per box of the selected node, - * or two for the draft). Its text shows as tags (prompt_tags.js) or as the whole text; with the - * meanings on, each tag has its Chinese meaning under it (prompt_gloss.js). A tag is clicked to + * or two for the draft). Its text shows as tags (prompt_tags.js) or as the whole text; with a + * meaning language picked, each tag has its meaning under it (prompt_gloss.js). A tag is clicked to * select it (weight − / +, edit, remove; Delete and + / − on the keyboard), double-clicked to * edit, dragged to move. Cards dropped on the box go in where they land, typed text at the end; - * Chinese is translated to English first, and tags the box has already are not added twice. + * text that is not English is translated to English first, and tags the box has already are + * not added twice. * Every change goes through `write`, which the target turns into a node write or a draft edit. */ @@ -12,7 +13,7 @@ import { translate as t } from './locales.js'; import { bindPromptCardDrag } from './prompt_card_drag.js'; import { fetchGlosses, glossOf } from './prompt_gloss.js'; import { insertTags, moveTag, promptTagsOf, removeTag, replaceTag, splitPrompt, tagWeight, withWeight } from './prompt_tags.js'; -import { hasChinese, translatePromptText } from './translation_service.js'; +import { needsEnglish, translatePromptText } from './translation_service.js'; import { showWorkbenchToast } from './ui_prompt_toast.js'; const TAG_TYPE = 'application/x-anomalous-prompt-tag'; @@ -41,7 +42,7 @@ const asTag = text => (/^[A-Z][a-z]/.test(text) ? text[0].toLowerCase() + text.s /** * `role` and `name` label the box; `getValue()` reads its text and `write(value)` changes it - * (false when it did not). `prefs` is the studio's { view: 'tags' | 'text', gloss }. `refuse(card)` + * (false when it did not). `prefs` is the studio's { view: 'tags' | 'text', glossLang }. `refuse(card)` * says why a card may not go in ('' when it may); `onSave(value)` keeps the text as a card. * `drawer` is the studio drawer, for the handle that drags the text onto the canvas. * Returns { element, role, render(), refresh(), addText(text) }. @@ -53,7 +54,7 @@ export function createPromptBoxEditor({ role, name, getValue, write, prefs, refu let editing = false; let busy = false; let glossTimer = null; - let glossFailed = false; + let glossFailed = ''; // the meaning language the service failed for let textTimer = null; scope.onDispose(() => { clearTimeout(glossTimer); clearTimeout(textTimer); }); @@ -82,9 +83,9 @@ export function createPromptBoxEditor({ role, name, getValue, write, prefs, refu actions.append(copy, save, handle); head.append(actions); - const chineseBar = el('div', 'anomalous-ps-chinese'); - chineseBar.append(el('span', '', t('promptStudioHasChinese')), - button('anomalous-ps-mini is-accent', t('promptStudioToEnglish'), '', () => void translateChinese())); + const foreignBar = el('div', 'anomalous-ps-foreign'); + foreignBar.append(el('span', '', t('promptStudioHasForeign')), + button('anomalous-ps-mini is-accent', t('promptStudioToEnglish'), '', () => void translateForeign())); const tagsEl = el('div', 'anomalous-ps-tags'); const addInput = el('input', 'anomalous-ps-add'); @@ -104,7 +105,7 @@ export function createPromptBoxEditor({ role, name, getValue, write, prefs, refu textTimer = setTimeout(flushText, 500); }; textArea.onblur = () => flushText(); - element.append(head, chineseBar, tagsEl, tools, textArea); + element.append(head, foreignBar, tagsEl, tools, textArea); const toast = message => showWorkbenchToast(message); @@ -133,7 +134,7 @@ export function createPromptBoxEditor({ role, name, getValue, write, prefs, refu } async function toEnglish(text) { - if (!hasChinese(text)) return text; + if (!needsEnglish(text)) return text; setBusy(true); const result = await translatePromptText(text, { targetLang: 'en', signal: scope.signal }); setBusy(false); @@ -159,11 +160,11 @@ export function createPromptBoxEditor({ role, name, getValue, write, prefs, refu return true; } - async function translateChinese() { + async function translateForeign() { if (busy) return; const now = getValue(); const targets = splitPrompt(now).items.map((item, index) => ({ index, ...tagWeight(item.text) })) - .filter(item => hasChinese(item.core)); + .filter(item => needsEnglish(item.core)); if (!targets.length) return; setBusy(true); const results = await Promise.all(targets.map(item => translatePromptText(item.core, { targetLang: 'en', signal: scope.signal }))); @@ -171,10 +172,19 @@ export function createPromptBoxEditor({ role, name, getValue, write, prefs, refu if (scope.signal.aborted || !element.isConnected) return; if (getValue() !== now) { toast(t('currentNodeChangedMeanwhile')); render(); return; } let next = now; + // A translation the box has already (by its words) goes instead of in twice. + const wordsOf = tag => tagWeight(tag).core.trim().toLowerCase(); + const have = new Set(splitPrompt(now).items.map(item => wordsOf(item.text))); // From the last tag back, so a translation that splits into several tags moves nothing before it. for (let position = targets.length - 1; position >= 0; position--) { const result = results[position]; - if (result.ok && result.translated) next = replaceTag(next, targets[position].index, withWeight(asTag(result.translated), targets[position].weight)); + if (!result.ok || !result.translated) continue; + const english = withWeight(asTag(result.translated), targets[position].weight); + if (have.has(wordsOf(english))) next = removeTag(next, targets[position].index); + else { + have.add(wordsOf(english)); + next = replaceTag(next, targets[position].index, english); + } } if (results.some(result => !result.ok)) toast(t('promptStudioTranslateFailed')); if (next !== now) commit(next); @@ -225,8 +235,8 @@ export function createPromptBoxEditor({ role, name, getValue, write, prefs, refu } function glossText(tag) { - const meaning = glossOf(tag); - return meaning === undefined ? (glossFailed ? '' : '…') : meaning; + const meaning = glossOf(tag, prefs.glossLang); + return meaning === undefined ? (glossFailed === prefs.glossLang ? '' : '…') : meaning; } function showGlosses() { @@ -235,11 +245,12 @@ export function createPromptBoxEditor({ role, name, getValue, write, prefs, refu function wantGlosses(tags) { clearTimeout(glossTimer); - if (!prefs.gloss || glossFailed || tags.every(tag => glossOf(tag) !== undefined)) return; + const lang = prefs.glossLang; + if (!lang || glossFailed === lang || tags.every(tag => glossOf(tag, lang) !== undefined)) return; glossTimer = setTimeout(async () => { - const ok = await fetchGlosses(tags, scope.signal); + const ok = await fetchGlosses(tags, lang, scope.signal); if (scope.signal.aborted || !element.isConnected) return; - if (!ok) glossFailed = true; + if (!ok) glossFailed = lang; showGlosses(); }, 250); } @@ -248,7 +259,7 @@ export function createPromptBoxEditor({ role, name, getValue, write, prefs, refu const { core, weight } = tagWeight(item.text); const chip = el('span', 'anomalous-ps-tag'); chip.classList.toggle('is-selected', index === selected); - chip.classList.toggle('is-chinese', hasChinese(core)); + chip.classList.toggle('is-foreign', needsEnglish(core)); chip.classList.toggle('is-up', weight > 1); chip.classList.toggle('is-down', weight < 1); chip.draggable = true; @@ -263,7 +274,7 @@ export function createPromptBoxEditor({ role, name, getValue, write, prefs, refu remove(index); })); chip.append(main); - if (prefs.gloss) { + if (prefs.glossLang) { const gloss = el('span', 'anomalous-ps-tag-gloss', glossText(item.text)); gloss.dataset.tag = item.text; chip.append(gloss); @@ -319,7 +330,7 @@ export function createPromptBoxEditor({ role, name, getValue, write, prefs, refu lastValue = value; card.content = value; card.title = name || t(ROLE_KEYS[role] || 'currentNodePromptPlain'); - chineseBar.hidden = !hasChinese(value); + foreignBar.hidden = !needsEnglish(value); const asText = prefs.view === 'text'; element.classList.toggle('is-text-view', asText); tagsEl.hidden = asText; diff --git a/web/modules/ui_prompt_target.js b/web/modules/ui_prompt_target.js index 8251ad7..43ea89e 100644 --- a/web/modules/ui_prompt_target.js +++ b/web/modules/ui_prompt_target.js @@ -47,7 +47,7 @@ function refusal(card, boxRole) { } /** - * Fills `parent`. `owner` keeps the draft; `prefs` is the studio's { view, gloss }; + * Fills `parent`. `owner` keeps the draft; `prefs` is the studio's { view, glossLang }; * `onSave(text, role)` keeps a box's text as a card. Returns { addCard(card), render() }. */ export function createPromptTarget(parent, { owner, drawer, scope, prefs, onSave }) { diff --git a/web/modules/ui_prompt_workbench.js b/web/modules/ui_prompt_workbench.js index af563ed..1e40c7d 100644 --- a/web/modules/ui_prompt_workbench.js +++ b/web/modules/ui_prompt_workbench.js @@ -1,13 +1,14 @@ /** * Prompt Studio's content (the drawer around it is ui_prompt_composer.js): a top bar with the - * view switch (tags or the whole text), the Chinese meanings switch (Chinese interface only), + * view switch (tags or the whole text), the language tags' meanings are shown in (or none), * the dock side and Close; the cards on the drawer's outer side (ui_prompt_source_deck.js) and * the prompt boxes beside the canvas (ui_prompt_target.js). A clicked card goes into the box of - * its role; Save on a box keeps its text as a card under My prompts. The two switches are - * remembered in this browser. + * its role; Save on a box keeps its text as a card under My prompts. The view and the meaning + * language are remembered in this browser. */ -import { resolveLocale, translate as t } from './locales.js'; +import { translate as t } from './locales.js'; +import { GLOSS_LANGUAGES } from './prompt_gloss.js'; import { promptTitle } from './prompt_composition.js'; import { savePromptCard } from './prompt_material_source.js'; import { anomalousPrompt } from './ui_dialog.js'; @@ -37,9 +38,11 @@ function button(className, label, title, onClick) { function loadPrefs() { try { const saved = JSON.parse(localStorage.getItem(PREFS_KEY) || '{}'); - return { view: saved.view === 'text' ? 'text' : 'tags', gloss: saved.gloss === true }; + // An older switch kept only "Chinese on". + const lang = saved.glossLang ?? (saved.gloss === true ? 'zh-CN' : ''); + return { view: saved.view === 'text' ? 'text' : 'tags', glossLang: GLOSS_LANGUAGES.some(([code]) => code === lang) ? lang : '' }; } catch { - return { view: 'tags', gloss: false }; + return { view: 'tags', glossLang: '' }; } } @@ -50,8 +53,6 @@ function savePrefs(prefs) { /** Fills `drawer`. `onToggleDockSide()` moves the drawer and returns whether it is now on the left. */ export function createPromptWorkbench(owner, drawer, scope, { onClose, onToggleDockSide }) { const prefs = loadPrefs(); - const chineseUi = resolveLocale() === 'zh'; - if (!chineseUi) prefs.gloss = false; const view = el('section', 'anomalous-ps'); const top = el('header', 'anomalous-ps-top'); @@ -74,13 +75,18 @@ export function createPromptWorkbench(owner, drawer, scope, { onClose, onToggleD viewSwitch.append(...viewButtons); controls.append(viewSwitch); - const glossButton = button('anomalous-ps-toggle', t('promptStudioGloss'), t('promptStudioGlossHint'), () => { - prefs.gloss = !prefs.gloss; + const glossPicker = el('select', 'anomalous-ps-gloss'); + glossPicker.title = t('promptStudioGlossHint'); + glossPicker.setAttribute('aria-label', t('promptStudioGlossHint')); + glossPicker.append(new Option(t('promptStudioGlossOff'), ''), + ...GLOSS_LANGUAGES.map(([code, name]) => new Option(t('promptStudioGlossIn', { language: name }), code))); + glossPicker.onchange = () => { + prefs.glossLang = glossPicker.value; savePrefs(prefs); drawSwitches(); target.render(); - }); - if (chineseUi) controls.append(glossButton); + }; + controls.append(glossPicker); const dock = button('anomalous-ps-icon', '', '', () => { onToggleDockSide(); @@ -97,8 +103,8 @@ export function createPromptWorkbench(owner, drawer, scope, { onClose, onToggleD function drawSwitches() { for (const item of viewButtons) item.classList.toggle('is-active', item.dataset.mode === prefs.view); - glossButton.classList.toggle('is-active', prefs.gloss); - glossButton.setAttribute('aria-pressed', String(prefs.gloss)); + glossPicker.value = prefs.glossLang; + glossPicker.classList.toggle('is-active', Boolean(prefs.glossLang)); } drawSwitches(); diff --git a/web/styles.css b/web/styles.css index 7ec1561..87dd538 100644 --- a/web/styles.css +++ b/web/styles.css @@ -1,22 +1,22 @@ -@import url("./styles/00-foundation-models.css?v=20261003-settings-1"); -@import url("./styles/01-shell-tools.css?v=20261003-settings-1"); -@import url("./styles/02-notebooks.css?v=20261003-combos-1"); -@import url("./styles/03-gallery-recipes.css?v=20261003-prompts-1"); -@import url("./styles/04-dialogs-materials.css?v=20261004-studio-1"); -@import url("./styles/05-recipe-studio-overrides.css?v=20261002-theme-1"); -@import url("./styles/06-material-workbench.css?v=20261004-studio-1"); -@import url("./styles/07-prompt-studio.css?v=20261004-studio-1"); -@import url("./styles/08-theme-gallery-overrides.css?v=20261004-studio-1"); -@import url("./styles/09-prompt-drawers.css?v=20261004-studio-1"); -@import url("./styles/10-model-sources.css?v=20261002-sources-1"); -@import url("./styles/11-audio-studio.css?v=20261002-voiceover-2"); -@import url("./styles/12-tts-setup.css?v=20261002-theme-1"); -@import url("./styles/13-script-director.css?v=20261002-voiceover-2"); -@import url("./styles/14-version-manager.css?v=20261002-theme-1"); -@import url("./styles/15-shell-rail.css?v=20261003-feedback-2"); -@import url("./styles/16-activity.css?v=20261002-theme-1"); -@import url("./styles/17-model-types.css?v=20261002-theme-1"); -@import url("./styles/18-scan-page.css?v=20261003-folders-1"); -@import url("./styles/19-model-doctor.css?v=20261003-levels-2"); -@import url("./styles/20-current-node.css?v=20261004-studio-1"); -@import url("./styles/21-prompt-studio.css?v=20261004-studio-1"); +@import url("./styles/00-foundation-models.css?v=20261003-settings-1"); +@import url("./styles/01-shell-tools.css?v=20261003-settings-1"); +@import url("./styles/02-notebooks.css?v=20261003-combos-1"); +@import url("./styles/03-gallery-recipes.css?v=20261003-prompts-1"); +@import url("./styles/04-dialogs-materials.css?v=20261004-studio-1"); +@import url("./styles/05-recipe-studio-overrides.css?v=20261002-theme-1"); +@import url("./styles/06-material-workbench.css?v=20261004-studio-1"); +@import url("./styles/07-prompt-studio.css?v=20261004-studio-1"); +@import url("./styles/08-theme-gallery-overrides.css?v=20261004-studio-1"); +@import url("./styles/09-prompt-drawers.css?v=20261004-studio-1"); +@import url("./styles/10-model-sources.css?v=20261002-sources-1"); +@import url("./styles/11-audio-studio.css?v=20261002-voiceover-2"); +@import url("./styles/12-tts-setup.css?v=20261002-theme-1"); +@import url("./styles/13-script-director.css?v=20261002-voiceover-2"); +@import url("./styles/14-version-manager.css?v=20261002-theme-1"); +@import url("./styles/15-shell-rail.css?v=20261003-feedback-2"); +@import url("./styles/16-activity.css?v=20261002-theme-1"); +@import url("./styles/17-model-types.css?v=20261002-theme-1"); +@import url("./styles/18-scan-page.css?v=20261003-folders-1"); +@import url("./styles/19-model-doctor.css?v=20261003-levels-2"); +@import url("./styles/20-current-node.css?v=20261004-studio-1"); +@import url("./styles/21-prompt-studio.css?v=20261004-langs-1"); diff --git a/web/styles/21-prompt-studio.css b/web/styles/21-prompt-studio.css index 84e669b..90e7f38 100644 --- a/web/styles/21-prompt-studio.css +++ b/web/styles/21-prompt-studio.css @@ -45,7 +45,7 @@ } .anomalous-ps-switch-btn, -.anomalous-ps-toggle, +.anomalous-ps-gloss, .anomalous-ps-icon, .anomalous-ps-mini { font-family: inherit; @@ -66,7 +66,7 @@ color: var(--amb-text-main); } -.anomalous-ps-toggle, +.anomalous-ps-gloss, .anomalous-ps-mini { border: 1px solid var(--amb-border); background: transparent; @@ -74,19 +74,25 @@ white-space: nowrap; } -.anomalous-ps-toggle { - padding: 4px 10px; +.anomalous-ps-gloss { + max-width: 150px; + padding: 4px 6px; border-radius: 8px; font-size: 12px; } +.anomalous-ps-gloss option { + background: var(--amb-bg-panel); + color: var(--amb-text-main); +} + .anomalous-ps-mini { padding: 3px 9px; border-radius: 7px; font-size: 11.5px; } -.anomalous-ps-toggle.is-active, +.anomalous-ps-gloss.is-active, .anomalous-ps-mini.is-accent { border-color: color-mix(in srgb, var(--amb-link) 55%, transparent); background: color-mix(in srgb, var(--amb-link) 14%, transparent); @@ -112,7 +118,7 @@ } .anomalous-ps-icon:hover, -.anomalous-ps-toggle:hover, +.anomalous-ps-gloss:hover, .anomalous-ps-mini:hover:not(:disabled) { border-color: var(--amb-border-hover); color: var(--amb-text-main); @@ -406,7 +412,7 @@ color: var(--amb-text-main); } -.anomalous-ps-chinese { +.anomalous-ps-foreign { display: flex; align-items: center; justify-content: space-between; @@ -453,7 +459,7 @@ background: color-mix(in srgb, var(--amb-link) 14%, transparent); } -.anomalous-ps-tag.is-chinese { +.anomalous-ps-tag.is-foreign { border-color: rgba(251, 191, 36, 0.55); } @@ -518,9 +524,11 @@ color: #fca5a5; } +/* The meaning reads as text of its own, one step quieter than the tag. */ .anomalous-ps-tag-gloss { - color: var(--amb-text-dim); - font-size: 10.5px; + margin-top: 1px; + color: var(--amb-text-muted); + font-size: 11.5px; line-height: 1.35; } @@ -595,7 +603,7 @@ /* These blocks set their own display, which would outrank the hidden attribute. */ .anomalous-ps-new[hidden], .anomalous-ps-target-note[hidden], -.anomalous-ps-chinese[hidden], +.anomalous-ps-foreign[hidden], .anomalous-ps-tags[hidden], .anomalous-ps-tools[hidden], .anomalous-ps-text[hidden] {