fix(autocomplete): reduce tag search overhead (#895)

This commit is contained in:
Will Miao
2026-04-15 20:42:33 +08:00
parent 62247bdd87
commit 4514ca94b7
7 changed files with 475 additions and 75 deletions

View File

@@ -126,6 +126,31 @@ describe('AutoComplete widget interactions', () => {
expect(caretHelperInstance.getCursorOffset).toHaveBeenCalled();
});
it('deduplicates duplicate-equivalent query variations before issuing requests', async () => {
vi.useFakeTimers();
fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({ success: true, words: [] }),
});
caretHelperInstance.getBeforeCursor.mockReturnValue('Example');
const input = document.createElement('textarea');
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
new AutoComplete(input, 'prompt', { debounceDelay: 0, showPreview: false, minChars: 1 });
input.value = 'Example';
input.dispatchEvent(new Event('input', { bubbles: true }));
await vi.runAllTimersAsync();
await Promise.resolve();
expect(fetchApiMock).toHaveBeenCalledTimes(1);
expect(fetchApiMock).toHaveBeenCalledWith('/lm/custom-words/search?enriched=true&search=Example&limit=100');
});
it('inserts the selected LoRA with usage tip strengths and restores focus', async () => {
fetchApiMock.mockImplementation((url) => {
if (url.includes('usage-tips-by-path')) {
@@ -244,6 +269,55 @@ describe('AutoComplete widget interactions', () => {
expect(inputListener).not.toHaveBeenCalled();
});
it('shows the full command list when typing a single slash', async () => {
const input = document.createElement('textarea');
input.value = '/';
input.selectionStart = input.value.length;
document.body.append(input);
caretHelperInstance.getBeforeCursor.mockReturnValue('/');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input,'prompt', { showPreview: false, minChars: 1 });
input.dispatchEvent(new Event('input', { bubbles: true }));
const commandNames = autoComplete.items.map((item) => item.command);
expect(commandNames).toContain('/character');
expect(commandNames).toContain('/char');
expect(commandNames).toContain('/artist');
expect(commandNames).toContain('/general');
expect(commandNames).toContain('/copyright');
expect(commandNames).toContain('/meta');
expect(commandNames).toContain('/species');
expect(commandNames).toContain('/lore');
expect(commandNames).toContain('/emb');
expect(commandNames).toContain('/embedding');
expect(commandNames).toContain('/wild');
expect(commandNames).toContain('/wildcard');
});
it('renders every command item when slash opens the command list', async () => {
const input = document.createElement('textarea');
input.value = '/';
input.selectionStart = input.value.length;
document.body.append(input);
caretHelperInstance.getBeforeCursor.mockReturnValue('/');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'prompt', { showPreview: false, minChars: 1 });
input.dispatchEvent(new Event('input', { bubbles: true }));
const renderedCommands = autoComplete.contentContainer.querySelectorAll('.lm-autocomplete-command-name');
expect(renderedCommands).toHaveLength(autoComplete.items.length);
});
it('accepts the selected suggestion with Enter', async () => {
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
@@ -1095,6 +1169,7 @@ describe('AutoComplete widget interactions', () => {
minChars: 1,
});
fetchApiMock.mockClear();
input.dispatchEvent(new Event('input', { bubbles: true }));
await vi.runAllTimersAsync();
await Promise.resolve();
@@ -1133,6 +1208,61 @@ describe('AutoComplete widget interactions', () => {
expect(input.setSelectionRange).toHaveBeenCalled();
});
it('does not reopen autocomplete on blur after inserting a wildcard literal', async () => {
const input = document.createElement('textarea');
input.value = '__flower__,';
input.selectionStart = input.value.length;
document.body.append(input);
caretHelperInstance.getBeforeCursor.mockReturnValue('__flower__,');
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input,'prompt', {
debounceDelay: 0,
showPreview: false,
minChars: 1,
});
const hideSpy = vi.spyOn(autoComplete, 'hide');
input.dispatchEvent(new Event('blur', { bubbles: true }));
expect(fetchApiMock).not.toHaveBeenCalled();
expect(hideSpy).toHaveBeenCalled();
expect(autoComplete.isVisible).toBe(false);
});
it('treats a command after a wildcard literal as the active token', async () => {
vi.useFakeTimers();
fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({
success: true,
words: [{ tag_name: 'flower_field', category: 4, post_count: 1234 }],
}),
});
const input = document.createElement('textarea');
input.value = '__flower__ /character f';
input.selectionStart = input.value.length;
document.body.append(input);
caretHelperInstance.getBeforeCursor.mockReturnValue('__flower__ /character f');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input,'prompt', {
debounceDelay: 0,
showPreview: false,
minChars: 1,
});
input.dispatchEvent(new Event('input', { bubbles: true }));
await vi.runAllTimersAsync();
await Promise.resolve();
expect(autoComplete.getSearchTerm(input.value)).toBe('/character f');
});
it('invalidates stale autocomplete metadata and falls back to delimiter-based matching', async () => {
settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.autocomplete_append_comma') {

View File

@@ -94,6 +94,19 @@ class TestCustomWordsService:
results = service.search_words("test")
assert mock_tag_index.called
def test_search_words_skips_prompt_like_queries(self):
service = CustomWordsService.__new__(CustomWordsService)
mock_tag_index = MockTagFTSIndex()
def mock_get_index():
return mock_tag_index
service._get_tag_index = mock_get_index
results = service.search_words("__flower__ /character f")
assert results == []
assert mock_tag_index.called is False
class MockTagFTSIndex:
"""Mock TagFTSIndex for testing."""

View File

@@ -1,6 +1,7 @@
"""Tests for TagFTSIndex functionality."""
import os
import sqlite3
import tempfile
from typing import List
@@ -173,6 +174,40 @@ class TestTagFTSIndexSearch:
assert len(results) >= 1
assert all(r["category"] in [4, 11] for r in results)
def test_search_with_category_filter_uses_fts_first_plan(self, populated_fts):
"""Category-filtered searches should start from FTS hits, not category scans."""
sql, params = populated_fts._build_search_statement(
query_lower="f",
fts_query="f*",
categories=[4, 11],
limit=20,
offset=0,
)
conn = sqlite3.connect(f"file:{populated_fts.get_database_path()}?mode=ro", uri=True)
try:
plan_rows = conn.execute(f"EXPLAIN QUERY PLAN {sql}", params).fetchall()
finally:
conn.close()
plan_details = [row[3] for row in plan_rows]
assert any(detail.startswith("SCAN tag_fts VIRTUAL TABLE INDEX") for detail in plan_details)
assert any("SEARCH t USING INTEGER PRIMARY KEY" in detail for detail in plan_details)
assert not any("SEARCH t USING INDEX idx_tags_category" in detail for detail in plan_details)
def test_search_statement_uses_post_count_as_tie_breaker(self, populated_fts):
"""Search ranking should use popularity as a secondary sort key."""
sql, _ = populated_fts._build_search_statement(
query_lower="f",
fts_query="f*",
categories=[4, 11],
limit=20,
offset=0,
)
assert "ORDER BY is_tag_name_match DESC, t.post_count DESC, rank_score DESC" in sql
assert "LOG10" not in sql
def test_search_with_category_filter_excludes_others(self, populated_fts):
"""Test that category filter excludes other categories."""
# Search for "hi" but only in general category