Compare commits

...
12 Commits
15 changed files with 2007 additions and 771 deletions
+2 -2
View File
@@ -12,9 +12,9 @@ jobs:
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: '18'
node-version: '22'
cache: 'npm'
- name: Install dependencies
+3 -2
View File
@@ -133,6 +133,7 @@ When the browser is reloaded, you can check the list of loaded CSV files in the
- **Enable Autocomplete**: Enable/disable the autocomplete feature.
- **Max suggestions**: Maximum number of autocomplete suggestions to display.
- **Replace '_' with 'Space'**: Replaces underscores with spaces when inserting tags. This setting also affects related tag display.
- **Enable Loras and Embeddings**: Display Lora and Embedding in the suggestions.
- **Use Fast Search**: Switch autocomplete suggestions search to fast processing (see [About Fast Search for Autocomplete](#about-fast-search-for-autocomplete) for details).
@@ -143,9 +144,9 @@ When the browser is reloaded, you can check the list of loaded CSV files in the
- **Default Display Position**: Default display position when ComfyUI starts.
- **Related Tags Trigger Mode**: Which action will trigger displaying related tags (click only, Ctrl+click)
### Miscellaneous
### Display
- **Check CSV updates**: Click the "Check Now" button to check if new CSV files are available in HuggingFace and download them if necessary.
- **Hide Alias**: Hide/show the Alias ​​column in autocomplete and related tags (default is show)
## Advanced Settings
+3 -2
View File
@@ -131,6 +131,7 @@ worst_quality,5,9999999,
- **Enable Autocomplete**: オートコンプリート機能の有効化/無効化
- **Max Suggestions**: オートコンプリート候補の最大表示件数
- **Replace '_' with 'Space'**: タグ挿入時にアンダースコアをスペースに置き換えます。この設定は関連タグ表示にも影響します
- **Enable Loras and Embeddings**: LoraとEmbeddingを候補に表示する
- **Use Fast Search**: オートコンプリート候補の検索を高速な処理に切り替える(詳細は [オートコンプリートの高速検索について](#オートコンプリートの高速検索について) を確認してください)
@@ -141,9 +142,9 @@ worst_quality,5,9999999,
- **Default Display Position**: ComfyUI起動時のデフォルト表示位置
- **Related Tags Trigger Mode** : 関連タグを表示する際、どの操作をトリガーとするか(クリックのみ、Ctrl+クリック)
### その他
### 表示
- **Check CSV updates**: 「Check Now」ボタンを押すと新しい CSV ファイルがHuggingFaceにあるか確認し、必要に応じてダウンロードを行います
- **Hide Alias**: オートコンプリートと関連タグで表示されるエイリアス列の非表示/表示を切り替え(デフォルトは表示です)
## 上級者向け設定
+1501 -739
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -6,9 +6,9 @@
"devDependencies": {
"@babel/core": "^7.27.1",
"@babel/preset-env": "^7.27.2",
"babel-jest": "^29.7.0",
"babel-jest": "^30.2.0",
"env-cmd": "^10.1.0",
"jest": "^29.7.0",
"jest": "^30.2.0",
"stylelint": "^16.19.1",
"stylelint-config-idiomatic-order": "^10.0.0",
"stylelint-config-standard": "^38.0.0"
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "comfyui-autocomplete-plus"
description = "Autocomplete and Related Tag display for ComfyUI"
version = "1.5.0"
version = "1.6.0"
license = {file = "LICENSE"}
dependencies = ["",]
+378
View File
@@ -0,0 +1,378 @@
import {
__test__
} from "../../web/js/autocomplete.js";
import {
TagData,
TagSource,
ModelTagSource,
autoCompleteData
} from "../../web/js/data.js";
import {
createFlexSearchDocument,
createFlexSearchDocumentForModel
} from "../../web/js/searchengine.js"
const {
searchCompletionCandidates,
sequentialSearch,
searchWithFlexSearch,
matchWord,
getCurrentPartialTag,
insertTagToTextArea
} = __test__;
// Helper function to create mock textarea element
function createMockTextarea(value, selectionStart, selectionEnd = -1) {
return {
value: value,
selectionStart: selectionStart,
selectionEnd: selectionEnd > selectionStart ? selectionEnd : selectionStart,
nodeName: 'TEXTAREA',
focus() { document.setFocus(this); },
setSelectionRange: () => { },
getBoundingClientRect: () => ({
top: 0,
left: 0,
width: 300,
height: 100
}),
scrollTop: 0,
scrollLeft: 0,
ownerDocument: {
defaultView: typeof window !== 'undefined' ? window : {},
documentElement: typeof document !== 'undefined' ? document.documentElement : {}
},
dispatchEvent: () => { }
};
}
// Test data setup function
function setupTestData() {
// Clear existing data
Object.keys(autoCompleteData).forEach(key => delete autoCompleteData[key]);
// Create mock AutocompleteData structure for each source
const sources = [...Object.values(TagSource), ...Object.values(ModelTagSource)];
sources.forEach(source => {
autoCompleteData[source] = {
flexSearchDocument: null,
sortedTags: [],
tagMap: new Map(),
aliasMap: new Map(),
cooccurrenceMap: new Map(),
isInitializing: false,
initialized: true,
baseLoadingProgress: {
cooccurrence: 0
}
};
});
// Add sample tag data for testing
const sampleTags = [
new TagData('1girl', 0, 1000000, ['one_girl'], TagSource.Danbooru),
new TagData('blue_hair', 0, 500000, ['blue hair'], TagSource.Danbooru),
new TagData('long_hair', 0, 800000, [], TagSource.Danbooru),
new TagData('__wildcard__', 0, 100, [], TagSource.Danbooru),
new TagData(':d', 0, 50000, [], TagSource.Danbooru),
new TagData('test_tag', 0, 1000, ['test'], TagSource.E621),
new TagData('<lora:test_model>', 0, 0, [], ModelTagSource.Lora),
new TagData('embedding:test_embedding', 0, 0, [], ModelTagSource.Embeddings)
];
// Distribute tags across sources
sampleTags.forEach(tagData => {
const source = tagData.source;
autoCompleteData[source].sortedTags.push(tagData);
autoCompleteData[source].tagMap.set(tagData.tag, tagData);
// Add aliases to alias map
if (tagData.alias && Array.isArray(tagData.alias)) {
tagData.alias.forEach(alias => {
autoCompleteData[source].aliasMap.set(alias, tagData.tag);
});
}
});
// Sort tags and build flexsearch index
sources.forEach(source => {
autoCompleteData[source].sortedTags.sort((a, b) => b.count - a.count);
const isModelSrc = Object.values(ModelTagSource).includes(source);
const doc = isModelSrc ? createFlexSearchDocumentForModel() : createFlexSearchDocument();
autoCompleteData[source].sortedTags.forEach((tagData, i) => {
doc.add(i, tagData);
});
autoCompleteData[source].flexSearchDocument = doc;
});
}
describe('Autocomplete Functions', () => {
beforeEach(() => {
setupTestData();
// Mock global document
global.document = {
execCommand: (commandId, showUI, value) => {
switch (commandId) {
case "insertText":
if (document.focusedElement) {
document.focusedElement.value += value;
}
break;
default:
throw new Error('Not implemented!');
}
},
createElement: () => ({
id: '',
style: {},
innerHTML: '',
appendChild: () => { },
getBoundingClientRect: () => ({
top: 0,
left: 0,
width: 300,
height: 100
})
}),
setFocus: (element) => {
document.focusedElement = element;
},
body: {
appendChild: () => { },
removeChild: () => { }
},
focusedElement: null
};
// Mock global window
global.window = {
getComputedStyle: () => ({
lineHeight: '20px',
fontSize: '14px',
fontFamily: 'Arial'
})
};
});
describe('matchWord', () => {
test('should match exact queries', () => {
const queries = new Set(['1girl', '1girls']);
const result = matchWord('1girl', queries);
expect(result.matched).toBe(true);
expect(result.isExactMatch).toBe(true);
});
test('should match partial queries', () => {
const queries = new Set(['girl']);
const result = matchWord('1girl', queries);
expect(result.matched).toBe(true);
expect(result.isExactMatch).toBe(false);
});
test('should handle wildcard prefixes', () => {
const queries = new Set(['__wild']);
const result = matchWord('__wildcard__', queries);
expect(result.matched).toBe(true);
expect(result.isExactMatch).toBe(false);
});
test('should handle symbol-only queries', () => {
const queries = new Set([':d']);
const result = matchWord(':d', queries);
expect(result.matched).toBe(true);
expect(result.isExactMatch).toBe(true);
});
test('should match after removing common symbols', () => {
const queries = new Set(['blue hair']);
const result = matchWord('blue_hair', queries);
expect(result.matched).toBe(true);
expect(result.isExactMatch).toBe(false);
});
test('should not match unrelated queries', () => {
const queries = new Set(['test']);
const result = matchWord('1girl', queries);
expect(result.matched).toBe(false);
expect(result.isExactMatch).toBe(false);
});
});
describe('getCurrentPartialTag', () => {
test('should extract partial tag before cursor', () => {
const textarea = createMockTextarea('1girl, blue_hair', 14);
const result = getCurrentPartialTag(textarea);
expect(typeof result).toBe('string');
expect(result).toBe('blue_ha');
});
test('should return full tag when cursor is at the end', () => {
const textarea = createMockTextarea('1girl', 5);
const result = getCurrentPartialTag(textarea);
expect(typeof result).toBe('string');
expect(result).toBe('1girl');
});
test('should handle empty textarea', () => {
const textarea = createMockTextarea('', 0);
const result = getCurrentPartialTag(textarea);
expect(typeof result).toBe('string');
expect(result).toBe('');
});
test('should handle newline separators', () => {
const textarea = createMockTextarea('1girl\nblue_h', 12);
const result = getCurrentPartialTag(textarea);
expect(typeof result).toBe('string');
expect(result).toBe('blue_h');
});
});
describe('searchCompletionCandidates', () => {
test('should return an empty array for empty input', () => {
const textarea = createMockTextarea('', 0);
const results = searchCompletionCandidates(textarea);
expect(Array.isArray(results)).toBe(true);
expect(results.length).toBe(0);
});
test('should return an empty array for null input', () => {
const results = searchCompletionCandidates(null);
expect(Array.isArray(results)).toBe(true);
expect(results.length).toBe(0);
});
test('should return an empty array for undefined input', () => {
const results = searchCompletionCandidates(undefined);
expect(Array.isArray(results)).toBe(true);
expect(results.length).toBe(0);
});
test('should return multiple candidates for valid input', () => {
const textarea = createMockTextarea('test', 4);
const results = searchCompletionCandidates(textarea);
expect(Array.isArray(results)).toBe(true);
expect(results.map(tagData => tagData.tag))
.toEqual(expect.arrayContaining([
'test_tag', '<lora:test_model>', 'embedding:test_embedding'
]));
});
test('should find wildcard tag', () => {
const textarea = createMockTextarea('__wild', 6);
const results = searchCompletionCandidates(textarea);
expect(Array.isArray(results)).toBe(true);
expect(results.map(tagData => tagData.tag))
.toEqual(expect.arrayContaining(['__wildcard__']));
});
test('should find emoticon tag', () => {
const textarea = createMockTextarea(':d', 2);
const results = searchCompletionCandidates(textarea);
expect(Array.isArray(results)).toBe(true);
expect(results.map(tagData => tagData.tag))
.toEqual(expect.arrayContaining([':d']));
});
});
describe('sequentialSearch', () => {
test('should find and return matching tags', () => {
const partialTag = 'test';
const queryVariations = new Set([partialTag.toLowerCase()]);
const results = sequentialSearch(partialTag, queryVariations);
expect(Array.isArray(results)).toBe(true);
expect(results.map(tagData => tagData.tag))
.toEqual(expect.arrayContaining([
'test_tag', '<lora:test_model>', 'embedding:test_embedding'
]));
});
});
describe('searchWithFlexSearch', () => {
test('should find and return matching tags using FlexSearch', () => {
const partialTag = 'test';
const queryVariations = new Set([partialTag.toLowerCase()]);
const results = searchWithFlexSearch(partialTag, queryVariations);
expect(Array.isArray(results)).toBe(true);
expect(results.map(tagData => tagData.tag))
.toEqual(expect.arrayContaining([
'test_tag', '<lora:test_model>', 'embedding:test_embedding'
]));
});
});
describe('insertTagToTextArea', () => {
test('should insert tag and replace underscore with space', () => {
const textarea = createMockTextarea('1girl, ', 7);
const tagData = { tag: 'blue_hair', source: 'danbooru' };
expect(() => {
insertTagToTextArea(textarea, tagData);
}).not.toThrow();
expect(textarea.value).toBe('1girl, blue hair, ');
});
test('should complete model tag and add trailing comma', () => {
const textarea = createMockTextarea('<lora:', 6);
const tagData = { tag: '<lora:my_model>', source: 'lora' };
expect(() => {
insertTagToTextArea(textarea, tagData);
}).not.toThrow();
expect(textarea.value).toBe('<lora:my_model>, ');
});
test('should handle wildcard tag insertion', () => {
const textarea = createMockTextarea('__wild', 6);
const tagData = { tag: '__wildcard__', source: 'danbooru' };
insertTagToTextArea(textarea, tagData);
expect(textarea.value).toBe('__wildcard__, ');
});
test('should handle emoticon tag insertion', () => {
const textarea = createMockTextarea(':d', 2);
const tagData = { tag: ':d', source: 'danbooru' };
insertTagToTextArea(textarea, tagData);
expect(textarea.value).toBe(':d, ');
});
});
});
+58
View File
@@ -1,3 +1,4 @@
import { settingValues } from '../../web/js/settings.js';
import {
extractTagsFromTextArea,
normalizeTagToSearch,
@@ -135,6 +136,11 @@ describe('normalizeTagToSearch', () => {
});
describe('normalizeTagToInsert', () => {
const originalValue = settingValues.replaceUnderscoreWithSpace;
afterEach(() => {
settingValues.replaceUnderscoreWithSpace = originalValue;
});
test('should return null or empty string for invalid inputs', () => {
expect(normalizeTagToInsert(null)).toBeNull();
expect(normalizeTagToInsert(undefined)).toBeUndefined();
@@ -167,6 +173,58 @@ describe('normalizeTagToInsert', () => {
expect(normalizeTagToInsert('year:2000')).toBe('year:2000');
expect(normalizeTagToInsert('foo:bar')).toBe('foo:bar');
});
test('should respect replaceUnderscoreWithSpace setting when true', () => {
settingValues.replaceUnderscoreWithSpace = true;
expect(normalizeTagToInsert('blue_hair')).toBe('blue hair');
expect(normalizeTagToInsert('red_eyes')).toBe('red eyes');
expect(normalizeTagToInsert('long_curly_hair')).toBe('long curly hair');
});
test('should respect replaceUnderscoreWithSpace setting when false', () => {
settingValues.replaceUnderscoreWithSpace = false;
expect(normalizeTagToInsert('blue_hair')).toBe('blue_hair');
expect(normalizeTagToInsert('red_eyes')).toBe('red_eyes');
expect(normalizeTagToInsert('long_curly_hair')).toBe('long_curly_hair');
});
test('should respect replaceUnderscoreWithSpace setting with parentheses', () => {
settingValues.replaceUnderscoreWithSpace = true;
expect(normalizeTagToInsert('blue_hair(style)')).toBe('blue hair\\(style\\)');
settingValues.replaceUnderscoreWithSpace = false;
expect(normalizeTagToInsert('blue_hair(style)')).toBe('blue_hair\\(style\\)');
settingValues.replaceUnderscoreWithSpace = originalValue;
});
test('should not replace underscores in wildcard syntax regardless of setting', () => {
const originalValue = settingValues.replaceUnderscoreWithSpace;
// Wildcard syntax should preserve underscores regardless of setting
settingValues.replaceUnderscoreWithSpace = true;
expect(normalizeTagToInsert('__wildcard__')).toBe('__wildcard__');
settingValues.replaceUnderscoreWithSpace = false;
expect(normalizeTagToInsert('__wildcard__')).toBe('__wildcard__');
settingValues.replaceUnderscoreWithSpace = originalValue;
});
test('should not replace underscores in symbol-only tags regardless of setting', () => {
const originalValue = settingValues.replaceUnderscoreWithSpace;
// Symbol-only tags should not be affected by the setting
settingValues.replaceUnderscoreWithSpace = true;
expect(normalizeTagToInsert('^_^')).toBe('^_^');
settingValues.replaceUnderscoreWithSpace = false;
expect(normalizeTagToInsert('^_^')).toBe('^_^');
settingValues.replaceUnderscoreWithSpace = originalValue;
});
});
describe('getCurrentTagRange', () => {
+33 -1
View File
@@ -259,6 +259,10 @@ function searchWithFlexSearch(partialTag, queryVariations) {
* @returns {string} The current partial tag.
*/
function getCurrentPartialTag(inputElement) {
if (!inputElement) {
return "";
}
const text = inputElement.value;
const cursorPos = inputElement.selectionStart;
@@ -304,10 +308,25 @@ function getCurrentPartialTag(inputElement) {
* @param {TagData} tagDataToInsert The raw tag string to insert.
*/
function insertTagToTextArea(inputElement, tagDataToInsert) {
if (!inputElement || !tagDataToInsert) {
return;
}
const text = inputElement.value;
const cursorPos = inputElement.selectionStart;
const { start: tagStart, end: tagEnd, tag: currentTag } = getCurrentTagRange(text, cursorPos);
const tagRange = getCurrentTagRange(text, cursorPos);
let tagStart, tagEnd, currentTag;
if (!tagRange) {
// Fallback: insert at cursor position
tagStart = cursorPos;
tagEnd = cursorPos;
currentTag = '';
} else {
({ start: tagStart, end: tagEnd, tag: currentTag } = tagRange);
}
const replaceStart = Math.min(cursorPos, tagStart);
let replaceEnd = cursorPos;
@@ -1025,3 +1044,16 @@ export class AutocompleteEventHandler {
handleClick(event) {
}
}
// Export functions for testing
const isTestEnvironment = typeof process !== 'undefined' && process.env.NODE_ENV === 'test';
export const __test__ = isTestEnvironment
? {
searchCompletionCandidates,
sequentialSearch,
searchWithFlexSearch,
matchWord,
getCurrentPartialTag,
insertTagToTextArea
}
: undefined;
+8 -12
View File
@@ -184,18 +184,8 @@ async function loadTags(csvUrl, siteName) {
updateMaxTagLength(tag.length);
autoCompleteData[siteName].sortedTags.push(tagData);
} else {
console.warn(`[Autocomplete-Plus] Invalid CSV format in line ${i + 1} of ${csvUrl}: ${line}. Expected ${TAGS_CSV_HEADER_COLUMNS.length} columns, but got ${columns.length}.`);
continue;
}
}
// Sort by count in descending order
autoCompleteData[siteName].sortedTags.sort((a, b) => b.count - a.count);
// Build maps as before, but ensure not to overwrite if already processed from extra files
autoCompleteData[siteName].sortedTags.forEach(tagData => {
if (!autoCompleteData[siteName].tagMap.has(tagData.tag)) {
// Set the tag and its alias in the maps
autoCompleteData[siteName].tagMap.set(tagData.tag, tagData);
if (tagData.alias && Array.isArray(tagData.alias)) {
tagData.alias.forEach(alias => {
@@ -204,8 +194,11 @@ async function loadTags(csvUrl, siteName) {
}
});
}
} else {
console.warn(`[Autocomplete-Plus] Invalid CSV format in line ${i + 1} of ${csvUrl}: ${line}. Expected ${TAGS_CSV_HEADER_COLUMNS.length} columns, but got ${columns.length}.`);
continue;
}
});
}
} catch (error) {
console.error(`[Autocomplete-Plus] Failed to fetch or process tags from ${csvUrl}:`, error);
@@ -450,6 +443,9 @@ async function initializeDataFromCSV(csvListData, source) {
await Promise.all([
Promise.all(tagsLoadPromiseFactories.map(factory => factory()))
.then(() => {
// Sort by count in descending order
autoCompleteData[source].sortedTags.sort((a, b) => b.count - a.count);
// Build FlexSearch index after tags are loaded
return buildFlexSearchIndex(source);
})
+12
View File
@@ -282,6 +282,7 @@ app.registerExtension({
settingValues.tagSource = newVal;
}
},
// --- Autocomplete Settings ---
{
id: id + ".use_fast_search",
@@ -305,6 +306,17 @@ app.registerExtension({
settingValues.enableModels = newVal;
}
},
{
id: id + ".replace_underscore_with_space",
name: "Replace '_' with 'Space'",
tooltip: "This setting also affects related tags display.",
type: "boolean",
defaultValue: true,
category: [name, "Autocompletion", "Replace Underscore with Space"],
onChange: (newVal, oldVal) => {
settingValues.replaceUnderscoreWithSpace = newVal;
}
},
{
id: id + ".max_suggestions",
name: "Max suggestions",
+2 -7
View File
@@ -411,13 +411,8 @@ class RelatedTagsUI {
.find((tagData) => tagData !== undefined);
if (!tagData) {
tagData = new TagData({
tag: this.currentTag,
source: TagSource.Danbooru,
category: 'unknown',
count: 0,
alias: [],
});
// Create a dummy TagData if not found
tagData = new TagData(this.currentTag, null, 0, [], TagSource.Danbooru);
}
const categoryText = TagCategory[tagData.source][tagData.category] || "unknown";
+1 -1
View File
@@ -14,7 +14,7 @@ function createTagEncoder() {
cache: true,
// filter: new Set(['and', 'to', 'be', 'on']),
replacer: [/(?<=[a-zA-Z\)])_$/, ''], // Remove trailing underscores after letters/parentheses
split: /(?<=[a-zA-Z\)])_(?=[a-zA-Z\(])|\((?=[a-zA-Z])|(?<=[a-zA-Z\)])\)|[ \n]/ // Split on underscores between words, parentheses, spaces, and newlines
split: /(?<=[a-zA-Z\)])_(?=[a-zA-Z\(])|\((?=[a-zA-Z])|(?<=[a-zA-Z\)])\)|[ \-\n]/ // Split on underscores between words, parentheses, spaces, hyphens, and newlines
});
}
+1
View File
@@ -9,6 +9,7 @@ export const settingValues = {
maxSuggestions: 10,
enableModels: true, // Enable Lora and Embedding suggestions
useFastSearch: false,
replaceUnderscoreWithSpace: true, // Replace underscores with spaces in tag insertion
// Related tags feature settings
enableRelatedTags: true,
+2 -2
View File
@@ -232,8 +232,8 @@ export function normalizeTagToInsert(str) {
if (isContainsLetterOrNumber(str)) {
const isWildcardCall = str.startsWith('__') && str.endsWith('__') && str.length > 4;
if (!isWildcardCall) {
// If doesn't wildcard call, replace underscores with spaces
if (!isWildcardCall && settingValues.replaceUnderscoreWithSpace) {
// If doesn't wildcard call and setting is enabled, replace underscores with spaces
return escapeParentheses(str.replace(/_/g, " "));
}
}