Merge pull request #98 from ComfyAssets/feat/shared-embedding-autocomplete

feat(autocomplete): unify UI and restore tag management
This commit is contained in:
Vito
2026-10-16 15:38:45 -07:00
committed by GitHub
25 changed files with 1717 additions and 617 deletions
+5 -5
View File
@@ -477,8 +477,8 @@ This feature is an enhanced fork of the autocomplete functionality from [ComfyUI
**Key Features:**
- **Smart Triggers**: Type `embedding:` for embeddings, `<lora:` for LoRAs, or just start typing for tags
- **Custom Word Lists**: Load tag databases (like Danbooru tags) from any URL
- **Security First**: Comprehensive input validation prevents code injection and XSS attacks
- **Custom Word Lists**: Import CSV/TXT files or browser-accessible URLs, including [versioned Danbooru tag packs](docs/tag-packs/README.md), with local offline caching
- **Safe Rendering**: Tag data stays plain text, with validated CSV fields and file-size limits
- **Flexible Settings**: Customize triggers, auto-insert commas, replace underscores, and more
- **Performance Optimized**: Handles 100,000+ tags smoothly with frequency-based sorting
- **Visual Polish**: Clean UI with proper scrolling, keyboard navigation, and type indicators
@@ -492,9 +492,9 @@ This feature is an enhanced fork of the autocomplete functionality from [ComfyUI
- Load custom word lists from URLs with security validation
**Security Features:**
- Validates all loaded content to prevent script injection
- Blocks dangerous patterns (eval, innerHTML, script tags, etc.)
- Safe character whitelist for tags
- Validates CSV structure, tag names, categories, counts, and file size
- Renders tag data as plain text rather than executable HTML or JavaScript
- Rejects control characters and HTML error documents
- File size limits to prevent memory exhaustion
- Clear error messages for rejected content
Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

+48
View File
@@ -0,0 +1,48 @@
# Autocomplete tag packs
ComfyAssets prepares filtered autocomplete data from Danbooru's public
`tags.json` and `tag_aliases.json` APIs. Dataset releases are versioned separately
from KikoTools and stored as release assets, keeping bulk data out of Git history.
## Danbooru — Illustrious / NoobAI, 2026-10-16
[Download the CSV and manifest](https://github.com/ComfyAssets/ComfyUI-KikoTools/releases/tag/tags-2026-10-16).
This snapshot contains 165,158 canonical tags, their categories, post counts, and
aliases. The adjacent manifest records the retrieval date, model targets,
selection filters, schema, byte size, and SHA-256 checksum.
The UTF-8 CSV has no header. Columns are `tag,category,post_count,aliases`.
Aliases share a comma-separated field; use a CSV parser that respects quoting.
An empty alias field means no aliases. Category codes are 0 (general), 1
(artist), 3 (copyright), 4 (character), and 5 (meta).
In ComfyUI Settings, search **Embedding Autocomplete**, open its settings,
then download the pack and choose the CSV under **Tag list file**. Enable
**Show Custom Words** and select **Save Settings**. Importing stages the list;
Cancel leaves the active list unchanged. The browser caches the saved file in
IndexedDB for offline use and restores it after a refresh. Clearing browser site
data removes this cache. There is no server-side autocomplete folder to populate.
Alternatively, enter a CSV/TXT URL and select **Load URL**, then save. Remote
servers must allow browser cross-origin requests; for GitHub release assets,
download and import the file locally. Existing one-column word lists and
`tag,frequency` lists remain supported. The default URL is unchanged.
Parsing runs in a Web Worker with a 32 MB UTF-8 limit. Search checks canonical
names and aliases, inserts the canonical tag, and ranks exact matches ahead of
prefixes and substrings, then by descending post count. The number of displayed
results is bounded by Maximum suggestions. Categories are retained in records;
category-specific controls are not currently exposed.
## Source and terms
Source data: [Danbooru](https://danbooru.donmai.us/), collected 2026-10-16.
Selection and packaging: ComfyAssets. This is an independent filtered snapshot,
not an official Danbooru release or a guarantee of model compatibility.
Refer to [Danbooru's Terms of Service](https://danbooru.donmai.us/terms) and the
[official terms template](https://github.com/danbooru/danbooru/blob/master/app/views/static/terms_of_service.html.erb).
The template's Copyright Infringement section identifies tags as factual
information that is generally not copyrightable. Its API Usage section requires
compliance with API rate limits. This reference does not claim an explicit
dataset license or extend KikoTools' software license to upstream data.
@@ -0,0 +1,35 @@
{
"source": "danbooru.donmai.us tags.json + tag_aliases.json",
"pulled": "2026-10-16",
"target_models": "Illustrious 1.0-3.x, NoobAI-XL",
"filters": {
"created_before": "2025-01-01",
"min_post_count": {
"general": 50,
"artist": 30,
"copyright": 20,
"character": 20
},
"artists_kept": true,
"deprecated_removed": true,
"meta_tags": "removed except small allowlist"
},
"tag_count": 165158,
"schema_version": 1,
"file": "danbooru-illustrious-noobai-2026-10-16.csv",
"encoding": "UTF-8",
"header_row": false,
"columns": [
"tag",
"category",
"post_count",
"aliases"
],
"aliases_format": "Comma-separated inside a CSV-quoted field when necessary",
"size_bytes": 3979593,
"sha256": "733531a60f2e2d38925f53381b18cff21513ef14550d1f2ca670a0f55b86b798",
"prepared_by": "ComfyAssets",
"terms_url": "https://danbooru.donmai.us/terms",
"terms_source_url": "https://github.com/danbooru/danbooru/blob/master/app/views/static/terms_of_service.html.erb",
"release_tag": "tags-2026-10-16"
}
+488 -1
View File
@@ -14,7 +14,7 @@ test.beforeEach(async ({ page, request, context, browserName, baseURL }) => {
page.errors.push(`Unexpected external request: ${url.origin}`);
return route.abort();
});
await page.addInitScript(() => localStorage.setItem('kikotools.embedding_autocomplete.settings', JSON.stringify({ showCustomWords: false, customWordsUrl: '' })));
await page.addInitScript(() => { if (!localStorage.getItem('kikotools.embedding_autocomplete.settings')) localStorage.setItem('kikotools.embedding_autocomplete.settings', JSON.stringify({ showCustomWords: false, customWordsUrl: '' })); });
});
test.afterEach(async ({ page, browserName }) => {
await saveCoverage(page, browserName);
@@ -1112,3 +1112,490 @@ test('display copy feedback expires and malformed execution messages retain the
await expect(panel.getByRole('status')).toHaveText('Copied.');
await expect(panel.getByRole('status')).toBeEmpty({ timeout: 4000 });
});
async function mountAutocompleteSettings(page) {
await page.evaluate(() => {
const table = document.createElement('table');
table.id = 'autocomplete-test-settings';
const body = document.createElement('tbody');
body.append(kikoHost.settingDefinitions.get('kikotools.embeddingAutocomplete').type());
table.append(body);
document.body.append(table);
});
await page.getByRole('button', { name: 'Open Settings', exact: true }).click();
return page.getByRole('dialog', { name: 'Embedding Autocomplete Settings', exact: true });
}
test('autocomplete shared settings save preferences, cancel edits and follow themes', async ({ page }) => {
await openHost(page);
await page.evaluate(() => Object.assign(window.kikotools.autocomplete.settings, { quickTrigger: 'keep', maxSuggestions: 7, insertOnTab: false }));
const dialog = await mountAutocompleteSettings(page);
await expect(dialog).toBeVisible();
await expect(dialog.getByRole('switch', { name: 'Enable Autocomplete', exact: true })).toBeChecked();
await dialog.getByRole('switch', { name: 'Show LoRAs', exact: true }).uncheck();
await page.keyboard.press('Escape');
await expect(dialog).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Open Settings', exact: true })).toBeFocused();
expect(await page.evaluate(() => window.kikotools.autocomplete.settings.showLoras)).toBe(true);
await page.getByRole('button', { name: 'Open Settings', exact: true }).click();
await page.evaluate(() => document.querySelector('#autocomplete-test-settings button').click());
await expect(dialog).toHaveCount(1);
for (const [color, theme] of [['#fafafa', 'light'], ['#202020', 'dark']]) {
await page.evaluate(color => document.body.style.setProperty('--bg-color', color), color);
await expect(dialog).toHaveAttribute('data-ca-theme', theme);
}
await page.setViewportSize({ width: 320, height: 480 });
expect(await dialog.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true);
await dialog.getByRole('switch', { name: 'Show LoRAs', exact: true }).uncheck();
await dialog.getByRole('button', { name: 'Save Settings', exact: true }).click();
await expect(dialog).toHaveCount(0);
expect(await page.evaluate(() => JSON.parse(localStorage.getItem('kikotools.embedding_autocomplete.settings')))).toMatchObject({ showLoras: false, quickTrigger: 'keep', maxSuggestions: 7, insertOnTab: false });
await page.getByRole('button', { name: 'Open Settings', exact: true }).click();
await expect(dialog.getByRole('switch', { name: 'Show LoRAs', exact: true })).not.toBeChecked();
await page.evaluate(() => document.getElementById('autocomplete-test-settings').remove());
await expect(dialog).toHaveCount(0);
});
test('autocomplete settings report storage failure and keep the previous preferences', async ({ page }) => {
await openHost(page);
const dialog = await mountAutocompleteSettings(page);
await dialog.getByRole('switch', { name: 'Enable Autocomplete', exact: true }).uncheck();
await page.evaluate(() => { window.restoreAutocompleteStorage = Storage.prototype.setItem; Storage.prototype.setItem = () => { throw new Error('blocked'); }; });
await dialog.getByRole('button', { name: 'Save Settings', exact: true }).click();
await expect(dialog.getByRole('status')).toContainText('Could not save');
expect(await page.evaluate(() => window.kikotools.autocomplete.settings.enabled)).toBe(true);
await page.evaluate(() => Storage.prototype.setItem = window.restoreAutocompleteStorage);
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(dialog).toHaveCount(0);
});
test('autocomplete suggestions retain focus, expose selection, and survive unrelated removal', async ({ page }) => {
await openHost(page);
await createNode(page, 'TextInput', { text: '' });
const other = await createNode(page, 'TextInput', { text: '' });
await expect.poll(() => page.evaluate(() => window.kikotools.autocomplete.embeddings.length)).toBe(2);
const prompt = page.getByLabel('text', { exact: true }).first();
await prompt.fill('em');
const list = page.getByRole('listbox', { name: 'Prompt completions', exact: true });
await expect(list.getByRole('option')).toHaveCount(2);
await prompt.press('ArrowDown');
const selected = list.getByRole('option', { selected: true });
await expect(selected).toHaveCount(1);
await expect(prompt).toHaveAttribute('aria-activedescendant', await selected.getAttribute('id'));
await expect(prompt).toBeFocused();
await page.evaluate(other => kikoHost.removeNode(other), other);
await expect(list).toBeVisible();
await prompt.press('Tab');
await expect(prompt).toHaveValue(/embedding:/);
await expect(list).toBeHidden();
await expect(prompt).not.toHaveAttribute('aria-activedescendant');
await prompt.fill('<lora:in');
await expect(list).toBeVisible();
await list.getByRole('option').click();
await expect(prompt).toHaveValue('<lora:styles/ink:1.0>, ');
await expect(prompt).toBeFocused();
});
test('autocomplete respects composition, read-only fields, custom words and keyboard preferences', async ({ page }) => {
await openHost(page);
await createNode(page, 'TextInput', { text: '' });
const prompt = page.getByLabel('text', { exact: true });
await page.evaluate(() => {
Object.assign(window.kikotools.autocomplete.settings, { showEmbeddings: false, showLoras: false, showCustomWords: true, insertOnEnter: false });
window.kikotools.autocomplete.customWords = [{ name: 'blue_sky', value: 'blue_sky', display: 'blue_sky', type: 'custom' }];
});
await expect(prompt).toHaveAttribute('aria-autocomplete', 'list');
await prompt.fill('blue');
const list = page.getByRole('listbox', { name: 'Prompt completions', exact: true });
await expect(list).toBeVisible();
await prompt.press('ArrowDown');
await prompt.dispatchEvent('keydown', { key: 'Enter', isComposing: true });
await expect(prompt).toHaveValue('blue');
await prompt.press('Enter');
await expect(prompt).not.toHaveValue('blue sky, ');
await prompt.fill('blue');
await prompt.press('ArrowDown');
await prompt.press('Tab');
await expect(prompt).toHaveValue('blue sky, ');
await prompt.fill('blue');
await prompt.press('Escape');
await expect(list).toBeHidden();
await prompt.evaluate(element => { element.readOnly = true; element.dispatchEvent(new Event('input', { bubbles: true })); });
await expect(list).toBeHidden();
});
test('autocomplete restores existing attributes and lifecycle hooks on detach', async ({ page }) => {
await openHost(page);
const result = await page.evaluate(() => {
const autocomplete = window.kikotools.autocomplete;
const input = document.createElement('textarea');
input.setAttribute('aria-controls', 'original-controls');
input.setAttribute('aria-autocomplete', 'none');
input.readOnly = true;
const calls = [];
const widget = { inputEl: input, onRemoved(...args) { calls.push([this === widget, args]); return 'widget-result'; } };
const node = { onRemoved(...args) { calls.push([this === node, args]); return 'node-result'; } };
const original = widget.onRemoved;
autocomplete.attachToWidget(widget, node);
const ignored = !autocomplete.activeWidgets.has(widget);
input.readOnly = false;
autocomplete.attachToWidget(widget, node);
autocomplete.attachToWidget(widget, node);
const value = widget.onRemoved('widget-arg', 7);
const restored = widget.onRemoved === original;
autocomplete.attachToWidget(widget, node);
const removed = node.onRemoved('node-arg');
autocomplete.detachFromWidget(widget);
autocomplete.detachFromWidget(widget);
return { ignored, value, removed, restored, calls, controls: input.getAttribute('aria-controls'), autocomplete: input.getAttribute('aria-autocomplete'), popup: input.hasAttribute('aria-haspopup'), active: autocomplete.activeWidgets.has(widget) };
});
expect(result).toEqual({ ignored: true, value: 'widget-result', removed: 'node-result', restored: true, calls: [[true, ['widget-arg', 7]], [true, ['node-arg']]], controls: 'original-controls', autocomplete: 'none', popup: false, active: false });
});
test('autocomplete popup fits narrow viewports and cleanup cancels pending refresh', async ({ page }) => {
await openHost(page);
await createNode(page, 'TextInput', { text: '' });
await expect.poll(() => page.evaluate(() => window.kikotools.autocomplete.embeddings.length)).toBe(2);
await page.setViewportSize({ width: 320, height: 480 });
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
const prompt = page.getByLabel('text', { exact: true });
await prompt.fill('em');
const list = page.getByRole('listbox', { name: 'Prompt completions', exact: true });
const box = await list.boundingBox();
expect(box.x).toBeGreaterThanOrEqual(0);
expect(box.x + box.width).toBeLessThanOrEqual(320);
expect(box.y + box.height).toBeLessThanOrEqual(480);
await prompt.dispatchEvent('blur');
await page.waitForTimeout(40);
await expect(list).toBeVisible();
await page.evaluate(() => {
const autocomplete = window.kikotools.autocomplete;
window.lateAutocompleteFetches = 0;
autocomplete.fetchResources = () => window.lateAutocompleteFetches++;
autocomplete.fetchResourcesDebounced();
autocomplete.cleanup();
});
await expect(list).toHaveCount(0);
await page.waitForTimeout(550);
expect(await page.evaluate(() => window.lateAutocompleteFetches)).toBe(0);
await expect(prompt).not.toHaveAttribute('aria-haspopup');
});
test('autocomplete binds replacement renderer fields without changing native widget ownership', async ({ page }) => {
await openHost(page);
const id = await createNode(page, 'TextInput', { text: '' });
await expect.poll(() => page.evaluate(() => window.kikotools.autocomplete.embeddings.length)).toBe(2);
await page.evaluate(id => {
const wrapper = document.createElement('div');
wrapper.setAttribute('node-id', String(id));
const label = document.createElement('label');
label.htmlFor = 'replacement-prompt';
label.textContent = 'text';
const textarea = document.createElement('textarea');
textarea.id = 'replacement-prompt';
wrapper.append(label, textarea);
document.body.append(wrapper);
}, id);
const prompt = page.locator('#replacement-prompt');
await prompt.fill('embedding:ca');
const list = page.getByRole('listbox', { name: 'Prompt completions', exact: true });
await expect(list).toBeVisible();
await prompt.press('ArrowDown');
await prompt.press('Enter');
await expect(prompt).toHaveValue('embedding:portraits/cat, ');
await prompt.fill('embedding:ca');
await expect(list).toBeVisible();
await page.evaluate(() => {
window.removedRendererBridge = window.kikotools.autocomplete.currentWidget;
document.getElementById('replacement-prompt').parentElement.remove();
});
await expect(prompt).toHaveCount(0);
await expect(list).toBeHidden();
expect(await page.evaluate(() => window.kikotools.autocomplete.activeWidgets.has(window.removedRendererBridge))).toBe(false);
const native = page.getByLabel('text', { exact: true });
await native.fill('embedding:ca');
await expect(list).toBeVisible();
await native.press('ArrowDown');
await native.press('Tab');
await expect(native).toHaveValue('embedding:portraits/cat, ');
});
test('autocomplete starts a new query without carrying over the previous selection', async ({ page }) => {
await openHost(page);
await createNode(page, 'TextInput', { text: '' });
await expect.poll(() => page.evaluate(() => window.kikotools.autocomplete.embeddings.length)).toBe(2);
const prompt = page.getByLabel('text', { exact: true });
const list = page.getByRole('listbox', { name: 'Prompt completions', exact: true });
await prompt.fill('embedding:ca');
await prompt.press('ArrowDown');
await prompt.press('ArrowDown');
await expect(list.getByRole('option', { selected: true })).toContainText('forest');
await prompt.fill('embedding:ca');
await prompt.press('ArrowDown');
await expect(list.getByRole('option', { selected: true })).toContainText('cat');
});
test('autocomplete keyboard selection ignores a stationary pointer over a new result list', async ({ page }) => {
await openHost(page);
await createNode(page, 'TextInput', { text: '' });
await expect.poll(() => page.evaluate(() => window.kikotools.autocomplete.embeddings.length)).toBe(2);
const prompt = page.getByLabel('text', { exact: true });
const list = page.getByRole('listbox', { name: 'Prompt completions', exact: true });
await prompt.fill('embedding:ca');
await list.getByRole('option').first().hover();
await prompt.fill('embedding:ca');
await page.waitForTimeout(50);
await expect(list.getByRole('option', { selected: true })).toHaveCount(0);
await prompt.press('ArrowDown');
await prompt.press('Enter');
await expect(prompt).toHaveValue('embedding:portraits/cat, ');
});
test('autocomplete delayed attachment cannot revive a removed node and teardown restores host hooks', async ({ page }) => {
await openHost(page);
await page.evaluate(async () => {
const id = await kikoHost.createNode('TextInput', { text: '' });
window.quicklyRemovedPrompt = kikoHost.nodes.get(String(id)).widgets.find(widget => widget.inputEl);
kikoHost.removeNode(id);
});
await page.waitForTimeout(70);
expect(await page.evaluate(() => window.kikotools.autocomplete.activeWidgets.has(window.quicklyRemovedPrompt))).toBe(false);
expect(await page.evaluate(() => {
const wrapped = kikoHost.app.graph.add;
const stringWidget = kikoHost.widgets.STRING;
window.kikotools.autocomplete.cleanup();
return { graphRestored: kikoHost.app.graph.add !== wrapped, widgetRestored: kikoHost.widgets.STRING !== stringWidget };
})).toEqual({ graphRestored: true, widgetRestored: true });
});
test('autocomplete tag import preserves aliases and ranking across reload and failed imports', async ({ page }) => {
await openHost(page);
const dialog = await mountAutocompleteSettings(page);
await dialog.getByRole('switch', { name: 'Show Custom Words', exact: true }).check();
await dialog.getByRole('switch', { name: 'Show Embeddings', exact: true }).uncheck();
await dialog.getByRole('switch', { name: 'Show LoRAs', exact: true }).uncheck();
await dialog.getByLabel('Tag list file', { exact: true }).setInputFiles({ name: 'tags.csv', mimeType: 'text/csv', buffer: Buffer.from('blue_sky,0,100,azure\nblue_eyes,0,900,"blue_eye,bright_eyes"') });
await expect(dialog.getByRole('status')).toContainText('2 tags ready');
await dialog.getByText('Triggers, matching and insertion', { exact: true }).click();
await dialog.getByRole('switch', { name: 'Replace underscores with spaces', exact: true }).uncheck();
await dialog.getByRole('button', { name: 'Save Settings', exact: true }).click();
await expect(dialog).toHaveCount(0);
await createNode(page, 'TextInput', { text: '' });
const prompt = page.getByLabel('text', { exact: true });
await expect(prompt).toHaveAttribute('aria-autocomplete', 'list');
await prompt.focus();
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(resolve)));
await prompt.fill('blue');
const list = page.getByRole('listbox', { name: 'Prompt completions', exact: true });
await expect(list.getByRole('option').first()).toContainText('blue_eyes');
await prompt.fill('azure');
await prompt.press('ArrowDown');
await prompt.press('Tab');
await expect(prompt).toHaveValue('blue_sky, ');
await page.reload();
await expect.poll(() => page.evaluate(() => window.kikotools?.autocomplete.customWords.length)).toBe(2);
const reopened = await mountAutocompleteSettings(page);
await expect(reopened.getByText('2 tags loaded', { exact: true })).toBeVisible();
await expect(reopened.getByText('Current source: tags.csv', { exact: true })).toBeVisible();
await reopened.getByLabel('Tag list file', { exact: true }).setInputFiles({ name: 'bad.csv', mimeType: 'text/csv', buffer: Buffer.from('bad,0,not-a-count,') });
await expect(reopened.getByRole('status')).toContainText('Could not load');
await reopened.getByRole('button', { name: 'Cancel', exact: true }).click();
expect(await page.evaluate(() => window.kikotools.autocomplete.customWords.length)).toBe(2);
});
test('autocomplete restores advanced preferences and stages URL tags until save', async ({ page }) => {
await openHost(page);
await page.route('**/__test__/tags.csv', route => route.fulfill({ body: 'tag,category,post_count,aliases\nblue_eyes,0,120,azure' }));
const dialog = await mountAutocompleteSettings(page);
await dialog.getByLabel('Tag list URL', { exact: true }).fill(new URL('/__test__/tags.csv', page.url()).href);
await dialog.getByRole('button', { name: 'Load URL', exact: true }).click();
await expect(dialog.getByRole('status')).toContainText('1 tags ready');
expect(await page.evaluate(() => window.kikotools.autocomplete.customWords.length)).toBe(0);
await dialog.getByText('Triggers, matching and insertion', { exact: true }).click();
await dialog.getByLabel('Embedding trigger', { exact: true }).fill('emb:');
await dialog.getByLabel('LoRA trigger', { exact: true }).fill('l:');
await dialog.getByLabel('Quick embedding trigger', { exact: true }).fill('zz');
await dialog.getByLabel('Maximum suggestions', { exact: true }).fill('8');
await dialog.getByLabel('Minimum characters', { exact: true }).fill('3');
await dialog.getByRole('switch', { name: 'Group by directory', exact: true }).uncheck();
await dialog.getByRole('switch', { name: 'Insert on Enter', exact: true }).uncheck();
await dialog.getByRole('button', { name: 'Save Settings', exact: true }).click();
await expect(dialog).toHaveCount(0);
expect(await page.evaluate(() => ({ ...window.kikotools.autocomplete.settings }))).toMatchObject({ embeddingTrigger: 'emb:', loraTrigger: 'l:', quickTrigger: 'zz', maxSuggestions: 8, minChars: 3, sortByDirectory: false, insertOnEnter: false });
expect(await page.evaluate(() => window.kikotools.autocomplete.customWords[0].frequency)).toBe(120);
});
test('autocomplete cancelling a staged import leaves the cached source unchanged', async ({ page }) => {
await openHost(page);
const dialog = await mountAutocompleteSettings(page);
await dialog.getByLabel('Tag list file', { exact: true }).setInputFiles({ name: 'staged.csv', mimeType: 'text/csv', buffer: Buffer.from('blue_eyes,0,10,azure') });
await expect(dialog.getByRole('status')).toContainText('1 tags ready');
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
expect(await page.evaluate(() => window.kikotools.autocomplete.customWords)).toEqual([]);
expect(await page.evaluate(async () => (await import('/extensions/kikotools/autocomplete/tag-storage.js')).tagCache())).toBeUndefined();
});
test('autocomplete reports tag download and cache errors without losing the current settings', async ({ page }) => {
await openHost(page);
const dialog = await mountAutocompleteSettings(page);
await dialog.getByRole('button', { name: 'Load URL', exact: true }).click();
await expect(dialog.getByRole('status')).toContainText('Enter a valid');
await page.route('**/__test__/bad-tags.csv', route => route.fulfill({ status: 404, body: 'Missing' }));
await dialog.getByLabel('Tag list URL', { exact: true }).fill(new URL('/__test__/bad-tags.csv', page.url()).href);
await dialog.getByRole('button', { name: 'Load URL', exact: true }).click();
await expect(dialog.getByRole('status')).toContainText('404');
await dialog.getByLabel('Tag list file', { exact: true }).setInputFiles({ name: 'valid.csv', mimeType: 'text/csv', buffer: Buffer.from('blue_eyes,0,10,azure') });
await expect(dialog.getByRole('status')).toContainText('1 tags ready');
await dialog.getByRole('switch', { name: 'Enable Autocomplete', exact: true }).uncheck();
await page.evaluate(() => {
window.originalTagDBOpen = indexedDB.open.bind(indexedDB);
indexedDB.open = () => { throw new DOMException('Storage denied', 'SecurityError'); };
});
await dialog.getByRole('button', { name: 'Save Settings', exact: true }).click();
await expect(dialog.getByRole('status')).toContainText('Could not save tag list');
expect(await page.evaluate(() => window.kikotools.autocomplete.settings.enabled)).toBe(true);
expect(await page.evaluate(() => window.kikotools.autocomplete.customWords.length)).toBe(0);
expect(await page.evaluate(() => window.kikotools.autocomplete.settings.tagSource)).toBeUndefined();
expect(await page.evaluate(() => JSON.parse(localStorage.getItem('kikotools.embedding_autocomplete.settings')).tagSource)).toBeUndefined();
expect(await page.evaluate(() => JSON.parse(localStorage.getItem('kikotools.embedding_autocomplete.settings')).enabled)).toBe(true);
await page.evaluate(() => indexedDB.open = window.originalTagDBOpen);
await dialog.getByRole('button', { name: 'Save Settings', exact: true }).click();
await expect(dialog).toHaveCount(0);
expect(await page.evaluate(() => window.kikotools.autocomplete.customWords.length)).toBe(1);
});
test('autocomplete cancels pending tag downloads when its settings close', async ({ page }) => {
await openHost(page);
let held;
await page.route('**/__test__/slow-tags.csv', route => { held = route; });
const dialog = await mountAutocompleteSettings(page);
await dialog.getByLabel('Tag list URL', { exact: true }).fill(new URL('/__test__/slow-tags.csv', page.url()).href);
await dialog.getByRole('button', { name: 'Load URL', exact: true }).click();
await expect.poll(() => Boolean(held)).toBe(true);
await expect(dialog.getByRole('button', { name: 'Save Settings', exact: true })).toBeDisabled();
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
await held.fulfill({ body: 'blue_eyes,0,10,azure' });
expect(await page.evaluate(() => window.kikotools.autocomplete.customWords.length)).toBe(0);
expect(await page.evaluate(() => window.kikotools.autocomplete.pendingFetches.size)).toBe(0);
});
test('autocomplete imports 300000 tags off the UI thread and bounds broad matches', async ({ page }) => {
await openHost(page);
const dialog = await mountAutocompleteSettings(page);
const text = Array.from({ length: 300000 }, (_, i) => `tag_${i},0,${i},alias_${i}`).join('\n');
await page.evaluate(() => { window.tagImportTicks = 0; window.tagImportTimer = setInterval(() => window.tagImportTicks++, 10); });
await dialog.getByLabel('Tag list file', { exact: true }).setInputFiles({ name: 'large.csv', mimeType: 'text/csv', buffer: Buffer.from(text) });
await expect(dialog.getByRole('status')).toContainText('300,000 tags ready', { timeout: 15000 });
expect(await page.evaluate(() => { clearInterval(window.tagImportTimer); return window.tagImportTicks; })).toBeGreaterThan(1);
await dialog.getByRole('switch', { name: 'Show Custom Words', exact: true }).check();
await dialog.getByRole('button', { name: 'Save Settings', exact: true }).click();
await expect(dialog).toHaveCount(0);
await createNode(page, 'TextInput', { text: '' });
const prompt = page.getByLabel('text', { exact: true });
await expect(prompt).toHaveAttribute('aria-autocomplete', 'list');
await prompt.fill('alias_299999');
const list = page.getByRole('listbox', { name: 'Prompt completions', exact: true });
await expect(list.getByRole('option')).toHaveCount(1);
await expect(list.getByRole('option')).toContainText('tag_299999');
await prompt.fill('tag_');
await expect(list.getByRole('option')).toHaveCount(20);
});
test('autocomplete legacy URL loads retain the newest valid list when older requests finish late', async ({ page }) => {
await openHost(page);
let held;
await page.route('**/__test__/old-tags.txt', route => { held = route; });
await page.route('**/__test__/new-tags.txt', route => route.fulfill({ body: 'blue_sky,10\nblue_eyes,200' }));
await page.evaluate(() => { window.oldTagLoad = window.kikotools.autocomplete.loadCustomWords('/__test__/old-tags.txt'); });
await expect.poll(() => Boolean(held)).toBe(true);
expect(await page.evaluate(() => window.kikotools.autocomplete.loadCustomWords('/__test__/new-tags.txt'))).toBe(2);
await held.fulfill({ body: 'obsolete,1000' });
expect(await page.evaluate(() => window.oldTagLoad)).toBe(0);
expect(await page.evaluate(() => window.kikotools.autocomplete.customWords.map(tag => tag.name))).toEqual(['blue_eyes', 'blue_sky']);
expect(await page.evaluate(() => window.kikotools.autocomplete.loadCustomWords('file:///tmp/tags.csv'))).toBe(0);
expect(await page.evaluate(() => window.kikotools.autocomplete.customWords.length)).toBe(2);
expect(await page.evaluate(() => window.kikotools.autocomplete.customWordsError)).toContain('HTTP or HTTPS');
});
test('autocomplete shares one removable node hook across widgets in any cleanup order', async ({ page }) => {
await openHost(page);
const result = await page.evaluate(() => {
const autocomplete = window.kikotools.autocomplete;
const restored = [];
const calls = [];
const make = () => {
const original = function (...args) { calls.push([this === node, args]); return 'original-result'; };
const node = { onRemoved: original };
const widgets = [0, 1, 2].map(() => ({ inputEl: document.createElement('textarea') }));
widgets.forEach(widget => autocomplete.attachToWidget(widget, node));
return { node, original, widgets };
};
for (const order of [[0, 1, 2], [2, 0, 1]]) {
const { node, original, widgets } = make();
const hook = node.onRemoved;
autocomplete.detachFromWidget(widgets[order[0]]);
restored.push(node.onRemoved === hook);
autocomplete.detachFromWidget(widgets[order[1]]);
autocomplete.detachFromWidget(widgets[order[2]]);
restored.push(node.onRemoved === original);
}
const active = make();
const removal = active.node.onRemoved('removed', 7);
restored.push(active.node.onRemoved === active.original, active.widgets.every(widget => !autocomplete.activeWidgets.has(widget)));
const final = make();
autocomplete.cleanup();
restored.push(final.node.onRemoved === final.original);
return { restored, removal, calls };
});
expect(result).toEqual({ restored: Array(7).fill(true), removal: 'original-result', calls: [[true, ['removed', 7]]] });
});
test('autocomplete inserts Unicode and punctuation aliases at the correct prompt boundary', async ({ page }) => {
await openHost(page);
await createNode(page, 'TextInput', { text: '' });
const prompt = page.getByLabel('text', { exact: true });
await expect(prompt).toHaveAttribute('aria-autocomplete', 'list');
await page.evaluate(async () => {
const autocomplete = window.kikotools.autocomplete;
const { parseTagList } = await import('/extensions/kikotools/autocomplete/tags.js');
autocomplete.customWords = parseTagList('日本語,0,10,にほんご\nheart,0,20,<3\nhatsune_miku_(vocaloid),4,100,miku/vocaloid\n🎨🎨,0,1,\n(o_o),0,1,');
Object.assign(autocomplete.settings, { showCustomWords: true, showEmbeddings: false, showLoras: false, replaceUnderscores: false });
});
const list = page.getByRole('listbox', { name: 'Prompt completions', exact: true });
for (const [query, label, value] of [
['日本', '日本語', '日本語, '],
['にほん', '日本語', '日本語, '],
['sky, <3', 'heart', 'sky, heart, '],
['miku/vocal', 'hatsune_miku_(vocaloid)', 'hatsune_miku_(vocaloid), '],
['sky, (日本:1.2)', '日本語', 'sky, (日本語:1.2)'],
['🎨🎨', '🎨🎨', '🎨🎨, '],
['(o_o)', '(o_o)', '(o_o), '],
]) {
await prompt.fill(query);
if (query.includes(':1.2')) await prompt.evaluate(element => { element.setSelectionRange(element.value.indexOf(':1.2'), element.value.indexOf(':1.2')); element.dispatchEvent(new Event('input', { bubbles: true })); });
await expect(list.getByRole('option').first()).toContainText(label);
await prompt.press('ArrowDown');
await prompt.press('Tab');
await expect(prompt).toHaveValue(value);
}
});
test('autocomplete stops an oversized URL stream before it reaches the parser', async ({ page }) => {
await openHost(page);
const result = await page.evaluate(async () => {
const autocomplete = window.kikotools.autocomplete;
let cancelled = false, sent = false;
const original = autocomplete.fetchWithAbort;
autocomplete.fetchWithAbort = async () => new Response(new ReadableStream({
pull(controller) { if (!sent) { sent = true; controller.enqueue(new Uint8Array(32 * 1024 * 1024 + 1).fill(97)); } else controller.close(); },
cancel() { cancelled = true; },
}, { highWaterMark: 0 }));
let error;
try { await autocomplete.fetchTagList('/__test__/oversized-tags.csv'); }
catch (failure) { error = failure.message; }
finally { autocomplete.fetchWithAbort = original; }
return { cancelled, error };
});
expect(result.cancelled).toBe(true);
expect(result.error).toMatch(/large/i);
});
+166 -1
View File
@@ -17,7 +17,7 @@ test.beforeEach(async ({ page, context, request, baseURL }, testInfo) => {
const request = route.request();
const url = new URL(request.url());
const path = url.pathname.replace(/^\/api\//, '/');
if (path.startsWith('/kiko_local_image_loader/') || path === '/view') {
if (path.startsWith('/kiko_local_image_loader/') || path.startsWith('/kikotools/autocomplete/') || path === '/view') {
const response = await route.fetch({ url: `${fixture}${path}${url.search}` });
return route.fulfill({ response });
}
@@ -35,6 +35,7 @@ test.beforeEach(async ({ page, context, request, baseURL }, testInfo) => {
}
return route.continue();
});
await page.addInitScript(() => localStorage.setItem('kikotools.embedding_autocomplete.settings', JSON.stringify({ showCustomWords: false, customWordsUrl: '' })));
await page.goto('/', { waitUntil: 'domcontentloaded' });
await expect(page.getByRole('status', { name: 'Loading ComfyUI' })).toBeHidden({ timeout: 30000 });
await page.waitForFunction(async () => {
@@ -562,3 +563,167 @@ for (const name of ['DisplayText', 'DisplayAny']) {
expect(await page.evaluate(() => window.removedDisplay.displayOutput.scope.signal.aborted)).toBe(true);
});
}
test('embedding suggestions survive real renderers, zoom, themes and workflow reload', async ({ page }, testInfo) => {
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
const node = LiteGraph.createNode('TextInput');
app.graph.add(node);
node.setSize([550, 350]);
app.canvas.centerOnNode(node);
});
await expect.poll(() => page.evaluate(() => window.kikotools.autocomplete.embeddings.length)).toBe(2);
const prompt = page.locator('textarea:visible');
const list = page.getByRole('listbox', { name: 'Prompt completions', exact: true });
for (const [renderer, zoom] of [[false, .65], [true, 1.1], [false, .9]]) {
await page.evaluate(async ({ renderer, zoom }) => {
const { app } = await import('/scripts/app.js');
await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer);
app.canvas.ds.scale = zoom;
app.canvas.centerOnNode(app.graph._nodes[0]);
}, { renderer, zoom });
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
await prompt.fill('embedding:ca');
await expect(list).toBeVisible();
await prompt.press('ArrowDown');
await expect(list.getByRole('option', { selected: true })).toHaveCount(1);
await expect(prompt).toBeFocused();
const box = await list.boundingBox();
expect(box.x).toBeGreaterThanOrEqual(0);
expect(box.y).toBeGreaterThanOrEqual(0);
expect(box.x + box.width).toBeLessThanOrEqual(1600);
expect(box.y + box.height).toBeLessThanOrEqual(1100);
await prompt.press('Enter');
await expect(prompt).toHaveValue('embedding:portraits/cat, ');
await prompt.fill('<lora:in');
await list.getByRole('option').click();
await expect(prompt).toHaveValue('<lora:styles/ink:1.0>, ');
}
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
await app.loadGraphData(app.graph.serialize());
app.canvas.centerOnNode(app.graph._nodes[0]);
});
await expect(prompt).toHaveValue('<lora:styles/ink:1.0>, ');
for (const theme of ['light', 'dark']) {
await page.evaluate(async theme => {
const { app } = await import('/scripts/app.js');
await app.ui.settings.setSettingValue('Comfy.ColorPalette', theme);
}, theme);
await prompt.fill('em');
await prompt.press('ArrowDown');
await expect(list).toHaveAttribute('data-ca-theme', theme);
await page.screenshot({ path: testInfo.outputPath(`suggestions-${theme}.png`) });
await prompt.press('Escape');
}
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
const node = app.graph._nodes[0];
window.removedCompletionWidget = node.widgets.find(widget => widget.inputEl);
app.graph.remove(node);
});
await expect(list).toBeHidden();
expect(await page.evaluate(() => window.kikotools.autocomplete.activeWidgets.has(window.removedCompletionWidget))).toBe(false);
});
test('embedding settings use the real host launcher and preserve preferences', async ({ page }, testInfo) => {
await page.getByRole('button', { name: 'Settings (Ctrl + ,)', exact: true }).click();
const search = page.getByPlaceholder(/search/i);
await search.fill('Embedding Autocomplete');
const trigger = page.getByRole('button', { name: 'Open Settings', exact: true });
await trigger.click();
const dialog = page.getByRole('dialog', { name: 'Embedding Autocomplete Settings', exact: true });
await expect(dialog).toBeVisible();
for (const theme of ['light', 'dark']) {
await page.evaluate(async theme => {
const { app } = await import('/scripts/app.js');
await app.ui.settings.setSettingValue('Comfy.ColorPalette', theme);
}, theme);
await expect(dialog).toHaveAttribute('data-ca-theme', theme);
await page.screenshot({ path: testInfo.outputPath(`settings-${theme}.png`) });
}
await dialog.getByRole('switch', { name: 'Show LoRAs', exact: true }).uncheck();
await dialog.getByRole('button', { name: 'Save Settings', exact: true }).click();
await expect(dialog).toHaveCount(0);
await expect(trigger).toBeFocused();
expect(await page.evaluate(() => JSON.parse(localStorage.getItem('kikotools.embedding_autocomplete.settings')).showLoras)).toBe(false);
await trigger.click();
await expect(dialog.getByRole('switch', { name: 'Show LoRAs', exact: true })).not.toBeChecked();
await dialog.getByRole('switch', { name: 'Show LoRAs', exact: true }).check();
await page.keyboard.press('Escape');
expect(await page.evaluate(() => window.kikotools.autocomplete.settings.showLoras)).toBe(false);
});
test('embedding tag files import through real settings and complete canonical tags in both renderers', async ({ page }, testInfo) => {
await page.getByRole('button', { name: 'Settings (Ctrl + ,)', exact: true }).click();
await page.getByPlaceholder(/search/i).fill('Embedding Autocomplete');
await page.getByRole('button', { name: 'Open Settings', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Embedding Autocomplete Settings', exact: true });
const input = process.env.KIKO_TAG_CSV || { name: 'tags.csv', mimeType: 'text/csv', buffer: Buffer.from('highres,5,100,"high_res,high_resolution"\nblue_eyes,0,90,azure') };
await dialog.getByLabel('Tag list file', { exact: true }).setInputFiles(input);
await expect(dialog.getByRole('status')).toContainText('tags ready', { timeout: 15000 });
await testInfo.attach('import-status.txt', { body: await dialog.getByRole('status').innerText(), contentType: 'text/plain' });
await dialog.getByRole('switch', { name: 'Show Custom Words', exact: true }).check();
await dialog.getByText('Triggers, matching and insertion', { exact: true }).click();
await dialog.getByRole('switch', { name: 'Replace underscores with spaces', exact: true }).uncheck();
await page.screenshot({ path: testInfo.outputPath('tag-import.png') });
await dialog.getByRole('button', { name: 'Save Settings', exact: true }).click();
await expect(dialog).toHaveCount(0);
await page.keyboard.press('Escape');
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
const node = LiteGraph.createNode('TextInput');
app.graph.add(node);
node.setSize([550, 350]);
app.canvas.centerOnNode(node);
});
const prompt = page.locator('textarea:visible');
const list = page.getByRole('listbox', { name: 'Prompt completions', exact: true });
for (const renderer of [false, true, false]) {
await page.evaluate(async renderer => {
const { app } = await import('/scripts/app.js');
await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer);
app.canvas.centerOnNode(app.graph._nodes[0]);
}, renderer);
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
await prompt.fill('high_resolution');
await expect(list.getByRole('option').first()).toContainText('highres');
await prompt.press('ArrowDown');
await prompt.press('Tab');
await expect(prompt).toHaveValue('highres, ');
}
});
test('embedding Unicode and emoticon completion preserves prompt boundaries in both renderers', async ({ page }) => {
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
const { parseTagList } = await import('/extensions/kikotools/autocomplete/tags.js');
const autocomplete = window.kikotools.autocomplete;
autocomplete.customWords = parseTagList('日本語,0,10,にほんご\nheart,0,20,<3');
Object.assign(autocomplete.settings, { showCustomWords: true, showEmbeddings: false, showLoras: false });
const node = LiteGraph.createNode('TextInput');
app.graph.add(node);
node.setSize([550, 350]);
app.canvas.centerOnNode(node);
});
const prompt = page.locator('textarea:visible');
const list = page.getByRole('listbox', { name: 'Prompt completions', exact: true });
for (const renderer of [false, true, false]) {
await page.evaluate(async renderer => {
const { app } = await import('/scripts/app.js');
await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer);
app.canvas.centerOnNode(app.graph._nodes[0]);
}, renderer);
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
await prompt.focus();
await expect(prompt).toHaveAttribute('aria-autocomplete', 'list');
for (const [query, result] of [['sky, 日本', 'sky, 日本語, '], ['sky, <3', 'sky, heart, ']]) {
await prompt.fill(query);
await expect(list.getByRole('option')).toHaveCount(1);
await prompt.press('ArrowDown');
await prompt.press('Tab');
await expect(prompt).toHaveValue(result);
}
}
});
+54
View File
@@ -0,0 +1,54 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readTagResponse } from '../../web/autocomplete/tag-download.js';
function response(chunks, headers = {}) {
let index = 0, cancelled = false;
const body = new ReadableStream({
pull(controller) { if (index < chunks.length) controller.enqueue(chunks[index++]); else controller.close(); },
cancel() { cancelled = true; },
}, { highWaterMark: 0 });
return { value: new Response(body, { headers }), cancelled: () => cancelled, read: () => index };
}
const encode = value => new TextEncoder().encode(value);
test('tag downloads stop reading at the byte limit with missing or dishonest headers', async () => {
for (const headers of [{}, { 'content-length': '1' }]) {
const stream = response([encode('1234'), encode('56789'), encode('unread')], headers);
await assert.rejects(readTagResponse(stream.value, { maxBytes: 8 }), /large/i);
assert.equal(stream.cancelled(), true);
assert.equal(stream.read(), 2);
assert.equal(stream.value.body.locked, false);
}
});
test('oversized declared downloads are cancelled without reading their contents', async () => {
const stream = response([encode('unread')], { 'content-length': '100' });
await assert.rejects(readTagResponse(stream.value, { maxBytes: 8 }), /large/i);
assert.equal(stream.read(), 0);
assert.equal(stream.cancelled(), true);
});
test('streamed UTF-8 survives split code points and an exact byte limit', async () => {
const bytes = encode('日本語');
const stream = response([bytes.slice(0, 2), bytes.slice(2, 5), bytes.slice(5)]);
assert.equal(await readTagResponse(stream.value, { maxBytes: bytes.length }), '日本語');
assert.equal(stream.value.body.locked, false);
assert.equal(await readTagResponse(new Response(null)), '');
});
test('aborted or failed downloads release their reader and preserve the error', { timeout: 2000 }, async () => {
const controller = new AbortController();
let cancelled = false;
const stream = new ReadableStream({ cancel() { cancelled = true; } });
const value = new Response(stream);
const pending = readTagResponse(value, { signal: controller.signal });
controller.abort();
await assert.rejects(pending, { name: 'AbortError' });
assert.equal(cancelled, true);
assert.equal(value.body.locked, false);
const stopped = response([encode('unread')]);
await assert.rejects(readTagResponse(stopped.value, { signal: AbortSignal.abort() }), { name: 'AbortError' });
assert.equal(stopped.cancelled(), true);
const error = new Error('Network interrupted');
const failed = new Response(new ReadableStream({ start(stream) { stream.error(error); } }));
await assert.rejects(readTagResponse(failed), /Network interrupted/);
assert.equal(failed.body.locked, false);
});
+9
View File
@@ -0,0 +1,9 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { suggestionPosition } from '../../web/ui/autocomplete-suggestions.js';
test('completion popup stays within the viewport and flips above a low field', () => {
assert.deepEqual(suggestionPosition({ left: 20, top: 40, bottom: 80 }, { width: 220, height: 150 }, { width: 800, height: 600 }), { left: 20, top: 84 });
assert.deepEqual(suggestionPosition({ left: 300, top: 400, bottom: 440 }, { width: 220, height: 150 }, { width: 320, height: 480 }), { left: 92, top: 246 });
assert.deepEqual(suggestionPosition({ left: -30, top: 2, bottom: 10 }, { width: 300, height: 300 }, { width: 240, height: 200 }), { left: 8, top: 8 });
});
+39
View File
@@ -0,0 +1,39 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { tagCache, parseTagFile } from '../../web/autocomplete/tag-storage.js';
test('a blocked cache operation cannot write after its caller has received an error', async () => {
const request = {};
let closed = false, wrote = false;
globalThis.indexedDB = { open: () => request };
try {
const operation = tagCache({ source: 'new-file' });
request.onblocked();
await assert.rejects(operation, /blocked/);
request.result = { close: () => { closed = true; }, transaction: () => { wrote = true; throw new Error('Abandoned operation wrote data'); } };
request.onsuccess();
assert.equal(closed, true);
assert.equal(wrote, false);
} finally { delete globalThis.indexedDB; }
});
test('cancelled imports never start workers, and running workers terminate on cancellation or failure', async () => {
await assert.rejects(parseTagFile('tag', AbortSignal.abort()), { name: 'AbortError' });
let current;
globalThis.Worker = class {
constructor() { current = this; this.terminated = false; }
postMessage() {}
terminate() { this.terminated = true; }
};
try {
const controller = new AbortController();
const aborted = parseTagFile('tag', controller.signal);
controller.abort();
await assert.rejects(aborted, { name: 'AbortError' });
assert.equal(current.terminated, true);
const failed = parseTagFile('tag', new AbortController().signal);
current.onerror();
await assert.rejects(failed, /Could not parse/);
assert.equal(current.terminated, true);
} finally { delete globalThis.Worker; }
});
+76
View File
@@ -0,0 +1,76 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { parseTagList, searchTags, tagQuery } from '../../web/autocomplete/tags.js';
test('four-column packs preserve categories, popularity and quoted aliases', () => {
const tags = parseTagList('\ufefftag,category,post_count,aliases\r\nblue_eyes,0,900,"blue_eye,azure_eyes"\r\nartist_name,1,700,"artist,artist_alias"\r\n');
assert.deepEqual(tags.map(({ name, category, frequency, aliases }) => ({ name, category, frequency, aliases })), [
{ name: 'blue_eyes', category: 0, frequency: 900, aliases: ['blue_eye', 'azure_eyes'] },
{ name: 'artist_name', category: 1, frequency: 700, aliases: ['artist', 'artist_alias'] },
]);
assert.equal(searchTags(tags, 'azure', 5)[0].value, 'blue_eyes');
});
test('legacy words, frequencies and headerless packs remain compatible', () => {
assert.deepEqual(parseTagList('low,2\nhigh,100\nplain\n').map(tag => [tag.name, tag.frequency]), [['high', 100], ['low', 2], ['plain', 0]]);
assert.equal(parseTagList('long_hair,0,6317335,')[0].frequency, 6317335);
assert.equal(parseTagList('"say""hello",0,12,"nickname"')[0].name, 'say"hello');
});
test('invalid data fails clearly instead of replacing a valid list with garbage', () => {
for (const text of ['', '<!doctype html><html>error</html>', '<script>alert(1)</script>', 'x,0,NaN,', 'x,2,12,', 'x,-1', '"unfinished', 'x,0,1,a,extra', 'x,0,1,"a"tail', 'name,category,post_count,aliases\n']) {
assert.throws(() => parseTagList(text), Error, text);
}
assert.throws(() => parseTagList('x'.repeat(32 * 1024 * 1024 + 1)), /large/i);
});
test('duplicates merge aliases while retaining the highest frequency', () => {
const tags = parseTagList('blue_eyes,0,100,azure\nblue_eyes,0,200,blue_eye');
assert.equal(tags.length, 1);
assert.equal(tags[0].frequency, 200);
assert.deepEqual(tags[0].aliases, ['azure', 'blue_eye']);
});
test('search ranks exact canonical and alias matches, prefixes, then substrings by popularity', () => {
const tags = parseTagList('blue_sky,0,100,azure\nblue_eyes,0,900,\nlight_blue,0,500,\nblue,0,1,\n');
assert.deepEqual(searchTags(tags, 'blue', 3).map(tag => tag.name), ['blue', 'blue_eyes', 'blue_sky']);
assert.equal(searchTags(tags, 'AZURE', 1)[0].name, 'blue_sky');
assert.deepEqual(searchTags(tags, 'missing', 3), []);
assert.deepEqual(searchTags(tags, '', 0), []);
assert.deepEqual(searchTags(tags, '', 2).map(tag => tag.name), ['blue_eyes', 'light_blue']);
});
test('search handles 300,000 tags with bounded results and canonical alias insertion', () => {
const text = Array.from({ length: 300000 }, (_, i) => `tag_${i},0,${i},alias_${i}`).join('\n');
const tags = parseTagList(text);
assert.equal(tags.length, 300000);
const matches = searchTags(tags, 'alias_299999', 20);
assert.equal(matches.length, 1);
assert.equal(matches[0].value, 'tag_299999');
});
test('tag data supports Unicode, emoticons and quoted commas as plain text', () => {
const tags = parseTagList('heart,0,50,<3\n>_<,0,20,\n日本語,3,10,"別名,にほんご"');
assert.equal(searchTags(tags, '<3', 1)[0].value, 'heart');
assert.equal(searchTags(tags, 'にほんご', 1)[0].value, '日本語');
assert.equal(searchTags(tags, '>_<', 1)[0].value, '>_<');
});
test('worker reports parsed tag records and errors through its message boundary', async () => {
const messages = [];
globalThis.self = { postMessage: message => messages.push(message) };
try {
await import('../../web/autocomplete/tag-worker.js');
self.onmessage({ data: 'blue_eyes,0,20,azure' });
self.onmessage({ data: 'bad,0,invalid,' });
assert.equal(messages[0].tags[0].frequency, 20);
assert.match(messages[1].error, /post count/);
} finally { delete globalThis.self; }
});
test('prompt query boundaries preserve Unicode, punctuation and surrounding weighting syntax', () => {
assert.deepEqual(tagQuery('sky, 日本'), { prefix: '日本', start: 5 });
assert.deepEqual(tagQuery('(o_o)'), { prefix: '(o_o)', start: 0 });
assert.deepEqual(tagQuery('<3'), { prefix: '<3', start: 0 });
assert.deepEqual(tagQuery('sky, (日本', true), { prefix: '日本', start: 6 });
assert.deepEqual(tagQuery('hatsune_miku_(vocaloid'), { prefix: 'hatsune_miku_(vocaloid', start: 0 });
assert.deepEqual(tagQuery('foo\nmiku/vocal'), { prefix: 'miku/vocal', start: 4 });
assert.deepEqual(tagQuery('🎨🎨'), { prefix: '🎨🎨', start: 0 });
assert.equal(tagQuery('sky, '), null);
assert.equal(tagQuery('(', true), null);
});
+47 -5
View File
@@ -1,4 +1,28 @@
{
"autocomplete/tag-download.js": {
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
},
"autocomplete/tag-storage.js": {
"lines": 98,
"branches": 98,
"functions": 74,
"statements": 98
},
"autocomplete/tag-worker.js": {
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
},
"autocomplete/tags.js": {
"lines": 98,
"branches": 95,
"functions": 98,
"statements": 98
},
"controllers/display-node.js": {
"lines": 98,
"branches": 95,
@@ -30,10 +54,10 @@
"statements": 52
},
"kikoEmbeddingAutocomplete.js": {
"lines": 44,
"branches": 43,
"functions": 59,
"statements": 44
"lines": 82,
"branches": 74,
"functions": 92,
"statements": 82
},
"kiko_custom_colors.js": {
"lines": 46,
@@ -77,6 +101,12 @@
"functions": 88,
"statements": 95
},
"ui/autocomplete-suggestions.js": {
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
},
"ui/color-picker.js": {
"lines": 98,
"branches": 94,
@@ -97,7 +127,7 @@
},
"ui/dialog.js": {
"lines": 98,
"branches": 85,
"branches": 88,
"functions": 98,
"statements": 98
},
@@ -119,12 +149,24 @@
"functions": 98,
"statements": 93
},
"ui/text-widget-adapter.js": {
"lines": 98,
"branches": 89,
"functions": 98,
"statements": 98
},
"ui/workflow-clock.js": {
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
},
"views/autocomplete-settings.js": {
"lines": 98,
"branches": 83,
"functions": 98,
"statements": 98
},
"views/image-browser.js": {
"lines": 98,
"branches": 98,
+39
View File
@@ -0,0 +1,39 @@
import { MAX_TAG_BYTES } from './tags.js';
export async function readTagResponse(response, { maxBytes = MAX_TAG_BYTES, signal } = {}) {
const tooLarge = () => new Error(`Tag file too large. Maximum size is ${maxBytes === MAX_TAG_BYTES ? '32 MB' : `${maxBytes} bytes`}.`);
if (signal?.aborted) {
await response.body?.cancel();
signal.throwIfAborted();
}
if (Number(response.headers.get('content-length')) > maxBytes) {
await response.body?.cancel();
throw tooLarge();
}
if (!response.body) return '';
const reader = response.body.getReader();
const abort = () => { reader.cancel().catch(() => {}); };
signal?.addEventListener('abort', abort, { once: true });
const decoder = new TextDecoder();
const parts = [];
let bytes = 0;
try {
while (true) {
signal?.throwIfAborted();
const { done, value } = await reader.read();
signal?.throwIfAborted();
if (done) break;
bytes += value.byteLength;
if (bytes > maxBytes) {
await reader.cancel();
throw tooLarge();
}
parts.push(decoder.decode(value, { stream: true }));
}
parts.push(decoder.decode());
return parts.join('');
} finally {
signal?.removeEventListener('abort', abort);
reader.releaseLock();
}
}
+36
View File
@@ -0,0 +1,36 @@
export function tagCache(value) {
return new Promise((resolve, reject) => {
const request = indexedDB.open('kikotools-autocomplete', 1);
request.onupgradeneeded = () => request.result.createObjectStore('tags');
request.onerror = () => reject(request.error);
let blocked = false;
request.onblocked = () => { blocked = true; reject(new Error('Tag storage is blocked by another browser tab.')); };
request.onsuccess = () => {
const db = request.result;
if (blocked) { db.close(); return; }
db.onversionchange = () => db.close();
const transaction = db.transaction('tags', value === undefined ? 'readonly' : 'readwrite');
const store = transaction.objectStore('tags');
const result = value === undefined ? store.get('active') : store.put(value, 'active');
transaction.oncomplete = () => { db.close(); resolve(result.result); };
transaction.onabort = transaction.onerror = () => { db.close(); reject(transaction.error || new Error('Could not access tag storage.')); };
};
});
}
export function parseTagFile(text, signal) {
return new Promise((resolve, reject) => {
if (signal.aborted) { reject(new DOMException('Import cancelled.', 'AbortError')); return; }
const worker = new Worker(new URL('./tag-worker.js', import.meta.url), { type: 'module' });
const finish = (callback, value) => {
worker.terminate();
signal.removeEventListener('abort', abort);
callback(value);
};
const abort = () => finish(reject, new DOMException('Import cancelled.', 'AbortError'));
signal.addEventListener('abort', abort, { once: true });
worker.onmessage = ({ data }) => data.error ? finish(reject, new Error(data.error)) : finish(resolve, data.tags);
worker.onerror = () => finish(reject, new Error('Could not parse tag file.'));
worker.postMessage(text);
});
}
+5
View File
@@ -0,0 +1,5 @@
import { parseTagList } from './tags.js';
self.onmessage = ({ data }) => {
try { self.postMessage({ tags: parseTagList(data) }); }
catch (error) { self.postMessage({ error: error.message }); }
};
+86
View File
@@ -0,0 +1,86 @@
export const MAX_TAG_BYTES = 32 * 1024 * 1024;
function* csvRows(text) {
let row = [], field = '', quoted = false, closed = false;
for (let i = 0; i <= text.length; i++) {
const char = text[i];
if (quoted) {
if (char === undefined) throw new Error('Unclosed CSV quote.');
if (char === '"') {
if (text[i + 1] === '"') { field += '"'; i++; }
else { quoted = false; closed = true; }
} else field += char;
} else if (char === ',' || char === '\n' || char === undefined) {
row.push(field.trim());
field = ''; closed = false;
if (char !== ',') {
if (row.some(value => value !== '')) yield row;
row = [];
}
} else if (char === '\r' && (text[i + 1] === '\n' || i === text.length - 1)) {
continue;
} else if (char === '"' && !field && !closed) quoted = true;
else {
if (closed && char.trim()) throw new Error('Unexpected text after CSV quote.');
field += char;
}
}
}
export function parseTagList(text) {
if (new TextEncoder().encode(text).byteLength > MAX_TAG_BYTES) throw new Error('Tag file too large. Maximum size is 32 MB.');
if (/^\s*<(?:!doctype|html|script|body|iframe)\b/i.test(text)) throw new Error('Expected a tag list, received HTML or script content.');
const unique = new Map();
let first = true;
for (const row of csvRows(text.replace(/^\ufeff/, ''))) {
if (first && ['tag', 'name'].includes(row[0].toLowerCase()) && ['category', 'frequency', 'post_count'].includes(row[1]?.toLowerCase())) {
first = false; continue;
}
first = false;
if (![1, 2, 4].includes(row.length)) throw new Error('Expected tag,frequency or tag,category,post_count,aliases.');
const name = row[0];
if (!name || name.length > 500 || /[\u0000-\u001f\u007f]/.test(name)) throw new Error('Invalid tag name.');
const count = row.length === 4 ? row[2] : row[1] || '0';
if (!/^\d+$/.test(count) || !Number.isSafeInteger(Number(count))) throw new Error(`Invalid post count for ${name}.`);
const category = row.length === 4 ? Number(row[1]) : null;
if (row.length === 4 && (!/^[01345]$/.test(row[1]))) throw new Error(`Invalid category for ${name}.`);
const aliases = row.length === 4 ? [...new Set(row[3].split(',').map(value => value.trim()).filter(Boolean))] : [];
if (aliases.some(alias => alias.length > 500 || /[\u0000-\u001f\u007f]/.test(alias))) throw new Error(`Invalid alias for ${name}.`);
const existing = unique.get(name);
if (existing) {
existing.frequency = Math.max(existing.frequency, Number(count));
existing.aliases = [...new Set([...existing.aliases, ...aliases])];
} else unique.set(name, { name, category, frequency: Number(count), aliases });
}
if (!unique.size) throw new Error('No tags found in the file.');
for (const tag of unique.values()) tag.searchText = [tag.name, ...tag.aliases].join('\n').toLowerCase();
return [...unique.values()].sort((a, b) => b.frequency - a.frequency || a.name.localeCompare(b.name));
}
export function searchTags(tags, prefix, limit) {
if (limit <= 0) return [];
prefix = prefix.toLowerCase();
const buckets = [[], [], []];
for (const tag of tags) {
if (tag.searchText && !tag.searchText.includes(prefix)) continue;
const names = [tag.name, ...(tag.aliases || [])];
let tier = 3;
for (const name of names) {
const lower = name.toLowerCase();
if (lower === prefix) { tier = 0; break; }
if (lower.startsWith(prefix)) tier = Math.min(tier, 1);
else if (lower.includes(prefix)) tier = Math.min(tier, 2);
}
if (tier < 3 && buckets[tier].length < limit) buckets[tier].push(tag);
if (buckets[0].length >= limit) break;
}
return buckets.flat().slice(0, limit).map(tag => ({ ...tag, fullPath: tag.name, type: 'custom', display: tag.name, value: tag.name }));
}
export function tagQuery(text, weighted = false) {
const match = text.match(/[^\s,]+$/u);
if (!match) return null;
const leading = weighted ? match[0].match(/^[\[(]*/)[0].length : 0;
const prefix = match[0].slice(leading);
return prefix ? { prefix, start: match.index + leading } : null;
}
File diff suppressed because it is too large Load Diff
+49
View File
@@ -141,3 +141,52 @@ Legacy widget values remain readable: converted input prefixes, prompt header
pairs, native Display Any modes and all saved output chunks are recognized.
The new output DOM widget does not serialize presentation state into native
widget values. Node removal disposes the view, its listeners and theme observers.
## Embedding Autocomplete
`autocomplete-suggestions.js` supplies the shared themed listbox, type badges,
selected state and viewport placement. The controller retains resource fetching,
matching, sorting, triggers and insertion preferences. Focus stays in the native
textarea; arrow keys select suggestions, configured Enter/Tab keys insert, and
Escape dismisses. New input clears the previous selection. Completion names are
rendered as text, including directory names, without interpreting markup.
`text-widget-adapter.js` binds editable Nodes 2.0 replacement textareas through
their node ID and associated label. It leaves the original widget/input intact,
cleans up disconnected replacements, and preserves Classic behavior when switching
back. Read-only and disabled fields are excluded. Each widget owns its listeners,
blur work and temporary accessibility attributes; removing a different widget
does not dismiss the active list. Page cleanup cancels pending refresh work.
`../views/autocomplete-settings.js` uses the shared native dialog and switches.
Only Save updates the existing `kikotools.embedding_autocomplete.settings` key;
Cancel/Escape discards edits. All advanced saved preferences are retained.
Storage failures keep the dialog open and restore the previous live preferences.
The settings launcher owns dialog cleanup, tolerates synchronous reparenting and
returns keyboard focus when the modal closes. The shared modal shell contains
pointer events so nested dialogs do not dismiss ComfyUI's parent settings panel.
### Tag packs and autocomplete preferences
The settings view stages CSV/TXT file and URL imports until Save Settings.
`autocomplete/tags.js` parses quoted four-column Danbooru data and legacy
word/frequency lists, validates counts/categories, deduplicates tags and aliases,
and limits search results without sorting every matching tag on each keystroke.
`autocomplete/tag-worker.js` performs parsing outside the UI thread; its owner
terminates it on completion, error, or dialog removal. Records expand to the
existing suggestion shape only for returned results.
`autocomplete/tag-storage.js` persists the active source text in browser
IndexedDB. The controller restores matching cached sources before requesting a
URL, guards stale loads, and retains the active list when an import fails.
Advanced trigger, matching and insertion controls use the existing settings key.
For dataset provenance, format and installation, see
[Tag packs](../../docs/tag-packs/README.md).
Node removal uses one hook per node, shared by its active text widgets. Detaching
widgets in any order restores the original hook after the last widget leaves.
General queries accept Unicode and punctuation between whitespace/comma
boundaries. Literal bracketed names are tried before interpreting leading
parentheses/brackets as prompt weighting; insertion preserves surrounding
weight syntax. URL downloads enforce their byte limit while reading the body,
cancel oversized/aborted streams, and decode UTF-8 across chunk boundaries.
+73
View File
@@ -0,0 +1,73 @@
import { createScope, mountTheme } from './core.js';
let nextList = 0;
export function suggestionPosition(field, popup, viewport) {
const left = Math.max(8, Math.min(field.left, viewport.width - popup.width - 8));
const below = field.bottom + 4;
const top = below + popup.height <= viewport.height - 8 ? below : field.top - popup.height - 4;
return { left, top: Math.max(8, Math.min(top, viewport.height - popup.height - 8)) };
}
export function createSuggestionView(scope, settings) {
const element = document.createElement('div');
element.className = 'ca-tools ca-completions kikotools-autocomplete-suggestions';
element.id = `kiko-completions-${++nextList}`;
element.role = 'listbox';
element.setAttribute('aria-label', 'Prompt completions');
element.hidden = true;
document.body.append(element);
mountTheme(element, settings, scope);
let optionsScope;
scope.cleanup(() => { optionsScope?.dispose(); element.remove(); });
return {
element,
hide() {
optionsScope?.dispose();
element.hidden = true;
element.replaceChildren();
},
render(suggestions, onSelect, onInsert) {
optionsScope?.dispose();
optionsScope = createScope();
element.replaceChildren();
suggestions.forEach((item, index) => {
const option = document.createElement('div');
option.className = 'kikotools-autocomplete-item';
option.id = `${element.id}-${index}`;
option.role = 'option';
option.setAttribute('aria-selected', 'false');
const badge = document.createElement('span');
badge.className = 'ca-completion-type';
badge.textContent = item.type === 'embedding' ? '[EMB]' : item.type === 'lora' ? '[LORA]' : '[TAG]';
const name = document.createElement('span');
name.className = 'ca-completion-name';
const path = item.type === 'embedding' ? item.fullPath : null;
if (path?.includes('/')) {
const directory = document.createElement('span');
directory.className = 'ca-completion-directory';
directory.textContent = path.slice(0, path.lastIndexOf('/') + 1);
name.append(directory, document.createTextNode(path.slice(path.lastIndexOf('/') + 1)));
} else name.textContent = item.name;
option.append(badge, name);
optionsScope.listen(option, 'pointerdown', event => event.preventDefault());
optionsScope.listen(option, 'pointermove', () => onSelect(index));
optionsScope.listen(option, 'click', () => onInsert(item));
element.append(option);
});
element.hidden = false;
},
select(index) {
const options = [...element.children];
options.forEach((option, position) => option.setAttribute('aria-selected', String(position === index)));
options[index]?.scrollIntoView({ block: 'nearest' });
return options[index]?.id;
},
position(field) {
const popup = element.getBoundingClientRect();
const { left, top } = suggestionPosition(field, popup, { width: innerWidth, height: innerHeight });
element.style.left = `${left}px`;
element.style.top = `${top}px`;
},
};
}
+27
View File
@@ -201,3 +201,30 @@
.ca-text-output[data-output-kind="any"] textarea { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
.ca-text-output > p { font-size: 11px; color: var(--ca-muted); min-block-size: 17px; margin: 0; }
.ca-text-output > p[data-error="true"] { color: var(--ca-error-text); }
.ca-autocomplete-launcher { display: inline-flex; background: transparent; }
.ca-autocomplete-settings form { display: flex; flex-direction: column; gap: 12px; }
.ca-autocomplete-settings .ca-switch { justify-content: flex-start; }
.ca-completions { position: fixed; z-index: 10000; min-inline-size: min(240px, calc(100dvw - 16px)); max-inline-size: min(420px, calc(100dvw - 16px)); max-block-size: min(240px, calc(100dvh - 16px)); overflow: auto; border: 1px solid var(--ca-border); border-radius: 6px; box-shadow: 0 6px 20px #0005; box-sizing: border-box; }
.ca-completions[hidden] { display: none; }
.ca-completions .kikotools-autocomplete-item { display: flex; align-items: flex-start; gap: 8px; padding: 7px 10px; min-block-size: 32px; cursor: pointer; }
.ca-completion-type { flex-shrink: 0; font-size: 11px; color: var(--ca-focus); min-inline-size: 44px; }
.ca-completion-name { min-inline-size: 0; overflow-wrap: anywhere; }
.ca-completion-directory { color: var(--ca-muted); }
.ca-completions [aria-selected="true"] { background: var(--ca-accent); color: #fff; outline: 2px solid var(--ca-focus); outline-offset: -2px; }
.ca-completions [aria-selected="true"] :where(.ca-completion-type, .ca-completion-directory) { color: inherit; }
@media (pointer: coarse) { .ca-completions .kikotools-autocomplete-item { min-block-size: 40px; } }
.ca-autocomplete-settings { inline-size: 520px; }
.ca-autocomplete-settings fieldset { min-inline-size: 0; margin: 0; padding: 12px; border: 1px solid var(--ca-line); border-radius: 6px; display: flex; flex-direction: column; gap: 10px; }
.ca-autocomplete-settings legend { padding-inline: 4px; font-size: 12px; font-weight: 550; }
.ca-autocomplete-settings .ca-field { display: flex; flex-direction: column; align-items: stretch; gap: 5px; }
.ca-autocomplete-settings input { min-inline-size: 0; max-inline-size: 100%; box-sizing: border-box; }
.ca-autocomplete-settings input[type="file"] { inline-size: 100%; }
.ca-autocomplete-settings .ca-dialog-hint { margin: 0; }
.ca-autocomplete-settings a { color: var(--ca-focus); font-size: 12px; }
.ca-autocomplete-settings summary { cursor: pointer; font-size: 12px; padding-block: 6px; }
.ca-autocomplete-settings details[open] { display: flex; flex-direction: column; gap: 12px; }
.ca-autocomplete-settings .ca-tag-status { margin: 0; min-block-size: 18px; font-size: 12px; overflow-wrap: anywhere; color: var(--ca-muted); }
.ca-autocomplete-settings .ca-tag-status[data-error="true"] { color: var(--ca-error-text); }
.ca-autocomplete-settings .ca-tag-source { overflow-wrap: anywhere; }
+1
View File
@@ -42,6 +42,7 @@ export function createDialog({ title, settings, fallbackFocus, onCancel }) {
});
scope.listen(element, 'keydown', event => event.stopPropagation());
scope.listen(element, 'keyup', event => event.stopPropagation());
for (const event of ['pointerdown', 'mousedown', 'click']) scope.listen(element, event, event => event.stopPropagation());
return {
element, scope, body, actions,
close: element.close,
+33
View File
@@ -0,0 +1,33 @@
export function watchRendererTextareas(scope, graph, autocomplete) {
const bridges = new Map();
const detachMissing = () => {
for (const [textarea, bridge] of bridges) {
if (textarea.isConnected) continue;
autocomplete.detachFromWidget(bridge);
bridges.delete(textarea);
}
};
scope.listen(document, 'focusin', event => {
const textarea = event.target;
if (!(textarea instanceof HTMLTextAreaElement) || textarea.readOnly || textarea.disabled) return;
const owner = textarea.closest('[node-id]');
const node = owner && graph.getNodeById(owner.getAttribute('node-id'));
if (!node) return;
const label = textarea.labels?.[0]?.textContent.trim();
const widget = node.widgets?.find(widget => (widget.label || widget.name) === label && widget.inputEl?.tagName === 'TEXTAREA');
if (!widget || widget.inputEl === textarea || widget.inputEl.readOnly) return;
let bridge = bridges.get(textarea);
if (!bridge) {
bridge = { inputEl: textarea };
bridges.set(textarea, bridge);
}
autocomplete.attachToWidget(bridge, node);
});
const observer = new MutationObserver(detachMissing);
observer.observe(document.body, { childList: true, subtree: true });
scope.cleanup(() => {
observer.disconnect();
for (const bridge of bridges.values()) autocomplete.detachFromWidget(bridge);
bridges.clear();
});
}
+185
View File
@@ -0,0 +1,185 @@
import { createButton, createField, createScope, createSwitch, mountTheme } from '../ui/core.js';
import { createDialog } from '../ui/dialog.js';
import { MAX_TAG_BYTES } from '../autocomplete/tags.js';
import { parseTagFile } from '../autocomplete/tag-storage.js';
export function openAutocompleteSettings(autocomplete, settings, fallbackFocus) {
const existing = autocomplete.settingsDialog;
if (existing && !existing.scope.signal.aborted) {
existing.element.querySelector('input').focus();
return existing;
}
autocomplete.hideSuggestions();
const dialog = createDialog({ title: 'Embedding Autocomplete Settings', settings, fallbackFocus });
autocomplete.settingsDialog = dialog;
dialog.element.classList.add('ca-autocomplete-settings');
const form = document.createElement('form');
form.id = `${dialog.element.id}-form`;
const fields = new Map();
const group = (title, parent = form) => {
const fieldset = document.createElement('fieldset');
const legend = document.createElement('legend');
legend.textContent = title;
fieldset.append(legend);
parent.append(fieldset);
return fieldset;
};
const addSwitch = (parent, key, label) => {
const field = createSwitch(label, '', `${form.id}-${key}`);
const input = field.querySelector('input');
input.checked = autocomplete.settings[key] !== false;
fields.set(key, input);
parent.append(field);
};
const addInput = (parent, key, label, type = 'text', limits = {}) => {
const input = document.createElement('input');
Object.assign(input, { type, value: autocomplete.settings[key] ?? '', ...limits });
fields.set(key, input);
parent.append(createField(label, input, `${form.id}-${key}`));
return input;
};
const sources = group('Sources');
for (const [key, label] of [['enabled', 'Enable Autocomplete'], ['showEmbeddings', 'Show Embeddings'], ['showLoras', 'Show LoRAs'], ['showCustomWords', 'Show Custom Words']]) addSwitch(sources, key, label);
const tags = group('Tags and custom words');
const count = document.createElement('p');
count.className = 'ca-dialog-hint';
count.textContent = `${autocomplete.customWords.length.toLocaleString()} tags loaded`;
const hint = document.createElement('p');
hint.className = 'ca-dialog-hint';
hint.textContent = 'Import CSV or TXT, or load a URL. Lists replace the current tags when you save. Supports tag,frequency and tag,category,post_count,aliases. Maximum 32 MB.';
const pack = document.createElement('a');
pack.href = 'https://github.com/ComfyAssets/ComfyUI-KikoTools/releases/tag/tags-2026-10-16';
pack.target = '_blank';
pack.rel = 'noopener noreferrer';
pack.textContent = 'Download the Danbooru tag pack';
const source = document.createElement('p');
source.className = 'ca-dialog-hint ca-tag-source';
source.textContent = `Current source: ${(autocomplete.settings.tagSource || autocomplete.settings.customWordsUrl || 'None').replace(/^file:/, '')}`;
tags.append(count, source, hint, pack);
const url = addInput(tags, 'customWordsUrl', 'Tag list URL', 'url');
const load = createButton('Load URL');
const file = document.createElement('input');
file.type = 'file';
file.accept = '.csv,.txt,text/csv,text/plain';
tags.append(load, createField('Tag list file', file, `${form.id}-file`));
const advanced = document.createElement('details');
const summary = document.createElement('summary');
summary.textContent = 'Triggers, matching and insertion';
advanced.append(summary);
form.append(advanced);
const matching = group('Triggers and matching', advanced);
addInput(matching, 'embeddingTrigger', 'Embedding trigger');
addInput(matching, 'loraTrigger', 'LoRA trigger');
addInput(matching, 'quickTrigger', 'Quick embedding trigger');
addInput(matching, 'minChars', 'Minimum characters', 'number', { min: 1, max: 10, step: 1, required: true });
addInput(matching, 'maxSuggestions', 'Maximum suggestions', 'number', { min: 1, max: 100, step: 1, required: true });
addSwitch(matching, 'sortByDirectory', 'Group by directory');
const insertion = group('Insertion', advanced);
for (const [key, label] of [['autoInsertComma', 'Auto-insert comma'], ['replaceUnderscores', 'Replace underscores with spaces'], ['insertOnTab', 'Insert on Tab'], ['insertOnEnter', 'Insert on Enter']]) addSwitch(insertion, key, label);
const status = document.createElement('p');
status.role = 'status';
status.className = 'ca-tag-status';
status.textContent = autocomplete.customWordsError || '';
tags.append(status);
dialog.body.append(form);
const cancel = createButton('Cancel');
const save = createButton('Save Settings', 'ca-primary');
save.type = 'submit';
save.setAttribute('form', form.id);
dialog.actions.append(cancel, save);
let pending, busy = false;
const prepare = async callback => {
busy = true;
load.disabled = file.disabled = save.disabled = true;
status.dataset.error = 'false';
status.textContent = 'Loading tags…';
try {
const result = await callback();
if (dialog.scope.signal.aborted) return;
pending = result;
status.textContent = `${pending.tags.length.toLocaleString()} tags ready. Save Settings to apply.`;
} catch (error) {
if (dialog.scope.signal.aborted) return;
status.dataset.error = 'true';
status.textContent = `Could not load tags: ${error.message}`;
} finally {
busy = false;
load.disabled = file.disabled = save.disabled = false;
}
};
dialog.scope.listen(load, 'click', () => {
if (!url.value.trim() || !url.reportValidity()) {
status.dataset.error = 'true';
status.textContent = 'Enter a valid tag-list URL.';
return;
}
prepare(() => autocomplete.fetchTagList(url.value.trim(), dialog.scope.signal));
});
dialog.scope.listen(file, 'change', () => {
const selected = file.files[0];
if (!selected) return;
prepare(async () => {
if (selected.size > MAX_TAG_BYTES) throw new Error('Tag file too large. Maximum size is 32 MB.');
const text = await selected.text();
const tags = await parseTagFile(text, dialog.scope.signal);
return { text, tags, source: `file:${selected.name}` };
});
file.value = '';
});
dialog.scope.listen(cancel, 'click', () => dialog.close('cancel'));
dialog.scope.listen(form, 'submit', async event => {
event.preventDefault();
if (busy) return;
const next = {};
for (const [key, input] of fields) next[key] = input.type === 'checkbox' ? input.checked : input.type === 'number' ? Number(input.value) : input.value.trim();
next.embeddingTrigger ||= 'embedding:';
next.loraTrigger ||= '<lora:';
next.tagSource = pending?.source || (next.customWordsUrl !== autocomplete.settings.customWordsUrl ? next.customWordsUrl : autocomplete.settings.tagSource);
save.disabled = true;
try {
await autocomplete.saveTagSettings(next, pending);
dialog.close('save');
} catch (error) {
status.dataset.error = 'true';
status.textContent = error.message;
save.disabled = false;
}
});
dialog.scope.cleanup(() => { if (autocomplete.settingsDialog === dialog) autocomplete.settingsDialog = null; });
dialog.open(fields.get('enabled'));
return dialog;
}
export function createAutocompleteLauncher(autocomplete, settings, fallbackFocus) {
if (!customElements.get('kiko-autocomplete-launcher')) {
customElements.define('kiko-autocomplete-launcher', class extends HTMLElement {
connectedCallback() {
if (this.scope && !this.scope.signal.aborted) return;
this.scope = createScope();
this.className = 'ca-tools ca-autocomplete-launcher';
mountTheme(this, this.settings, this.scope);
const button = createButton('Open Settings', 'ca-primary');
this.replaceChildren(button);
this.scope.listen(button, 'click', () => {
this.dialog = openAutocompleteSettings(this.autocomplete, this.settings, this.fallbackFocus);
this.dialog.owner = this;
});
this.scope.cleanup(() => { if (this.dialog?.owner === this) this.dialog.close('removed'); });
}
disconnectedCallback() {
const scope = this.scope;
queueMicrotask(() => { if (!this.isConnected) scope.dispose(); });
}
});
}
const row = document.createElement('tr');
row.className = 'kikotools-settings-row';
const label = document.createElement('td');
label.textContent = 'Embedding Autocomplete Configuration';
const value = document.createElement('td');
const launcher = document.createElement('kiko-autocomplete-launcher');
Object.assign(launcher, { autocomplete, settings, fallbackFocus });
value.append(launcher);
row.append(label, value);
return row;
}