Merge branch 'feature/model-import' into feature/1.61

This commit is contained in:
DemonGatanjieu
2026-10-18 23:31:30 +08:00
11 changed files with 283 additions and 22 deletions
+3 -1
View File
@@ -639,7 +639,9 @@ covered by the `styles.css` manifest.
`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
saved prompts as cards and saves one; `ui_prompt_reference.js` is the reference column a
clicked card opens in, between the cards and the boxes (its tags taken one by one or all, ticks
on those the box has; it widens the drawer while open); `ui_prompt_card_popover.js` owns the card preview (hover
corridor, pin, Copy / Add / Rename / Delete); `prompt_card_drag.js` lets a card or a box be
dragged out of the drawer onto a canvas prompt box or empty canvas (through `material_drag.js`
and `prompt_drop.js`), passing through inside the drawer so the boxes still take it.
+4 -1
View File
@@ -133,9 +133,12 @@
- **Quick to open with many models, or models on a network drive** (a NAS, a cloud server's model disk): the page reads the headers of models not scanned yet 16 at a time and remembers them until the file changes, so it opens again at once; while it counts, it says **Counting your models…** instead of staying blank. On a cloud server with 1041 unscanned models it had taken 79 seconds each time.
### ⚙️ Settings in tabs (设置分组)
- The settings page is split into tabs: **Look, Models, Workflows, Prompts, Backup, Help**. "Change where downloads go" opens Models directly.
- The settings page is split into tabs: **Look, Models, Workflows, Node packs, Prompts, Backup, Help**. "Change where downloads go" opens Models directly.
- **Where downloads (and now imports) go is a choice**: by base model (the new default), in Downloads then by base model, all in a Downloads folder, straight in the type's folder, or your own folder typed by hand; an example path shows the result. Without a saved setting, downloads now go by base model instead of into a Downloads folder.
### 🔖 Take single tags from a saved prompt (从存过的提示词里挑词)
- **In Prompt Studio a clicked card opens as a reference** between the cards and the prompt you are editing, as its tags (with their meanings when a language is picked). Click one tag and only that tag goes into the prompt; tags the prompt has already are ticked. **Add all** puts the whole card in, as a click on a card used to; the preview's Add and dragging a card work as before. The ✕ closes the reference; beside the canvas the drawer widens by the column while it is open.
### Fixes
- **Edit emotions: every clip in the list, and the line follows the clip (编辑情绪:下拉列出全部音频,换音频台词跟着换)**: a box that already held a file listed only the files whose names contain it (the browser filters by the box's text), so a character's other clips seemed missing. Pressing the box now lists all of them, with the current file shown greyed; leaving without a pick keeps it. Picking another clip no longer keeps the old clip's line beside it: the line the node already reads for that clip is filled in, otherwise the line the node finds for the clip (its .txt / .lab or the annotation list) is filled in, with Anomalous_TTS from interface 14 on (older versions leave the box blank and the node reads the line when it speaks); going back to the first clip brings its line back. A blank line box shows, greyed, the line the node uses for its clip.
- **Search a character's clips by line (按台词找参考音频)**: Edit emotions has a search box above the clips: type part of a line or a file name and the matches list with their lines, to hear (▶), **Use as main voice** or **Add as emotion** (a new emotion row with that clip and its line, ready for its name). Searching lines needs Anomalous_TTS interface 14; with an older version it searches file names.
+4 -1
View File
@@ -343,7 +343,10 @@ while the page is visible (scheduled after the previous request), on focus/visib
and after a save; a newer request cancels the older one and a failed one keeps the
current cards. List summaries are not prompt bodies: a prompt's text is read through
`material_prompt_data.js` once per filename and timestamp, since a saved prompt's text
never changes. A card's role decides its box: a positive card never goes into a
never changes. A clicked card opens as a reference (`ui_prompt_reference.js`, read only,
one at a time) between the cards and the boxes: a click on one of its tags adds that tag,
"Add all" the whole card (as the preview's Add does); the ticks follow the box's text on the
same 400 ms poll. A card's role decides its box: a positive card never goes into a
negative box or the reverse; boxes of unknown or both roles take either. A saved
card's preview renames it (`update_material`, tags kept) or deletes it
(`delete_material`, to the Recycle Bin). A card, or a box's handle with the box's text
+16
View File
@@ -1798,6 +1798,14 @@ export const i18n = {
promptStudioRemoveTag: "删掉",
promptStudioTagHint: "点一下选中(可调权重),双击改字,拖动换位置",
promptStudioAllThere: "这些词已经都有了",
promptStudioRefTitle: "参考:{name}",
promptStudioRefClose: "关掉参考",
promptStudioRefAddAll: "全部加入",
promptStudioRefAddAllHint: "把这张卡片的词一次全加进去(已经有的不重复加)",
promptStudioRefNote: "点一个词,只把它加进正在改的提示词;打勾的是已经有的。",
promptStudioRefTake: "只把这个词加进去",
promptStudioRefThere: "这个词已经有了",
promptStudioRefHint: "点卡片现在会先把它打开成参考,可以一个词一个词地挑;想整张加,点参考上的“全部加入”。",
promptStudioAddedSkipped: "加了 {count} 个,{skipped} 个已经有了",
promptDeckSearch: "搜索词卡…",
promptDeckNew: "新建词卡",
@@ -4492,6 +4500,14 @@ export const i18n = {
promptStudioRemoveTag: "Remove",
promptStudioTagHint: "Click to select (and set its weight), double-click to edit, drag to move",
promptStudioAllThere: "Those tags are there already",
promptStudioRefTitle: "Reference: {name}",
promptStudioRefClose: "Close the reference",
promptStudioRefAddAll: "Add all",
promptStudioRefAddAllHint: "Add every tag of this card at once (those there already are not added twice)",
promptStudioRefNote: "Click a tag to add just that one to the prompt being edited; ticked ones are there already.",
promptStudioRefTake: "Add just this tag",
promptStudioRefThere: "This tag is there already",
promptStudioRefHint: "A clicked card now opens as a reference, to pick its tags one by one; to add it whole, press “Add all” on the reference.",
promptStudioAddedSkipped: "Added {count}; {skipped} were there already",
promptDeckSearch: "Search cards…",
promptDeckNew: "New card",
+2 -1
View File
@@ -67,7 +67,8 @@ export function withWeight(tag, weight) {
return rounded === 1 ? core : `(${core}:${rounded})`;
}
const tagKey = tag => tagWeight(tag).core.trim().toLowerCase();
/** What makes two tags the same: their words, whatever the weight or case. */
export const tagKey = tag => tagWeight(tag).core.trim().toLowerCase();
// New tags before item `at` (the end when past it); the text's ending stays at the end.
function spliceTags(parsed, at, texts) {
+2 -1
View File
@@ -45,7 +45,7 @@ const asTag = text => (/^[A-Z][a-z]/.test(text) ? text[0].toLowerCase() + text.s
* (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) }.
* Returns { element, role, value(), render(), refresh(), addText(text) }.
*/
export function createPromptBoxEditor({ role, name, getValue, write, prefs, refuse, onSave, drawer, scope }) {
const id = nextEditorId++;
@@ -406,6 +406,7 @@ export function createPromptBoxEditor({ role, name, getValue, write, prefs, refu
return {
element,
role,
value: getValue,
render,
/** Redraws when the text changed elsewhere (not while it is being edited here). */
refresh() {
+161
View File
@@ -0,0 +1,161 @@
/**
* Prompt Studio's reference column: a clicked card opens here, between the cards and the prompt
* boxes, as its tags (with their meanings when a language is picked), so single tags can be
* taken from it: a click puts that tag into the box the card goes into (ui_prompt_target.js),
* "Add all" the whole card. Tags the box has already are ticked. Read only: the card itself is
* never changed here. In the drawer it widens the drawer by the column while it is open.
*/
import { translate as t } from './locales.js';
import { fetchGlosses, glossOf } from './prompt_gloss.js';
import { promptTagsOf, tagKey, tagWeight } from './prompt_tags.js';
import { showWorkbenchToast } from './ui_prompt_toast.js';
const POLL_MS = 400;
const EXTRA_WIDTH = 280; // the drawer grows by this while the column is open
const HINT_KEY = 'anomalous_prompt_reference_hint';
const ROLE_KEYS = { positive: 'recipePromptRolePositive', negative: 'recipePromptRoleNegative' };
function el(tag, className, content) {
const node = document.createElement(tag);
if (className) node.className = className;
if (content !== undefined) node.textContent = content;
return node;
}
function button(className, label, title, onClick) {
const node = el('button', className, label);
node.type = 'button';
if (title) node.title = title;
node.onclick = onClick;
return node;
}
/** Says once, the first time a card opens here, that a click no longer adds the whole card. */
function hintOnce() {
try {
if (localStorage.getItem(HINT_KEY)) return;
localStorage.setItem(HINT_KEY, '1');
} catch { /* no storage here: say it each visit */ }
showWorkbenchToast(t('promptStudioRefHint'));
}
/**
* Fills `grid` (between the cards and the boxes). `target` is the prompt side ({ addCard(card,
* text), valueFor(card) }); `prefs` the studio's { glossLang }. Returns { open(card), close(), render() }.
*/
export function createPromptReference(grid, { drawer, scope, prefs, target }) {
const panel = el('section', 'anomalous-ps-ref');
panel.hidden = true;
const head = el('header', 'anomalous-ps-target-head');
const label = el('div', 'anomalous-ps-target-label');
const title = el('span', 'anomalous-ps-target-name');
const closeButton = button('anomalous-ps-target-x', '✕', t('promptStudioRefClose'), () => close());
closeButton.setAttribute('aria-label', t('promptStudioRefClose'));
label.append(title, closeButton);
const addAll = button('anomalous-ps-mini', t('promptStudioRefAddAll'), t('promptStudioRefAddAllHint'), () => {
if (card) void target.addCard(card).then(render);
});
head.append(label, addAll);
const role = el('span', 'anomalous-ps-role');
const note = el('p', 'anomalous-ps-target-note', t('promptStudioRefNote'));
const tagsEl = el('div', 'anomalous-ps-tags');
panel.append(head, role, note, tagsEl);
grid.insertBefore(panel, grid.children[1] || null); // right after the cards
let card = null;
let seen = null; // the box text the ticks were drawn from
let taken = new Set(); // tags taken from this card here (a translated one reads differently in the box)
let widened = null; // { from, to } drawer widths while the column is open
let glossTimer = null;
let glossFailed = '';
const timer = setInterval(() => {
if (card && target.valueFor(card) !== seen) render();
}, POLL_MS);
scope.onDispose(() => { clearInterval(timer); clearTimeout(glossTimer); });
function widen(open) {
if (!drawer.classList.contains('anomalous-prompt-studio-drawer')) return;
if (open && !widened) {
const from = drawer.getBoundingClientRect().width;
const to = Math.round(Math.min(window.innerWidth * 0.85, from + EXTRA_WIDTH));
drawer.style.width = `${to}px`;
widened = { from, to };
} else if (!open && widened) {
// Dragged wider or narrower meanwhile: that width is the user's now.
if (Math.round(drawer.getBoundingClientRect().width) === widened.to) drawer.style.width = `${widened.from}px`;
widened = null;
}
}
function glossText(tag) {
const meaning = glossOf(tag, prefs.glossLang);
return meaning === undefined ? (glossFailed === prefs.glossLang ? '' : '…') : meaning;
}
function wantGlosses(tags) {
clearTimeout(glossTimer);
const lang = prefs.glossLang;
if (!lang || glossFailed === lang || tags.every(tag => glossOf(tag, lang) !== undefined)) return;
glossTimer = setTimeout(async () => {
const ok = await fetchGlosses(tags, lang, scope.signal);
if (scope.signal.aborted || !card) return;
if (!ok) glossFailed = lang;
render();
}, 250);
}
function chipFor(tag, have) {
const { core, weight } = tagWeight(tag);
const there = have.has(tagKey(tag)) || taken.has(tagKey(tag));
const chip = el('button', `anomalous-ps-tag is-ref${there ? ' is-there' : ''}`);
chip.type = 'button';
chip.title = t(there ? 'promptStudioRefThere' : 'promptStudioRefTake');
const main = el('span', 'anomalous-ps-tag-main');
if (there) main.append(el('span', 'anomalous-ps-ref-tick', '✓'));
main.append(el('span', 'anomalous-ps-tag-text', core));
if (weight !== 1) main.append(el('span', 'anomalous-ps-tag-weight', String(weight)));
chip.append(main);
if (prefs.glossLang) chip.append(el('span', 'anomalous-ps-tag-gloss', glossText(tag)));
chip.onclick = async () => {
if (there) return;
chip.disabled = true;
if (await target.addCard(card, tag)) taken.add(tagKey(tag));
render();
};
return chip;
}
/** Draws the open card's tags, ticking those its box has. */
function render() {
if (!card) return;
seen = target.valueFor(card);
const have = new Set(promptTagsOf(seen ?? '').map(tagKey));
const tags = promptTagsOf(card.content);
title.textContent = t('promptStudioRefTitle', { name: card.title });
const cardRole = card.role === 'negative' ? 'negative' : 'positive';
role.className = `anomalous-ps-role is-${cardRole}`;
role.textContent = t(ROLE_KEYS[cardRole]);
tagsEl.replaceChildren(...tags.map(tag => chipFor(tag, have)));
wantGlosses(tags);
}
function open(next) {
hintOnce();
if (card?.id !== next.id) taken = new Set();
card = next;
panel.hidden = false;
grid.classList.add('has-ref');
widen(true);
render();
}
function close() {
card = null;
panel.hidden = true;
grid.classList.remove('has-ref');
widen(false);
}
return { open, close, render };
}
+6 -5
View File
@@ -1,7 +1,8 @@
/**
* Prompt Studio's cards: three common ones and the prompts you saved (prompt_material_source.js),
* with search and a small form for a new card. A card is clicked (or added from its preview,
* ui_prompt_card_popover.js) to go into the prompt box of its role, dragged onto a box, or
* with search and a small form for a new card. A card is clicked to open it as a reference
* (ui_prompt_reference.js, tags taken one by one), added whole from its preview
* (ui_prompt_card_popover.js) into the prompt box of its role, dragged onto a box, or
* dragged out onto the canvas (prompt_card_drag.js); a saved one is renamed or deleted from its
* preview. The saved list is checked again every 30 seconds and when the window comes back.
*/
@@ -32,15 +33,15 @@ function button(className, label, title, onClick) {
return node;
}
/** Fills `parent`; `onPick(card)` takes a clicked card. Returns { sync() }. */
export function createPromptSourceDeck(parent, drawer, scope, onPick) {
/** Fills `parent`; `onPick(card)` takes a clicked card, `onAdd(card)` one added whole from its preview. Returns { sync() }. */
export function createPromptSourceDeck(parent, drawer, scope, { onPick, onAdd }) {
const presets = PROMPT_PRESETS.map(card => ({ ...card, title: t(card.titleKey) }));
let saved = [];
let keyword = '';
const popover = createCardPopover({
drawer,
scope,
onAdd: card => onPick(card),
onAdd,
onRename: card => void rename(card),
onDelete: card => void remove(card),
});
+15 -8
View File
@@ -48,7 +48,7 @@ function refusal(card, boxRole) {
/**
* 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() }.
* `onSave(text, role)` keeps a box's text as a card. Returns { addCard(card, text), valueFor(card), render() }.
*/
export function createPromptTarget(parent, { owner, drawer, scope, prefs, onSave }) {
const draft = owner.promptStudioDraft ||= { positive: '', negative: '' };
@@ -162,17 +162,24 @@ export function createPromptTarget(parent, { owner, drawer, scope, prefs, onSave
const timer = setInterval(tick, POLL_MS);
scope.onDispose(() => clearInterval(timer));
/** The box a card goes into: the one of its role, or one that takes either; null when none does. */
const editorFor = card => {
const role = card.role === 'negative' ? 'negative' : 'positive';
return editors.find(item => item.role === role) || editors.find(item => !refusal(card, item.role)) || null;
};
return {
/** A card clicked: into the box of its role (or one that takes either), at the end. */
addCard(card) {
const role = card.role === 'negative' ? 'negative' : 'positive';
const editor = editors.find(item => item.role === role) || editors.find(item => !refusal(card, item.role));
/** `text` (the whole card when left out) into the box the card goes into, at the end; false when it did not go in. */
async addCard(card, text = card.content) {
const editor = editorFor(card);
if (!editor) {
showWorkbenchToast(t('promptStudioNoBoxFor', { card: t(ROLE_NAMES[role]) }));
return;
showWorkbenchToast(t('promptStudioNoBoxFor', { card: t(ROLE_NAMES[card.role === 'negative' ? 'negative' : 'positive']) }));
return false;
}
void editor.addText(card.content);
return editor.addText(text);
},
/** The text of the box the card goes into, null when there is none. */
valueFor: card => editorFor(card)?.value() ?? null,
render: () => editors.forEach(editor => editor.render()),
};
}
+11 -3
View File
@@ -3,8 +3,9 @@
* a top bar with the view switch (tags or the whole text), the language tags' meanings are shown
* in (or none), and on the page "Beside the canvas", in the drawer its dock side, "Back to the
* window" 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 view and the meaning
* the prompt boxes beside the canvas (ui_prompt_target.js). A clicked card opens between them as
* a reference (ui_prompt_reference.js) to take its tags one by one; Save on a box keeps its text
* as a card under My prompts. The view and the meaning
* language are remembered in this browser.
*/
@@ -13,6 +14,7 @@ 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';
import { createPromptReference } from './ui_prompt_reference.js';
import { createPromptSourceDeck } from './ui_prompt_source_deck.js';
import { createPromptTarget } from './ui_prompt_target.js';
import { showWorkbenchToast } from './ui_prompt_toast.js';
@@ -91,6 +93,7 @@ export function createPromptWorkbench(owner, drawer, scope, { mode = 'docked', o
savePrefs(prefs);
drawSwitches();
target.render();
reference.render();
};
controls.append(glossPicker);
@@ -135,6 +138,11 @@ export function createPromptWorkbench(owner, drawer, scope, { mode = 'docked', o
}
}
const deck = createPromptSourceDeck(grid, drawer, scope, card => target.addCard(card));
const deck = createPromptSourceDeck(grid, drawer, scope, {
onPick: card => reference.open(card),
onAdd: card => void target.addCard(card),
});
const target = createPromptTarget(grid, { owner, drawer, scope, prefs, onSave: save });
// Between the cards and the boxes, whichever side the drawer is docked on.
const reference = createPromptReference(grid, { drawer, scope, prefs, target });
}
+59 -1
View File
@@ -20,6 +20,10 @@
grid-template-columns: 240px minmax(0, 1fr);
}
.anomalous-prompt-page .anomalous-ps-grid.has-ref {
grid-template-columns: 240px minmax(0, 1fr) minmax(0, 1fr);
}
.anomalous-ps {
container: anomalous-ps / inline-size;
display: flex;
@@ -165,6 +169,51 @@
grid-template-columns: minmax(0, 1fr) 200px;
}
/* A card opened as a reference (ui_prompt_reference.js) sits between the cards and the boxes. */
.anomalous-ps-grid.has-ref {
grid-template-columns: 200px minmax(0, 1fr) minmax(0, 1fr);
}
.anomalous-prompt-studio-drawer:not(.is-dock-left) .anomalous-ps-grid.has-ref {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 200px;
}
.anomalous-ps-ref {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
padding: 10px 12px 16px;
overflow-y: auto;
border-right: 1px solid var(--amb-border);
background: var(--amb-bg-panel);
}
.anomalous-prompt-studio-drawer:not(.is-dock-left) .anomalous-ps-ref {
order: 1;
border-right: 0;
border-left: 1px solid var(--amb-border);
}
.anomalous-ps-ref .anomalous-ps-role {
align-self: flex-start;
}
.anomalous-ps-tag.is-ref {
font-family: inherit;
text-align: left;
cursor: copy;
}
.anomalous-ps-tag.is-ref.is-there {
opacity: 0.55;
cursor: default;
}
.anomalous-ps-ref-tick {
color: var(--amb-ok-text);
}
.anomalous-ps-deck {
display: flex;
flex-direction: column;
@@ -648,6 +697,7 @@
/* These blocks set their own display, which would outrank the hidden attribute. */
.anomalous-ps-new[hidden],
.anomalous-ps-ref[hidden],
.anomalous-ps-target-note[hidden],
.anomalous-ps-foreign[hidden],
.anomalous-ps-tags[hidden],
@@ -664,8 +714,16 @@
grid-template-rows: minmax(120px, 34%) minmax(0, 1fr);
}
.anomalous-ps-grid.has-ref,
.anomalous-prompt-studio-drawer:not(.is-dock-left) .anomalous-ps-grid.has-ref {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(100px, 26%) minmax(100px, 30%) minmax(0, 1fr);
}
.anomalous-ps-deck,
.anomalous-prompt-studio-drawer:not(.is-dock-left) .anomalous-ps-deck {
.anomalous-prompt-studio-drawer:not(.is-dock-left) .anomalous-ps-deck,
.anomalous-ps-ref,
.anomalous-prompt-studio-drawer:not(.is-dock-left) .anomalous-ps-ref {
order: 0;
border: 0;
border-bottom: 1px solid var(--amb-border);