Merge pull request #98 from ComfyAssets/feat/shared-embedding-autocomplete
feat(autocomplete): unify UI and restore tag management
This commit is contained in:
@@ -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 |
@@ -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"
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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; }
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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 }); }
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
+216
-605
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
|
||||
@@ -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`;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user