Tag suggestions and weight shortcuts for NovelAI prompts. 通过Ctrl+↑/↓快速调整输入光标所在的Tag权重,Ctrl+←/→移动Tag位置。支持{}[]||等特殊表达式。自动格式化Prompt。
// ==UserScript==
// @name Novelai Prompt Helper / Novelai 提示词增强
// @namespace https://novelai.net
// @version 1.2.3
// @description Tag suggestions and weight shortcuts for NovelAI prompts. 通过Ctrl+↑/↓快速调整输入光标所在的Tag权重,Ctrl+←/→移动Tag位置。支持{}[]||等特殊表达式。自动格式化Prompt。
// @author Takoro
// @match https://novelai.net/image
// @match https://novelai.github.io/image
// @icon https://www.google.com/s2/favicons?sz=64&domain=novelai.net
// @license MIT
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @grant GM_openInTab
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @grant GM_unregisterMenuCommand
// @connect danbooru.donmai.us
// @connect raw.githubusercontent.com
// @history 1.2.3 (2026-02-05) 添加候选词收藏/排除功能(右键候选词操作);收藏词优先匹配并置顶显示;添加NAI重命名Tag关联(如v→peace sign);修复权重组内尾部带数字的tag被NAI错误解析;表情联想支持;格式化自动替换中文逗号
// @history 1.2.2 (2026-01-28) 修复开启保持选中后连续调整带权重tag时无法继续调整的问题
// @history 1.2.1 (2026-01-26) 添加[]减权符号支持;完善{} []||等特殊表达式的识别和处理
// @history 1.2 (2026-01-26) 添加{}加权和||...||随机组支持;支持在特殊表达式内移动和调整权重
// @history 1.1.5 原始版本
// ==/UserScript==
(function () {
'use strict';
if (typeof window.__NAIWeightAdjusting !== 'boolean') {
window.__NAIWeightAdjusting = false;
}
if (typeof window.__NAIWeightAdjustingUntil !== 'number') {
window.__NAIWeightAdjustingUntil = 0;
}
const LOCALE_KEY = 'nai_prompt_helper_locale_v1';
const AUTOFORMAT_KEY = 'nai_prompt_helper_autoformat_enabled_v1';
const KEEP_SELECTION_KEY = 'nai_prompt_helper_keep_selection_v1';
const FAVORITES_KEY = 'nai_prompt_helper_favorites_v1';
const EXCLUDED_KEY = 'nai_prompt_helper_excluded_v1';
const LOCALE_MESSAGES = {
zh: {
infoTitle: 'Did you mean...?',
hints: [
'按住 Ctrl + 左键点击标签可以打开对应的 Wiki 页面。',
'紫色标签代表系列/IP,绿色标签代表角色。',
'标签后面的数字表示相关作品数量。',
'可以尝试使用 Tab、Enter 或方向键选择候选项。',
'右键点击标签可以收藏或排除。',
'Takoro'
],
fallbackHint: 'Danbooru API 连接失败,已切换到本地缓存。',
apiTimeoutLog: '[NAI Prompt Helper] 网络请求超时,改用本地缓存。',
logReady: '[NAI Prompt Helper] 标签联想模块就绪。',
menuSwitchLabel: '切换到英文界面',
menuEnableFormat: '启用自动格式化',
menuDisableFormat: '禁用自动格式化',
menuEnableKeepSelection: '启用调整后保持选中',
menuDisableKeepSelection: '禁用调整后保持选中',
contextMenuFavorite: '收藏',
contextMenuExclude: '排除',
contextMenuRemoveFavorite: '取消收藏',
contextMenuRemoveExclude: '取消排除',
},
en: {
infoTitle: 'Did you mean...?',
hints: [
'Ctrl + Click a tag to open its Danbooru wiki page.',
'Purple tags are series/IP names; green tags are characters.',
'Numbers after a tag show total post counts.',
'Use Tab, Enter, or arrow keys to pick a suggestion.',
'Right-click a tag to favorite or exclude it.',
'Takoro'
],
fallbackHint: 'Danbooru API error, switching to cached suggestions.',
apiTimeoutLog: '[NAI Prompt Helper] API timeout, using cached suggestions.',
logReady: '[NAI Prompt Helper] Tag suggestions ready.',
menuSwitchLabel: 'Switch to Chinese UI',
menuEnableFormat: 'Enable Auto-Formatting',
menuDisableFormat: 'Disable Auto-Formatting',
menuEnableKeepSelection: 'Enable Keep Selection After Adjustment',
menuDisableKeepSelection: 'Disable Keep Selection After Adjustment',
contextMenuFavorite: 'Favorite',
contextMenuExclude: 'Exclude',
contextMenuRemoveFavorite: 'Remove Favorite',
contextMenuRemoveExclude: 'Remove Exclude',
}
};
const SUPPORTED_LOCALES = Object.keys(LOCALE_MESSAGES);
function detectInitialLocale() {
try {
if (typeof GM_getValue === 'function') {
const stored = GM_getValue(LOCALE_KEY);
if (stored && SUPPORTED_LOCALES.includes(stored)) {
return stored;
}
}
} catch (error) { }
const nav = (navigator.language || navigator.userLanguage || '').toLowerCase();
if (nav.startsWith('zh')) { return 'zh'; }
return 'en';
}
let currentLocale = detectInitialLocale();
let autoFormatEnabled = true;
let keepSelectionAfterAdjustment = true;
let favoriteTags = new Set();
let excludedTags = new Set();
try {
if (typeof GM_getValue === 'function') {
const storedValue = GM_getValue(AUTOFORMAT_KEY);
if (typeof storedValue === 'boolean') {
autoFormatEnabled = storedValue;
} else {
GM_setValue(AUTOFORMAT_KEY, true);
}
const storedKeepSelection = GM_getValue(KEEP_SELECTION_KEY);
if (typeof storedKeepSelection === 'boolean') {
keepSelectionAfterAdjustment = storedKeepSelection;
} else {
GM_setValue(KEEP_SELECTION_KEY, true);
}
const storedFavorites = GM_getValue(FAVORITES_KEY);
if (Array.isArray(storedFavorites)) {
favoriteTags = new Set(storedFavorites);
}
const storedExcluded = GM_getValue(EXCLUDED_KEY);
if (Array.isArray(storedExcluded)) {
excludedTags = new Set(storedExcluded);
}
}
} catch (e) { }
function saveFavorites() {
try { if (typeof GM_setValue === 'function') GM_setValue(FAVORITES_KEY, Array.from(favoriteTags)); } catch (e) { }
}
function saveExcluded() {
try { if (typeof GM_setValue === 'function') GM_setValue(EXCLUDED_KEY, Array.from(excludedTags)); } catch (e) { }
}
function toggleFavorite(tagName) {
if (favoriteTags.has(tagName)) {
favoriteTags.delete(tagName);
} else {
favoriteTags.add(tagName);
excludedTags.delete(tagName);
saveExcluded();
}
saveFavorites();
}
function toggleExcluded(tagName) {
if (excludedTags.has(tagName)) {
excludedTags.delete(tagName);
} else {
excludedTags.add(tagName);
favoriteTags.delete(tagName);
saveFavorites();
}
saveExcluded();
}
let registeredMenuIds = [];
const isChineseLocale = currentLocale === 'zh';
function t(key) {
const bundle = LOCALE_MESSAGES[currentLocale] || LOCALE_MESSAGES.en;
return bundle[key] ?? LOCALE_MESSAGES.en[key] ?? key;
}
function getHints() {
const bundle = LOCALE_MESSAGES[currentLocale] || LOCALE_MESSAGES.en;
return bundle.hints || [];
}
function clearLocaleMenus() {
if (typeof GM_unregisterMenuCommand !== 'function') return;
registeredMenuIds.forEach(id => {
try { GM_unregisterMenuCommand(id); } catch (error) { }
});
registeredMenuIds = [];
}
function registerMenus() {
if (typeof GM_registerMenuCommand !== 'function') return;
clearLocaleMenus();
const otherLocale = currentLocale === 'zh' ? 'en' : 'zh';
const localeLabel = LOCALE_MESSAGES[currentLocale].menuSwitchLabel;
try {
const localeCmdId = GM_registerMenuCommand(localeLabel, () => {
try {
if (typeof GM_setValue === 'function') {
GM_setValue(LOCALE_KEY, otherLocale);
}
} catch (error) { }
window.location.reload();
});
if (typeof localeCmdId !== 'undefined') {
registeredMenuIds.push(localeCmdId);
}
} catch (error) { }
const formatLabel = autoFormatEnabled ? t('menuDisableFormat') : t('menuEnableFormat');
try {
const formatCmdId = GM_registerMenuCommand(formatLabel, () => {
try {
if (typeof GM_setValue === 'function') {
GM_setValue(AUTOFORMAT_KEY, !autoFormatEnabled);
}
} catch (error) { }
window.location.reload();
});
if (typeof formatCmdId !== 'undefined') {
registeredMenuIds.push(formatCmdId);
}
} catch (error) { }
const keepSelectionLabel = keepSelectionAfterAdjustment ? t('menuDisableKeepSelection') : t('menuEnableKeepSelection');
try {
const keepSelectionCmdId = GM_registerMenuCommand(keepSelectionLabel, () => {
try {
if (typeof GM_setValue === 'function') {
GM_setValue(KEEP_SELECTION_KEY, !keepSelectionAfterAdjustment);
}
} catch (error) { }
window.location.reload();
});
if (typeof keepSelectionCmdId !== 'undefined') {
registeredMenuIds.push(keepSelectionCmdId);
}
} catch (error) { }
}
function formatPromptText(text) {
// 中文逗号替换为英文逗号
text = text.replace(/,/g, ',');
const structure = WeightShortcuts.parsePromptStructure(text);
WeightShortcuts.normalizeTree(structure);
return WeightShortcuts.serializeTree(structure);
}
registerMenus();
const TagAssist = (() => {
const MAX_SUGGESTIONS = 10;
const DEBOUNCE_DELAY = 400;
const DEBOUNCE_DELAY_EMOTICON = 1200;
const API_BASE_URL = 'https://danbooru.donmai.us';
const TRANSLATION_URL = 'https://raw.githubusercontent.com/Yellow-Rush/zh_CN-Tags/main/danbooru.csv';
const CACHE_KEY = 'danbooru_translations_cache';
const CACHE_DURATION_MS = 7 * 24 * 60 * 60 * 1000;
const TITLE_COLOR = '#e3dccc';
const SHORT_QUERY_PREFIX_LENGTH = 2;
const ALLOWED_CHARS_REGEX = /^[a-zA-Z\d_\-\s'\^=@()\u4e00-\u9fa5\u3040-\u309F\u30A0-\u30FF]+$/;
const BREAK_CHARS = ',{}[]:.';
let popup = null;
let selectedIndex = -1;
let currentMatches = [];
let isPopupActive = false;
let isKeyboardNavigation = false;
let apiAbortController = null;
let lastKnownRange = null;
let translations = new Map();
let contextMenu = null;
let lastQuery = '';
let lastInput = null;
const STAR_SVG = '<svg viewBox="0 0 24 24"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>';
const CROSS_SVG = '<svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>';
// NAI tag aliases: original Danbooru name -> NAI name
const NAI_TAG_ALIASES = {
'v': 'peace sign',
'double_v': 'double peace',
'|_|': 'bar eyes',
'\\||/': 'open \\m/',
':|': 'neutral face',
';|': 'neutral face',
'<|>_<|>': 'neco-arc eyes',
'eyepatch_bikini': 'square bikini',
'tachi-e': 'character image',
'vivian_banshee': 'vivian (zenless zone zero)'
};
// Reverse mapping: NAI name -> original Danbooru name(s)
const NAI_ALIAS_REVERSE = {};
for (const [original, nai] of Object.entries(NAI_TAG_ALIASES)) {
const naiKey = nai.toLowerCase().replace(/\s+/g, '_');
if (!NAI_ALIAS_REVERSE[naiKey]) NAI_ALIAS_REVERSE[naiKey] = [];
NAI_ALIAS_REVERSE[naiKey].push(original);
}
GM_addStyle(`
.autocomplete-container { position: absolute; background: #0e0f21; border: 1px solid #3B3B52; border-radius: 8px; box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3); max-height: 450px; display: flex; flex-direction: column; z-index: 100000; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; color: #EAEAEB; min-width: 350px; max-width: 500px; }
.suggestion-info-display { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; background: #1a1c30; border-bottom: 1px solid #3B3B52; font-size: 13.5px; line-height: 1.6; min-height: 17px; font-weight: bold; }
.info-hint { color: #8a8a9e; font-weight: normal; font-size: 12px; margin-left: 10px; white-space: nowrap; }
.info-hint-error { color: #ff7b7b; font-weight: bold; font-size: 12px; margin-left: 10px; white-space: nowrap; }
.info-title { color: ${TITLE_COLOR}; }
.suggestion-scroll-wrapper { padding: 7px; overflow-y: auto; }
.suggestion-flex { display: flex; flex-wrap: wrap; gap: 6px; }
.suggestion-item { display: flex; align-items: center; justify-content: space-between; padding: 4px 10px; background: #22253f; border: 1px solid transparent; border-radius: 4px; cursor: pointer; transition: all 0.2s ease; font-size: 14px; height: 26px; white-space: nowrap; flex-shrink: 0; }
.suggestion-item:hover, .suggestion-item.selected { background: #34395f; border-color: #F5F3C2; }
.suggestion-item:hover .suggestion-divider, .suggestion-item.selected .suggestion-divider { background: #8a8a9e; }
.suggestion-text { overflow: hidden; text-overflow: ellipsis; color: #EAEAEB; }
.suggestion-count { color: #8a8a9e; margin-left: 12px; font-size: 12px; }
.suggestion-item[data-category='4'] .suggestion-text { color: #a6f3a6; }
.suggestion-item[data-category='3'] .suggestion-text { color: #d6bcf5; }
.suggestion-alias { color: #8a8a9e; margin-left: 4px; }
.suggestion-icon { display: flex; align-items: center; margin-right: 6px; flex-shrink: 0; }
.suggestion-icon svg { width: 12px; height: 12px; }
.suggestion-icon.fav-icon svg { fill: #ffd700; }
.suggestion-icon.excl-icon svg { fill: #ff6b6b; }
.suggestion-divider { width: 1px; height: 14px; background: #3B3B52; margin: 0 6px; flex-shrink: 0; }
.tag-context-menu { position: fixed; background: #1a1c30; border: 1px solid #3B3B52; border-radius: 6px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); z-index: 100001; min-width: 100px; padding: 2px 0; }
.tag-context-menu-item { display: flex; align-items: center; padding: 5px 10px; cursor: pointer; font-size: 13px; color: #EAEAEB; transition: background 0.15s ease; }
.tag-context-menu-item:first-child { border-bottom: 1px solid #3B3B52; }
.tag-context-menu-item:hover { background: #34395f; }
.tag-context-menu-item svg { width: 12px; height: 12px; margin-right: 6px; }
.tag-context-menu-item.fav-item svg { fill: #ffd700; }
.tag-context-menu-item.excl-item svg { fill: #ff6b6b; }
`);
const normalizeQuery = (query) => query.trim().replace(/\s+/g, '_');
function hideContextMenu() {
if (contextMenu) {
contextMenu.remove();
contextMenu = null;
}
}
function showContextMenu(tagName, x, y) {
hideContextMenu();
contextMenu = document.createElement('div');
contextMenu.className = 'tag-context-menu';
const isFavorite = favoriteTags.has(tagName);
const isExcluded = excludedTags.has(tagName);
const favItem = document.createElement('div');
favItem.className = 'tag-context-menu-item fav-item';
favItem.innerHTML = `${STAR_SVG}<span>${isFavorite ? t('contextMenuRemoveFavorite') : t('contextMenuFavorite')}</span>`;
favItem.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
toggleFavorite(tagName);
hideContextMenu();
if (lastQuery && lastInput && currentMatches.length > 0) {
updatePopup(lastInput, currentMatches, null, lastQuery);
}
});
const exclItem = document.createElement('div');
exclItem.className = 'tag-context-menu-item excl-item';
exclItem.innerHTML = `${CROSS_SVG}<span>${isExcluded ? t('contextMenuRemoveExclude') : t('contextMenuExclude')}</span>`;
exclItem.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
toggleExcluded(tagName);
hideContextMenu();
if (lastQuery && lastInput && currentMatches.length > 0) {
updatePopup(lastInput, currentMatches, null, lastQuery);
}
});
contextMenu.appendChild(favItem);
contextMenu.appendChild(exclItem);
contextMenu.style.left = `${x}px`;
contextMenu.style.top = `${y}px`;
document.body.appendChild(contextMenu);
}
function sortMatchesByPriority(matches, query) {
const normalizedQuery = normalizeQuery(query.toLowerCase());
return matches.sort((a, b) => {
const aFav = favoriteTags.has(a.en);
const bFav = favoriteTags.has(b.en);
const aExcl = excludedTags.has(a.en);
const bExcl = excludedTags.has(b.en);
// Favorites first
if (aFav && !bFav) return -1;
if (!aFav && bFav) return 1;
// Excluded last
if (aExcl && !bExcl) return 1;
if (!aExcl && bExcl) return -1;
// For favorites, check if query matches the start
if (aFav && bFav) {
const aStart = a.en.toLowerCase().startsWith(normalizedQuery);
const bStart = b.en.toLowerCase().startsWith(normalizedQuery);
if (aStart && !bStart) return -1;
if (!aStart && bStart) return 1;
}
return 0;
});
}
function mergeMatchedFavorites(matches, query) {
if (!favoriteTags.size) return matches;
const queryLower = normalizeQuery(query).toLowerCase();
if (queryLower.length < 3) return matches;
const querySpaced = query.toLowerCase().trim().replace(/\s+/g, ' ');
const matchNames = new Set(matches.map(m => m.en));
const additionalFavorites = [];
for (const favName of favoriteTags) {
if (matchNames.has(favName)) continue;
const enLower = favName.toLowerCase();
const enSpaced = enLower.replace(/_/g, ' ');
if (enLower.startsWith(queryLower) || enSpaced.startsWith(querySpaced) ||
enLower.includes(queryLower) || enSpaced.includes(querySpaced)) {
additionalFavorites.push({ en: favName, count: undefined, category: 0 });
}
}
return [...additionalFavorites, ...matches];
}
function findNaiAliasMatches(query) {
const queryLower = query.toLowerCase().trim();
const queryNorm = queryLower.replace(/\s+/g, '_');
const results = [];
// Search by NAI name (reverse lookup)
for (const [naiKey, originals] of Object.entries(NAI_ALIAS_REVERSE)) {
const naiSpaced = naiKey.replace(/_/g, ' ');
if (naiKey.startsWith(queryNorm) || naiSpaced.startsWith(queryLower) ||
naiKey.includes(queryNorm) || naiSpaced.includes(queryLower)) {
for (const original of originals) {
results.push({ en: original, count: undefined, category: 0, isAliasMatch: true });
}
}
}
// Search by original name
for (const [original, nai] of Object.entries(NAI_TAG_ALIASES)) {
const origLower = original.toLowerCase();
const origSpaced = origLower.replace(/_/g, ' ');
if (origLower.startsWith(queryNorm) || origSpaced.startsWith(queryLower) ||
origLower.includes(queryNorm) || origSpaced.includes(queryLower)) {
if (!results.some(r => r.en === original)) {
results.push({ en: original, count: undefined, category: 0, isAliasMatch: true });
}
}
}
return results;
}
function mergeNaiAliasMatches(matches, query) {
const aliasMatches = findNaiAliasMatches(query);
if (!aliasMatches.length) return matches;
const matchNames = new Set(matches.map(m => m.en));
const additional = aliasMatches.filter(a => !matchNames.has(a.en));
return [...additional, ...matches];
}
function shouldSkipWeightSuggestions(textBeforeCursor, delimiterIndex) {
if (delimiterIndex < 0 || textBeforeCursor[delimiterIndex] !== ':') return false;
if (textBeforeCursor.substring(delimiterIndex - 1, delimiterIndex + 1) === '::') { return false; }
let i = delimiterIndex - 1;
if (i >= 0 && textBeforeCursor[i] === ':') {
i--;
while (i >= 0 && /\s/.test(textBeforeCursor[i])) i--;
if (i >= 0 && /[\d.]/.test(textBeforeCursor[i])) {
let end = i;
while (i >= 0 && /[\d.]/.test(textBeforeCursor[i])) i--;
const numericPart = textBeforeCursor.substring(i + 1, end + 1);
if (numericPart && /^\d+(?:\.\d+)?$/.test(numericPart)) {
const precedingChar = i >= 0 ? textBeforeCursor[i] : '';
if (i < 0 || BREAK_CHARS.includes(precedingChar) || /\s/.test(precedingChar)) { return true; }
}
}
return false;
}
while (i >= 0 && /\s/.test(textBeforeCursor[i])) i--;
if (i < 0) return false;
let end = i;
while (i >= 0 && /[\d.]/.test(textBeforeCursor[i])) i--;
const numericPart = textBeforeCursor.substring(i + 1, end + 1);
if (!numericPart || !/^\d+(?:\.\d+)?$/.test(numericPart)) return false;
const precedingChar = i >= 0 ? textBeforeCursor[i] : '';
if (i >= 0 && !BREAK_CHARS.includes(precedingChar) && !/\s/.test(precedingChar)) return false;
return true;
}
function getClonedSelectionRange() {
const selection = window.getSelection();
if (!selection || !selection.rangeCount) return null;
return selection.getRangeAt(0).cloneRange();
}
function loadTranslations() {
if (!isChineseLocale) { translations = new Map(); return; }
const cachedData = GM_getValue(CACHE_KEY);
if (cachedData && cachedData.timestamp && (Date.now() - cachedData.timestamp < CACHE_DURATION_MS)) {
translations = new Map(cachedData.translations); return;
}
GM_xmlhttpRequest({
method: "GET", url: TRANSLATION_URL,
onload: function (response) {
if (response.status === 200) {
const lines = response.responseText.split('\n').filter(line => line.trim());
lines.forEach(line => {
const columns = line.split(/,(?=(?:(?:[^"]*"){2})*[^"]*$)/);
if (columns.length >= 2) {
const en = (columns[0] || '').trim().replace(/^"|"$/g, '');
const zh = (columns[1] || '').trim().replace(/^"|"$/g, '');
if (en && zh) { translations.set(en, zh); }
}
});
GM_setValue(CACHE_KEY, { translations: Array.from(translations.entries()), timestamp: Date.now() });
}
},
});
}
function openWikiPage(tagName) { if (!tagName) return; GM_openInTab(`${API_BASE_URL}/wiki_pages/show_or_new?title=${tagName}`, { active: true }); }
function getRandomHint() {
const hints = getHints();
if (!hints.length || Math.random() < 0.5) { return ""; }
return hints[Math.floor(Math.random() * hints.length)];
}
function searchLocalSuggestions(query, input) {
if (!isChineseLocale || !translations.size) return;
const queryLower = query.toLowerCase();
const rankedItems = [];
for (const [en, zh] of translations.entries()) {
const zhLower = zh.toLowerCase();
let score = 0;
if (zhLower.startsWith(queryLower)) score = 1;
else if (zhLower.includes(queryLower)) score = 2;
if (score > 0) { rankedItems.push({ score: score, data: { en: en, count: undefined, category: 0 } }); }
}
let finalMatches = rankedItems.sort((a, b) => a.score - b.score).map(item => item.data).slice(0, MAX_SUGGESTIONS);
finalMatches = mergeMatchedFavorites(finalMatches, query);
finalMatches = mergeNaiAliasMatches(finalMatches, query);
updatePopup(input, finalMatches, null, query);
}
function searchLocalFallback(query, input) {
if (!isChineseLocale || !translations.size) { hidePopup(); return; }
const queryLower = query.toLowerCase(), normalizedQuery = normalizeQuery(queryLower), spacedQuery = queryLower.replace(/\s+/g, ' ');
if (!normalizedQuery) { hidePopup(); return; }
const rankedItems = [];
for (const en of translations.keys()) {
const enLower = en.toLowerCase();
let score = 0;
const enNormalized = normalizeQuery(enLower), enSpaced = enLower.replace(/_/g, ' ');
if (enNormalized.startsWith(normalizedQuery) || enSpaced.startsWith(spacedQuery)) score = 1;
else if (enNormalized.includes(normalizedQuery) || enSpaced.includes(spacedQuery)) score = 2;
if (score > 0) { rankedItems.push({ score: score, data: { en: en, count: undefined, category: 0 } }); }
}
const finalMatches = rankedItems.sort((a, b) => a.score - b.score).map(item => item.data).slice(0, MAX_SUGGESTIONS);
if (finalMatches.length > 0) { updatePopup(input, mergeNaiAliasMatches(mergeMatchedFavorites(finalMatches, query), query), t('fallbackHint'), query); } else { hidePopup(); }
}
function fetchSuggestions(query, input) {
const normalizedQuery = normalizeQuery(query);
if (!normalizedQuery) { hidePopup(); return; }
if (apiAbortController) apiAbortController.abort();
apiAbortController = new AbortController();
const searchPattern = normalizedQuery.length <= SHORT_QUERY_PREFIX_LENGTH ? `${normalizedQuery}*` : `*${normalizedQuery}*`;
const params = new URLSearchParams({ 'search[name_matches]': searchPattern, 'search[order]': 'count', 'limit': MAX_SUGGESTIONS, 'search[hide_empty]': 'true' });
GM_xmlhttpRequest({
method: "GET",
url: `${API_BASE_URL}/tags.json?${params.toString()}`,
signal: apiAbortController.signal,
timeout: 1500,
headers: {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.0.0 Safari/537.36",
"Referer": "https://danbooru.donmai.us/"
},
ontimeout: function () {
console.error(`[NAI Prompt Helper] API 请求超时 (ontimeout)。(Timeout: 1500ms). URL: ${API_BASE_URL}/tags.json?${params.toString()}`);
searchLocalFallback(query, input);
},
onload: function (response) {
if (response.status === 200) {
let matches = JSON.parse(response.responseText).map(tag => ({ en: tag.name, count: tag.post_count, category: tag.category }));
matches = mergeMatchedFavorites(matches, query);
matches = mergeNaiAliasMatches(matches, query);
updatePopup(input, matches, null, query);
} else {
console.error(`[NAI Prompt Helper] API 错误: 收到 HTTP 状态 ${response.status} ${response.statusText}`);
console.warn(`[NAI Prompt Helper] 失败的URL: ${response.finalUrl}`);
searchLocalFallback(query, input);
}
},
onerror: (error) => {
console.error('[NAI Prompt Helper] API 请求失败 (onerror)。 详细信息:', error);
if (error.readyState !== 0) { // 忽略 abort 信号
searchLocalFallback(query, input);
}
}
});
}
function fetchEmoticonSuggestions(query, input) {
// 先获取 NAI 别名匹配
const aliasMatches = findNaiAliasMatches(query);
// 同时尝试 Danbooru API
const normalizedQuery = normalizeQuery(query);
if (!normalizedQuery) {
if (aliasMatches.length > 0) {
updatePopup(input, aliasMatches, null, query);
} else { hidePopup(); }
return;
}
if (apiAbortController) apiAbortController.abort();
apiAbortController = new AbortController();
const searchPattern = `${normalizedQuery}*`;
const params = new URLSearchParams({ 'search[name_matches]': searchPattern, 'search[order]': 'count', 'limit': MAX_SUGGESTIONS, 'search[hide_empty]': 'true' });
GM_xmlhttpRequest({
method: "GET",
url: `${API_BASE_URL}/tags.json?${params.toString()}`,
signal: apiAbortController.signal,
timeout: 1500,
headers: {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.0.0 Safari/537.36",
"Referer": "https://danbooru.donmai.us/"
},
ontimeout: function () {
// API 超时,只显示别名匹配
if (aliasMatches.length > 0) {
updatePopup(input, aliasMatches, null, query);
} else { hidePopup(); }
},
onload: function (response) {
if (response.status === 200) {
let matches = JSON.parse(response.responseText).map(tag => ({ en: tag.name, count: tag.post_count, category: tag.category }));
// 合并 API 结果和别名匹配
const matchNames = new Set(matches.map(m => m.en));
const uniqueAliases = aliasMatches.filter(a => !matchNames.has(a.en));
matches = [...uniqueAliases, ...matches];
matches = mergeMatchedFavorites(matches, query);
if (matches.length > 0) {
updatePopup(input, matches, null, query);
} else { hidePopup(); }
} else {
// API 错误,只显示别名匹配
if (aliasMatches.length > 0) {
updatePopup(input, aliasMatches, null, query);
} else { hidePopup(); }
}
},
onerror: () => {
// API 错误,只显示别名匹配
if (aliasMatches.length > 0) {
updatePopup(input, aliasMatches, null, query);
} else { hidePopup(); }
}
});
}
function updatePopup(input, matches, overrideHint = null, query = '') {
createPopup();
hideContextMenu();
lastInput = input;
lastQuery = query;
// Sort matches by favorites/excluded priority
if (query) {
matches = sortMatchesByPriority([...matches], query);
}
let hintHTML = overrideHint ? `<span class="info-hint-error">${overrideHint}</span>` : (getRandomHint() ? `<span class="info-hint">${getRandomHint()}</span>` : '');
popup.innerHTML = `<div class="suggestion-info-display"><span class="info-title">${t('infoTitle')}</span>${hintHTML}</div><div class="suggestion-scroll-wrapper"><div class="suggestion-flex"></div></div>`;
const flexContainer = popup.querySelector('.suggestion-flex');
currentMatches = matches;
if (matches.length === 0) { hidePopup(); return; }
matches.forEach((tag, index) => {
const item = document.createElement('div');
item.className = 'suggestion-item';
item.dataset.category = tag.category || '0';
const naiAlias = NAI_TAG_ALIASES[tag.en];
const enText = tag.en.replace(/_/g, ' ');
const zhText = translations.get(tag.en);
let displayHTML;
if (naiAlias) {
const naiText = naiAlias.replace(/_/g, ' ');
displayHTML = zhText
? `<span class="suggestion-text">${enText}</span><span class="suggestion-divider"></span><span class="suggestion-text">${naiText} (${zhText})</span>`
: `<span class="suggestion-text">${enText}</span><span class="suggestion-divider"></span><span class="suggestion-text">${naiText}</span>`;
} else {
const displayText = zhText ? `${enText} (${zhText})` : enText;
displayHTML = `<span class="suggestion-text">${displayText}</span>`;
}
// Store the name to insert (NAI version if available)
tag.insertName = naiAlias || tag.en;
let countHTML = '';
if (tag.count !== undefined && tag.count !== null) {
const countText = tag.count > 1000 ? `${(tag.count / 1000).toFixed(1)}k` : tag.count;
countHTML = `<span class="suggestion-count">${countText}</span>`;
}
// Add icon for favorite/excluded tags
let iconHTML = '';
if (favoriteTags.has(tag.en)) {
iconHTML = `<span class="suggestion-icon fav-icon">${STAR_SVG}</span>`;
} else if (excludedTags.has(tag.en)) {
iconHTML = `<span class="suggestion-icon excl-icon">${CROSS_SVG}</span>`;
}
item.innerHTML = `${iconHTML}${displayHTML}${countHTML}`;
item.addEventListener('mouseover', () => {
isKeyboardNavigation = false;
if (selectedIndex !== index) { selectedIndex = index; updateSelectionUI(); }
});
item.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
e.preventDefault();
selectedIndex = index;
updateSelectionUI();
if (e.ctrlKey) { openWikiPage(tag.en); }
else {
const currentInput = getActiveInputElement();
if (currentInput) {
const rangeToRestore = lastKnownRange ? lastKnownRange.cloneRange() : getClonedSelectionRange();
currentInput.focus();
requestAnimationFrame(() => insertTag(currentInput, tag.insertName, rangeToRestore));
}
}
});
item.addEventListener('contextmenu', (e) => {
e.preventDefault();
e.stopPropagation();
// Save selection before showing context menu to prevent cursor loss
const currentInput = getActiveInputElement();
if (currentInput) {
const range = getClonedSelectionRange();
if (range && currentInput.contains(range.commonAncestorContainer)) {
lastKnownRange = range;
}
}
showContextMenu(tag.en, e.clientX, e.clientY);
});
flexContainer.appendChild(item);
});
positionPopup(input);
popup.style.display = 'flex';
isPopupActive = true;
if (matches.length > 0) { selectedIndex = 0; updateSelectionUI(); }
}
function findNodeAndOffsetFromGlobal(root, globalOffset) {
const treeWalker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null, false);
let accumulatedLength = 0;
let lastTextNode = null;
let currentNode;
while (currentNode = treeWalker.nextNode()) {
lastTextNode = currentNode;
const nodeLength = currentNode.textContent.length;
if (accumulatedLength + nodeLength >= globalOffset) {
return { node: currentNode, offset: globalOffset - accumulatedLength };
}
accumulatedLength += nodeLength;
}
if (lastTextNode) {
return { node: lastTextNode, offset: lastTextNode.textContent.length };
}
return { node: root, offset: 0 };
}
function insertTag(input, tag, rangeToRestore) {
const cleanTag = tag.replace(/_/g, ' ');
const workingRange = rangeToRestore ? rangeToRestore.cloneRange() : getClonedSelectionRange();
if (!workingRange) return;
const tempRange = document.createRange();
tempRange.setStart(input, 0);
tempRange.setEnd(workingRange.startContainer, workingRange.startOffset);
const globalCursorPos = tempRange.toString().length;
const fullText = input.textContent;
if (autoFormatEnabled) {
let start = globalCursorPos;
while (start > 0 && !BREAK_CHARS.includes(fullText[start - 1])) { start--; }
let end = globalCursorPos;
while (end < fullText.length && !BREAK_CHARS.includes(fullText[end])) { end++; }
let textBefore = fullText.substring(0, start);
const textAfter = fullText.substring(end);
// 只对以分隔符开头的标签检查重叠(如 :> 这类表情符号)
if (cleanTag.length > 0 && BREAK_CHARS.includes(cleanTag[0])) {
for (let overlap = Math.min(textBefore.length, cleanTag.length); overlap > 0; overlap--) {
if (textBefore.endsWith(cleanTag.substring(0, overlap))) {
textBefore = textBefore.substring(0, textBefore.length - overlap);
break;
}
}
}
const newFullText = textBefore + cleanTag + textAfter;
let formattedText = formatPromptText(newFullText);
formattedText = formattedText.trim();
if (formattedText.length > 0 && !formattedText.endsWith(',')) {
formattedText += ', ';
} else if (formattedText.endsWith(',')) {
formattedText += ' ';
}
// 计算插入位置之前有多少个同名 tag
let countBefore = 0;
let searchPos = 0;
while ((searchPos = textBefore.indexOf(cleanTag, searchPos)) !== -1) {
// 确保是完整的 tag(前后是边界字符或字符串边界)
const charBefore = searchPos > 0 ? textBefore[searchPos - 1] : ',';
const charAfter = searchPos + cleanTag.length < textBefore.length ? textBefore[searchPos + cleanTag.length] : ',';
if ((BREAK_CHARS.includes(charBefore) || /\s/.test(charBefore)) &&
(BREAK_CHARS.includes(charAfter) || /\s/.test(charAfter))) {
countBefore++;
}
searchPos += cleanTag.length;
}
// 在格式化后的文本中找到第 countBefore+1 个同名 tag
let newCursorPos = -1;
let foundIndex = -1;
searchPos = 0;
for (let i = 0; i <= countBefore; i++) {
foundIndex = formattedText.indexOf(cleanTag, searchPos);
if (foundIndex === -1) break;
searchPos = foundIndex + cleanTag.length;
}
if (foundIndex !== -1) {
newCursorPos = foundIndex + cleanTag.length + 2;
} else {
newCursorPos = start + cleanTag.length + 2;
}
if (newCursorPos >= 2 && formattedText.substring(newCursorPos - 2, newCursorPos) === '::') {
newCursorPos -= 2;
}
newCursorPos = Math.min(newCursorPos, formattedText.length);
WeightShortcuts.updateInputContent(input, formattedText, newCursorPos, newCursorPos);
} else {
try {
if (!input.contains(workingRange.startContainer)) return;
let start = globalCursorPos;
while (start > 0 && !BREAK_CHARS.includes(fullText[start - 1])) { start--; }
let end = globalCursorPos;
while (end < fullText.length && !BREAK_CHARS.includes(fullText[end])) { end++; }
const { node: startNode, offset: startOffset } = findNodeAndOffsetFromGlobal(input, start);
const { node: endNode, offset: endOffset } = findNodeAndOffsetFromGlobal(input, end);
const replacementRange = document.createRange();
replacementRange.setStart(startNode, startOffset);
replacementRange.setEnd(endNode, endOffset);
replacementRange.deleteContents();
let textToInsert = cleanTag;
const textBefore = fullText.substring(0, start).trimEnd();
let prefix = '';
if (textBefore.length > 0) {
if (textBefore.endsWith(',')) {
prefix = ' ';
} else {
prefix = ', ';
}
}
textToInsert = prefix + textToInsert + ', ';
const newTextNode = document.createTextNode(textToInsert);
replacementRange.insertNode(newTextNode);
replacementRange.setStartAfter(newTextNode);
replacementRange.collapse(true);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(replacementRange);
const inputEvent = new Event('input', { bubbles: true, composed: true });
input.dispatchEvent(inputEvent);
} catch (error) {
console.error("[NAI Prompt Helper] An error occurred during surgical insertTag:", error);
}
}
window.__NAIWeightAdjustingUntil = Date.now() + 500;
hidePopup();
}
function createPopup() { if (!popup) { popup = document.createElement('div'); popup.className = 'autocomplete-container'; document.body.appendChild(popup); } }
function hidePopup() { if (popup) popup.style.display = 'none'; hideContextMenu(); if (apiAbortController) apiAbortController.abort(); selectedIndex = -1; isPopupActive = false; currentMatches = []; isKeyboardNavigation = false; }
let debounceTimeout = null;
function cancelPendingSuggestions() { if (debounceTimeout) { clearTimeout(debounceTimeout); debounceTimeout = null; } if (apiAbortController) apiAbortController.abort(); }
function getActiveInputElement() { const selection = window.getSelection(); if (!selection.rangeCount) return null; const node = selection.focusNode; const pElement = node.nodeType === 3 ? node.parentElement.closest('p') : node.closest('p'); if (pElement && (pElement.closest('[class*="prompt-input"]') || pElement.closest('[class*="character-prompt-input"]'))) { return pElement; } return null; }
function positionPopup(input) { let rect; const selection = window.getSelection(); if (selection.rangeCount > 0) { const range = selection.getRangeAt(0).cloneRange(); if (range.collapsed) { const tempSpan = document.createElement('span'); tempSpan.appendChild(document.createTextNode('\u200b')); range.insertNode(tempSpan); rect = tempSpan.getBoundingClientRect(); tempSpan.remove(); } else { rect = range.getBoundingClientRect(); } } if (!rect || (rect.width === 0 && rect.height === 0)) { rect = input.getBoundingClientRect(); } popup.style.top = `${rect.bottom + window.scrollY + 5}px`; popup.style.left = `${rect.left + window.scrollX}px`; }
function updateSelectionUI() { const items = popup.querySelectorAll('.suggestion-item'); items.forEach((item, index) => item.classList.toggle('selected', index === selectedIndex)); const selectedEl = popup.querySelector('.selected'); if (selectedEl) selectedEl.scrollIntoView({ block: 'nearest' }); }
function saveSelection() {
const selection = window.getSelection();
const activeInput = getActiveInputElement();
if (activeInput && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
if (activeInput.contains(range.commonAncestorContainer)) { lastKnownRange = range.cloneRange(); }
}
}
function handleInput() {
cancelPendingSuggestions();
const input = getActiveInputElement(); if (!input) { hidePopup(); return; }
const textBeforeCursor = (() => { const sel = window.getSelection(); if (!sel.rangeCount) return ''; const range = sel.getRangeAt(0).cloneRange(); const parent = sel.focusNode.parentElement; if (!parent) return ''; range.selectNodeContents(parent); range.setEnd(sel.focusNode, sel.focusOffset); return range.toString(); })();
if (textBeforeCursor.endsWith(',')) { hidePopup(); return; }
// 检测表情符号模式:以 : 或 ; 开头,可能后跟 _ < > 等符号(不含 |,NAI会转换)
const emoticonMatch = textBeforeCursor.match(/(?:^|[,\s\n\r{[\|])([;:][_<>\-\\\/;:]*?)$/);
if (emoticonMatch) {
const emoticonQuery = emoticonMatch[1];
debounceTimeout = setTimeout(() => {
const now = Date.now();
if (window.__NAIWeightAdjusting || now < window.__NAIWeightAdjustingUntil) { hidePopup(); return; }
// 表情符号查询:同时搜索 NAI 别名和 Danbooru API
fetchEmoticonSuggestions(emoticonQuery, input);
}, DEBOUNCE_DELAY_EMOTICON);
return;
}
debounceTimeout = setTimeout(() => {
const now = Date.now();
if (window.__NAIWeightAdjusting || now < window.__NAIWeightAdjustingUntil) { hidePopup(); return; }
const lastDelimiterIndex = Math.max(textBeforeCursor.lastIndexOf(','), textBeforeCursor.lastIndexOf(':'), textBeforeCursor.lastIndexOf('['), textBeforeCursor.lastIndexOf('{'), textBeforeCursor.lastIndexOf('|'));
const currentQuery = textBeforeCursor.substring(lastDelimiterIndex + 1).trim();
if (shouldSkipWeightSuggestions(textBeforeCursor, lastDelimiterIndex)) { hidePopup(); return; }
if (currentQuery.length < 1) { hidePopup(); return; }
if (!ALLOWED_CHARS_REGEX.test(currentQuery)) { hidePopup(); return; }
if (/^\d*\.?\d*$/.test(currentQuery)) { hidePopup(); return; }
if (/[\u4e00-\u9fa5]/.test(currentQuery)) { searchLocalSuggestions(currentQuery, input); } else { fetchSuggestions(currentQuery, input); }
}, DEBOUNCE_DELAY);
}
function handleKeydown(e) {
if (e.ctrlKey) { return; }
if (e.key === ',') { cancelPendingSuggestions(); hidePopup(); return; }
const keyMap = { 'ArrowDown': 1, 'ArrowRight': 1, 'ArrowUp': -1, 'ArrowLeft': -1 };
if (keyMap[e.key] !== undefined) { cancelPendingSuggestions(); }
if (!isPopupActive) return;
if (keyMap[e.key] !== undefined) {
e.preventDefault();
isKeyboardNavigation = true;
selectedIndex = (selectedIndex + keyMap[e.key] + currentMatches.length) % currentMatches.length;
updateSelectionUI();
} else if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault(); e.stopPropagation();
if (selectedIndex >= 0 && currentMatches[selectedIndex]) {
const input = getActiveInputElement();
if (input) {
const rangeForInsert = getClonedSelectionRange();
insertTag(input, currentMatches[selectedIndex].insertName, rangeForInsert);
}
} else { hidePopup(); }
} else if (e.key === 'Escape') { e.preventDefault(); hidePopup(); }
else { isKeyboardNavigation = false; }
}
function handleClickOutside(e) {
if (contextMenu && !contextMenu.contains(e.target)) { hideContextMenu(); }
const input = getActiveInputElement();
const clickedInPopup = popup && popup.contains(e.target);
const clickedInInput = input && input.contains(e.target);
const clickedInContext = contextMenu && contextMenu.contains(e.target);
if (!clickedInPopup && !clickedInInput && !clickedInContext) { cancelPendingSuggestions(); }
if (isPopupActive && popup && !clickedInPopup && !clickedInInput && !clickedInContext) { hidePopup(); }
}
function init() {
loadTranslations();
document.addEventListener('input', handleInput);
document.addEventListener('keydown', handleKeydown, true);
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keyup', saveSelection);
document.addEventListener('mouseup', saveSelection);
document.addEventListener('selectionchange', saveSelection);
}
return { init };
})();
const WeightShortcuts = (() => {
function getActiveInputElement() {
const selection = window.getSelection();
if (!selection.rangeCount) return null;
const node = selection.focusNode;
const pElement = node.nodeType === 3 ? node.parentElement.closest('p') : node.closest('p');
if (pElement && (pElement.closest('.prompt-input-box-prompt') || pElement.closest('.prompt-input-box-base-prompt') || pElement.closest('.prompt-input-box-negative-prompt') || pElement.closest('.prompt-input-box-undesired-content') || pElement.closest('[class*="character-prompt-input"]'))) {
return pElement;
}
return null;
}
function getSelectedTagInfo(inputElement) {
if (!inputElement) return null;
const selection = window.getSelection();
if (!selection.rangeCount) return null;
const range = selection.getRangeAt(0);
const fullText = inputElement.textContent || '';
// 辅助函数:计算节点在 inputElement 中的全局文本偏移量
function computeGlobalOffset(node, offset) {
let globalOffset = 0;
if (node.nodeType === 3) {
// 文本节点:遍历找到该节点前的所有文本长度
const treeWalker = document.createTreeWalker(inputElement, NodeFilter.SHOW_TEXT);
let currentNode;
while ((currentNode = treeWalker.nextNode())) {
if (currentNode === node) break;
globalOffset += currentNode.length;
}
globalOffset += offset;
} else if (node === inputElement || inputElement.contains(node)) {
// 元素节点:offset 是子节点索引,计算前面子节点的文本长度
const children = node.childNodes;
for (let i = 0; i < offset && i < children.length; i++) {
globalOffset += children[i].textContent?.length || 0;
}
// 如果 node 不是 inputElement,还需要加上 node 之前的文本长度
if (node !== inputElement) {
const treeWalker = document.createTreeWalker(inputElement, NodeFilter.SHOW_ALL);
let currentNode;
let foundNode = false;
let textBeforeNode = 0;
while ((currentNode = treeWalker.nextNode())) {
if (currentNode === node) { foundNode = true; break; }
if (currentNode.nodeType === 3) { textBeforeNode += currentNode.length; }
}
if (foundNode) globalOffset += textBeforeNode;
}
} else {
globalOffset = offset;
}
return Math.max(0, Math.min(globalOffset, fullText.length));
}
// 计算选区开始和结束位置
const globalStartOffset = computeGlobalOffset(range.startContainer, range.startOffset);
const globalEndOffset = computeGlobalOffset(range.endContainer, range.endOffset);
// 使用选区中间位置,更容易命中标签
const globalOffset = Math.floor((globalStartOffset + globalEndOffset) / 2);
const BOUNDARY_CHARS = new Set([',', '\n', '{', '}', '[', ']', '|']);
let start = globalOffset;
while (start > 0 && !BOUNDARY_CHARS.has(fullText[start - 1])) { start--; }
let end = globalOffset;
while (end < fullText.length && !BOUNDARY_CHARS.has(fullText[end])) { end++; }
if (BOUNDARY_CHARS.has(fullText[start])) start++;
if (end > 0 && BOUNDARY_CHARS.has(fullText[end - 1])) end--;
const tagText = fullText.slice(start, end).trim();
return tagText ? { tagText, start, end, fullText, cursorOffset: globalOffset } : null;
}
function updateInputContent(inputElement, newContent, selectionStart, selectionEnd, keepSelection = false) {
window.__NAIWeightAdjusting = true;
window.__NAIWeightAdjustingUntil = Date.now() + 500;
try {
if (inputElement.childNodes.length === 1 && inputElement.firstChild.nodeType === 3) {
inputElement.firstChild.textContent = newContent;
} else {
const newTextNode = document.createTextNode(newContent);
inputElement.innerHTML = '';
inputElement.appendChild(newTextNode);
}
const newRange = document.createRange();
const textLength = inputElement.firstChild.textContent.length;
if (keepSelection) {
const safeStart = Math.max(0, Math.min(textLength, selectionStart));
const safeEnd = Math.max(0, Math.min(textLength, selectionEnd));
newRange.setStart(inputElement.firstChild, safeStart);
newRange.setEnd(inputElement.firstChild, safeEnd);
} else {
const safeIndex = Math.max(0, Math.min(textLength, selectionEnd));
newRange.setStart(inputElement.firstChild, safeIndex);
newRange.setEnd(inputElement.firstChild, safeIndex);
}
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(newRange);
const inputEvent = new Event('input', { bubbles: true });
inputElement.dispatchEvent(inputEvent);
} finally { window.__NAIWeightAdjusting = false; }
}
function parsePromptStructure(text) {
return parseSequence(text, 0, false, 0).items;
}
function parseSequence(text, startIndex, stopAtClose, depth = 0) {
const MAX_DEPTH = 20;
if (depth > MAX_DEPTH) {
return { items: [], index: text.length };
}
const items = [];
let i = startIndex;
const readSeparator = (input, index, shouldStopAtClose) => {
let cursor = index; let separator = '';
while (cursor < input.length) {
if (shouldStopAtClose && input.startsWith('::', cursor)) { break; }
const ch = input[cursor];
if (ch === ',') { separator += ch; cursor++; while (cursor < input.length && input[cursor] === ' ') { separator += input[cursor]; cursor++; } }
else if (ch === '\n' || ch === '\r') { separator += ch; cursor++; }
else if (ch === ' ' || ch === '\t') { separator += ch; cursor++; }
else { break; }
}
return { separator, nextIndex: cursor };
};
while (i < text.length) {
if (stopAtClose && text.startsWith('::', i)) { return { items, index: i + 2, closeStart: i, closeEnd: i + 2 }; }
while (i < text.length && (text[i] === ' ' || text[i] === '\t')) { i++; }
if (stopAtClose && text.startsWith('::', i)) { return { items, index: i + 2, closeStart: i, closeEnd: i + 2 }; }
if (i >= text.length) { break; }
// 检查 || ... || 随机组
if (text.startsWith('||', i)) {
const randomStart = i;
i += 2;
let randomEnd = i;
while (randomEnd < text.length && !text.startsWith('||', randomEnd)) {
randomEnd++;
}
if (randomEnd < text.length) {
const randomContent = text.slice(i, randomEnd);
const childResult = parseSequence(randomContent, 0, false, depth + 1);
const randomNode = { type: 'random', content: randomContent, start: randomStart, contentStart: i, contentEnd: randomEnd, end: randomEnd + 2, children: childResult.items };
i = randomEnd + 2;
const separatorInfo = readSeparator(text, i, stopAtClose);
i = separatorInfo.nextIndex;
items.push({ node: randomNode, separator: separatorInfo.separator });
continue;
}
}
// 检查 { ... } 大括号权重
if (text[i] === '{') {
const braceStart = i;
i += 1;
let braceDepth = 1;
let braceEnd = i;
while (braceEnd < text.length && braceDepth > 0) {
if (text[braceEnd] === '{') braceDepth++;
else if (text[braceEnd] === '}') braceDepth--;
if (braceDepth > 0) braceEnd++;
}
if (braceDepth === 0) {
const braceContent = text.slice(i, braceEnd);
const childResult = parseSequence(braceContent, 0, false, depth + 1);
const braceNode = { type: 'brace', content: braceContent, start: braceStart, contentStart: i, contentEnd: braceEnd, end: braceEnd + 1, children: childResult.items };
i = braceEnd + 1;
const separatorInfo = readSeparator(text, i, stopAtClose);
i = separatorInfo.nextIndex;
items.push({ node: braceNode, separator: separatorInfo.separator });
continue;
}
}
// 检查 [ ... ] 方括号减权
if (text[i] === '[') {
const bracketStart = i;
i += 1;
let bracketDepth = 1;
let bracketEnd = i;
while (bracketEnd < text.length && bracketDepth > 0) {
if (text[bracketEnd] === '[') bracketDepth++;
else if (text[bracketEnd] === ']') bracketDepth--;
if (bracketDepth > 0) bracketEnd++;
}
if (bracketDepth === 0) {
const bracketContent = text.slice(i, bracketEnd);
const childResult = parseSequence(bracketContent, 0, false, depth + 1);
const bracketNode = { type: 'bracket', content: bracketContent, start: bracketStart, contentStart: i, contentEnd: bracketEnd, end: bracketEnd + 1, children: childResult.items };
i = bracketEnd + 1;
const separatorInfo = readSeparator(text, i, stopAtClose);
i = separatorInfo.nextIndex;
items.push({ node: bracketNode, separator: separatorInfo.separator });
continue;
}
}
const doubleColonMatch = text.slice(i).match(/^(-?\d+(?:\.\d+)?)(::)(?!:)/);
if (doubleColonMatch) {
const groupStart = i;
const weightString = doubleColonMatch[1];
const parsedWeight = parseFloat(weightString);
i += doubleColonMatch[0].length;
const childResult = parseSequence(text, i, true, depth + 1);
const groupNode = { type: 'group', weight: isNaN(parsedWeight) ? 1.0 : parsedWeight, format: 'doubleColon', start: groupStart, weightStart: groupStart, weightEnd: groupStart + weightString.length, prefixEnd: i, closeStart: childResult.closeStart ?? childResult.index, closeEnd: childResult.closeEnd ?? childResult.index, children: childResult.items };
i = childResult.index;
const separatorInfo = readSeparator(text, i, stopAtClose);
i = separatorInfo.nextIndex;
items.push({ node: groupNode, separator: separatorInfo.separator });
continue;
}
const groupMatch = text.slice(i).match(/^(-?\d+(?:\.\d+)?)(\s*):(?!:)/);
if (groupMatch) {
const groupStart = i;
const weightString = groupMatch[1];
const parsedWeight = parseFloat(weightString);
i += groupMatch[0].length;
const childResult = parseSequence(text, i, true, depth + 1);
const groupNode = { type: 'group', weight: isNaN(parsedWeight) ? 1.0 : parsedWeight, format: 'singleColon', start: groupStart, weightStart: groupStart, weightEnd: groupStart + weightString.length, prefixEnd: i, closeStart: childResult.closeStart ?? childResult.index, closeEnd: childResult.closeEnd ?? childResult.index, children: childResult.items };
i = childResult.index;
const separatorInfo = readSeparator(text, i, stopAtClose);
i = separatorInfo.nextIndex;
items.push({ node: groupNode, separator: separatorInfo.separator });
continue;
}
const tagRawStart = i;
while (i < text.length && text[i] !== ',' && text[i] !== '\n' && text[i] !== '\r' && text[i] !== '{' && text[i] !== '}' && text[i] !== '[' && text[i] !== ']' && text[i] !== '|') {
if (stopAtClose && text.startsWith('::', i)) { break; }
if (i === tagRawStart) {
const aheadMatch = text.slice(i).match(/^(-?\d+(?:\.\d+)?)(\s*):(?!:)/);
if (aheadMatch) { break; }
}
i++;
}
const rawSegment = text.slice(tagRawStart, i);
if (rawSegment.trim()) {
let leadingSpaces = 0;
while (leadingSpaces < rawSegment.length && /\s/.test(rawSegment[leadingSpaces])) { leadingSpaces++; }
let trailingSpaces = 0;
while (trailingSpaces < rawSegment.length - leadingSpaces && /\s/.test(rawSegment[rawSegment.length - 1 - trailingSpaces])) { trailingSpaces++; }
const contentStart = tagRawStart + leadingSpaces;
const contentEnd = i - trailingSpaces;
const tagText = text.slice(contentStart, contentEnd);
const tagNode = { type: 'tag', text: tagText, start: contentStart, end: contentEnd };
const separatorInfo = readSeparator(text, i, stopAtClose);
i = separatorInfo.nextIndex;
items.push({ node: tagNode, separator: separatorInfo.separator });
} else {
const separatorInfo = readSeparator(text, i, stopAtClose);
i = separatorInfo.nextIndex;
if (items.length) { items[items.length - 1].separator += separatorInfo.separator; }
if (i === tagRawStart && i < text.length) { i++; }
}
}
return { items, index: i };
}
function normalizeSequence(sequence) {
for (let i = 0; i < sequence.length; i++) {
sequence[i].separator = i < sequence.length - 1 ? ', ' : '';
if (sequence[i].node.type === 'group') { normalizeSequence(sequence[i].node.children); }
}
}
function normalizeTree(sequence) { normalizeSequence(sequence); }
function formatWeightValue(value) {
const rounded = Math.round(value * 20) / 20;
let str = rounded.toFixed(2);
return str.replace(/\.?0+$/, '');
}
function serializeTree(sequence) {
const state = { output: '', offset: 0 };
serializeSequence(sequence, state);
return state.output;
}
function serializeSequence(sequence, state) {
for (let i = 0; i < sequence.length; i++) {
serializeNode(sequence[i].node, state);
const sep = sequence[i].separator || '';
state.output += sep;
state.offset += sep.length;
}
}
function serializeNode(node, state) {
if (node.type === 'tag') {
node.serializeStart = state.offset;
node.serializeEnd = state.offset + node.text.length;
state.output += node.text;
state.offset += node.text.length;
} else if (node.type === 'brace') {
node.serializeStart = state.offset;
state.output += '{';
state.offset += 1;
state.output += node.content;
state.offset += node.content.length;
state.output += '}';
state.offset += 1;
node.serializeEnd = state.offset;
} else if (node.type === 'bracket') {
node.serializeStart = state.offset;
state.output += '[';
state.offset += 1;
state.output += node.content;
state.offset += node.content.length;
state.output += ']';
state.offset += 1;
node.serializeEnd = state.offset;
} else if (node.type === 'random') {
node.serializeStart = state.offset;
state.output += '||';
state.offset += 2;
state.output += node.content;
state.offset += node.content.length;
state.output += '||';
state.offset += 2;
node.serializeEnd = state.offset;
} else if (node.type === 'group') {
node.serializeStart = state.offset;
const weightStr = formatWeightValue(node.weight);
const format = node.format || 'singleColon';
if (format === 'doubleColon') {
state.output += `${weightStr}::`;
state.offset += weightStr.length + 2;
} else {
state.output += `${weightStr}:`;
state.offset += weightStr.length + 1;
}
const childrenStart = state.output.length;
serializeSequence(node.children, state);
// Fix: if content inside group ends with digits (as part of a tag name),
// NAI would misinterpret trailing digits + :: as a weight prefix.
// Insert a comma to prevent this, e.g. "style1999" -> "style1999," before "::"
const childrenContent = state.output.slice(childrenStart);
if (/\d$/.test(childrenContent) && /[a-zA-Z_]/.test(childrenContent)) {
state.output += ',';
state.offset += 1;
}
state.output += '::';
state.offset += 2;
node.serializeEnd = state.offset;
}
}
function findFirstTagInSequence(sequence, ancestors = [], depth = 0) {
const MAX_DEPTH = 20;
if (depth > MAX_DEPTH) return null;
for (let i = 0; i < sequence.length; i++) {
const item = sequence[i];
const node = item.node;
if (node.type === 'tag') {
return { item, sequence, index: i, ancestors };
} else if (node.type === 'group') {
const result = findFirstTagInSequence(node.children,
ancestors.concat({ groupNode: node, entry: item, parentSequence: sequence, index: i }), depth + 1);
if (result) return result;
} else if (node.type === 'brace') {
const result = findFirstTagInSequence(node.children,
ancestors.concat({ braceNode: node, entry: item, parentSequence: sequence, index: i }), depth + 1);
if (result) return result;
} else if (node.type === 'bracket') {
const result = findFirstTagInSequence(node.children,
ancestors.concat({ bracketNode: node, entry: item, parentSequence: sequence, index: i }), depth + 1);
if (result) return result;
} else if (node.type === 'random') {
const result = findFirstTagInSequence(node.children,
ancestors.concat({ randomNode: node, entry: item, parentSequence: sequence, index: i }), depth + 1);
if (result) return result;
}
}
return null;
}
function findTagNodeByOffset(sequence, offset, ancestors = [], depth = 0) {
const MAX_DEPTH = 20;
if (depth > MAX_DEPTH) {
return null;
}
for (let i = 0; i < sequence.length; i++) {
const item = sequence[i];
const node = item.node;
if (node.type === 'tag') {
if (node.start <= offset && offset <= node.end) { return { item, sequence, index: i, ancestors }; }
} else if (node.type === 'brace') {
if (node.start <= offset && offset <= node.end) {
const childOffset = offset - node.contentStart;
const result = findTagNodeByOffset(node.children, childOffset, ancestors.concat({ braceNode: node, entry: item, parentSequence: sequence, index: i }), depth + 1);
if (result) return result;
}
} else if (node.type === 'bracket') {
if (node.start <= offset && offset <= node.end) {
const childOffset = offset - node.contentStart;
const result = findTagNodeByOffset(node.children, childOffset, ancestors.concat({ bracketNode: node, entry: item, parentSequence: sequence, index: i }), depth + 1);
if (result) return result;
}
} else if (node.type === 'random') {
if (node.start <= offset && offset <= node.end) {
const childOffset = offset - node.contentStart;
const result = findTagNodeByOffset(node.children, childOffset, ancestors.concat({ randomNode: node, entry: item, parentSequence: sequence, index: i }), depth + 1);
if (result) return result;
}
} else if (node.type === 'group') {
const groupEnd = node.closeEnd ?? node.closeStart ?? Infinity;
const newAncestors = ancestors.concat({ groupNode: node, entry: item, parentSequence: sequence, index: i });
// 先尝试精确匹配
const result = findTagNodeByOffset(node.children, offset, newAncestors, depth + 1);
if (result) { return result; }
// 如果在 group 范围内但没有精确匹配,返回第一个 tag
if (node.start <= offset && offset <= groupEnd) {
const firstTag = findFirstTagInSequence(node.children, newAncestors, depth + 1);
if (firstTag) return firstTag;
}
}
}
return null;
}
function removeGroupWrapper(groupInfo) {
const { parentSequence, index, groupNode } = groupInfo;
const children = groupNode.children;
parentSequence.splice(index, 1, ...children);
}
function wrapTagWithGroup(target, newWeight) {
const { sequence, index, item } = target;
const tagNode = item.node, originalSeparator = item.separator || '';
sequence.splice(index, 1);
const groupNode = { type: 'group', weight: newWeight, format: 'doubleColon', children: [{ node: tagNode, separator: '' }] };
sequence.splice(index, 0, { node: groupNode, separator: originalSeparator });
return groupNode;
}
function adjustWeightForTag(target, direction) {
const step = 0.05;
const tagNode = target.item.node;
const ancestors = target.ancestors;
// 找到最近的 group 祖先(忽略 brace 和 random)
let groupAncestorIndex = -1;
for (let i = ancestors.length - 1; i >= 0; i--) {
if (ancestors[i].groupNode) {
groupAncestorIndex = i;
break;
}
}
if (groupAncestorIndex >= 0) {
const outerGroupInfo = ancestors[groupAncestorIndex];
const groupNode = outerGroupInfo.groupNode;
let newWeight = groupNode.weight + (direction * step);
newWeight = Math.round(newWeight * 20) / 20;
if (Math.abs(newWeight - 1.0) < 0.001) {
removeGroupWrapper(outerGroupInfo);
return { tagNode, wrapperNode: null };
}
else {
groupNode.weight = newWeight;
return { tagNode, wrapperNode: groupNode };
}
}
let newWeight = 1.0 + (direction * step);
newWeight = Math.round(newWeight * 20) / 20;
if (Math.abs(newWeight - 1.0) < 0.001) { return { tagNode, wrapperNode: null }; }
const newGroupNode = wrapTagWithGroup(target, newWeight);
return { tagNode, wrapperNode: newGroupNode };
}
function resolveMovementContext(target) {
let { sequence, index, item, ancestors } = target;
let movedAsGroup = false;
if (ancestors.length) {
const immediate = ancestors[ancestors.length - 1];
const immediateNode = immediate.groupNode || immediate.braceNode || immediate.bracketNode || immediate.randomNode;
if (immediateNode && immediateNode.children && immediateNode.children.length === 1) {
movedAsGroup = true;
sequence = immediate.parentSequence;
index = immediate.index;
item = immediate.entry;
ancestors = ancestors.slice(0, -1);
}
}
return { sequence, index, item, ancestors, movedAsGroup };
}
function moveTagWithinStructure(target, direction) {
const context = resolveMovementContext(target);
const { sequence, index, item, ancestors } = context;
const isOriginalWeighted = target.ancestors.length > 0;
if (direction === -1) {
if (index > 0) {
const prevItem = sequence[index - 1];
if (!isOriginalWeighted && prevItem.node.type === 'group') {
sequence.splice(index, 1);
item.separator = '';
prevItem.node.children.push(item);
return true;
}
sequence[index - 1] = item;
sequence[index] = prevItem;
return true;
}
if (!ancestors.length) { return false; }
const parentContext = ancestors[ancestors.length - 1];
const { parentSequence, index: parentIndex } = parentContext;
sequence.splice(index, 1);
if (sequence.length === 0) {
parentSequence.splice(parentIndex, 1);
parentSequence.splice(parentIndex, 0, item);
} else { parentSequence.splice(parentIndex, 0, item); }
return true;
}
if (direction === 1) {
if (index < sequence.length - 1) {
const nextItem = sequence[index + 1];
if (!isOriginalWeighted && nextItem.node.type === 'group') {
sequence.splice(index, 1);
item.separator = '';
nextItem.node.children.unshift(item);
return true;
}
sequence[index + 1] = item;
sequence[index] = nextItem;
return true;
}
if (!ancestors.length) { return false; }
const parentContext = ancestors[ancestors.length - 1];
const { parentSequence, index: parentIndex } = parentContext;
sequence.splice(index, 1);
if (sequence.length === 0) {
parentSequence.splice(parentIndex, 1);
parentSequence.splice(parentIndex, 0, item);
} else { parentSequence.splice(parentIndex + 1, 0, item); }
return true;
}
return false;
}
function handleKeydown(event) {
const inputElement = getActiveInputElement();
if (!inputElement || !event.ctrlKey) return;
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
event.preventDefault(); event.stopPropagation();
const tagInfo = getSelectedTagInfo(inputElement); if (!tagInfo) return;
const direction = (event.key === 'ArrowUp') ? 1 : -1;
const leadingWhitespace = tagInfo.fullText.slice(tagInfo.start, tagInfo.end).match(/^\s*/)[0];
const structure = parsePromptStructure(tagInfo.fullText);
const offsetForSearch = tagInfo.cursorOffset ?? (tagInfo.start + leadingWhitespace.length);
const target = findTagNodeByOffset(structure, offsetForSearch); if (!target) { return; }
const { tagNode, wrapperNode } = adjustWeightForTag(target, direction);
normalizeTree(structure);
const serialized = serializeTree(structure);
let selectionStart, selectionEnd;
if (keepSelectionAfterAdjustment) {
if (wrapperNode) {
selectionStart = wrapperNode.serializeStart;
selectionEnd = wrapperNode.serializeEnd;
} else {
selectionStart = tagNode.serializeStart ?? (tagInfo.start + leadingWhitespace.length);
selectionEnd = tagNode.serializeEnd ?? (selectionStart + tagNode.text.length);
}
} else {
selectionStart = tagNode.serializeEnd ?? (tagInfo.start + leadingWhitespace.length + tagNode.text.length);
selectionEnd = selectionStart;
}
updateInputContent(inputElement, serialized, selectionStart, selectionEnd, keepSelectionAfterAdjustment);
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
event.preventDefault(); event.stopPropagation();
const tagInfo = getSelectedTagInfo(inputElement); if (!tagInfo) return;
const direction = (event.key === 'ArrowLeft') ? -1 : 1;
const leadingWhitespace = tagInfo.fullText.slice(tagInfo.start, tagInfo.end).match(/^\s*/)[0];
const structure = parsePromptStructure(tagInfo.fullText);
const offsetForSearch = tagInfo.cursorOffset ?? (tagInfo.start + leadingWhitespace.length);
const target = findTagNodeByOffset(structure, offsetForSearch); if (!target) { return; }
const tagNode = target.item.node;
if (!moveTagWithinStructure(target, direction)) { return; }
normalizeTree(structure);
const serialized = serializeTree(structure);
let selectionStart, selectionEnd;
if (keepSelectionAfterAdjustment) {
selectionStart = tagNode.serializeStart ?? (tagInfo.start + leadingWhitespace.length);
selectionEnd = tagNode.serializeEnd ?? (selectionStart + tagNode.text.length);
} else {
selectionStart = tagNode.serializeEnd ?? (tagInfo.start + leadingWhitespace.length + tagNode.text.length);
selectionEnd = selectionStart;
}
updateInputContent(inputElement, serialized, selectionStart, selectionEnd, keepSelectionAfterAdjustment);
}
}
function init() {
const checkInterval = setInterval(() => {
const inputElement = getActiveInputElement();
if (inputElement) { clearInterval(checkInterval); document.addEventListener('keydown', handleKeydown, true); }
}, 500);
}
return { init, parsePromptStructure, normalizeTree, serializeTree, updateInputContent };
})();
function init() {
TagAssist.init();
WeightShortcuts.init();
}
if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", init); }
else { init(); }
})();