baqu2213 commited on
Commit
136d658
·
verified ·
1 Parent(s): 4a8983d

Upload 3 files

Browse files
ui/components/prompt_tabs.py CHANGED
@@ -27,6 +27,7 @@ def create_prompt_tabs():
27
  - Tab 5: Auto-hide (tag removal patterns)
28
  """
29
  with gr.Tabs(elem_id="naia-prompt-tabs", elem_classes=["prompt-tabs"]) as prompt_tabs:
 
30
  # Tab 1: Positive Prompt
31
  with gr.Tab("Positive", id="positive"):
32
  positive_prompt = gr.Textbox(
@@ -37,6 +38,19 @@ def create_prompt_tabs():
37
  elem_id="naia-positive-prompt"
38
  )
39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  # Tab 2: Negative Prompt
41
  with gr.Tab("Negative (UC)", id="negative"):
42
  negative_prompt = gr.Textbox(
 
27
  - Tab 5: Auto-hide (tag removal patterns)
28
  """
29
  with gr.Tabs(elem_id="naia-prompt-tabs", elem_classes=["prompt-tabs"]) as prompt_tabs:
30
+
31
  # Tab 1: Positive Prompt
32
  with gr.Tab("Positive", id="positive"):
33
  positive_prompt = gr.Textbox(
 
38
  elem_id="naia-positive-prompt"
39
  )
40
 
41
+ with gr.Accordion("Random Image Generation Guide", open=False, elem_id="naia-random-guide"):
42
+ gr.Markdown(
43
+ "NAIA-WEB-Lite features a workflow where once you set your target prompt using Quick Search, you can generate random prompt-based images using the **[🎰 Random + Generate ]** button.\n\n"
44
+ "1. **The prompt area is volatile.** This space is merely for briefly displaying random prompts. You should modify the prompt in this area only when you like the composition after clicking \"Generate\" and want to create a better image.\n\n"
45
+ "2. **Pre-prompt and Post-prompt are non-volatile.** They are stored in your browser's LocalStorage. Every time an image is generated, it is combined in the format `[Person Count] + [Pre-prompt] + [Random Prompt - Auto-hide] + [Post-prompt]` and sent to the Novel AI API server.\n\n"
46
+ "3. Generally, it is recommended to place **artist tags in [Pre-prompt]**, and **background, various style tags, and quality tags in [Post-prompt]**."
47
+ )
48
+ gr.Image(
49
+ value="src/guide_4.png",
50
+ show_label=False,
51
+ container=False
52
+ )
53
+
54
  # Tab 2: Negative Prompt
55
  with gr.Tab("Negative (UC)", id="negative"):
56
  negative_prompt = gr.Textbox(
ui/components/settings_panel.py CHANGED
@@ -217,7 +217,8 @@ LOCALSTORAGE_JS = """
217
  window.NAIAGenerate = {
218
  start: startGenerateTimer,
219
  stop: stopGenerateTimer,
220
- getAvgTime: getAverageResponseTime
 
221
  };
222
 
223
  // Token management functions
@@ -468,7 +469,10 @@ LOCALSTORAGE_JS = """
468
  // Disable Autocomplete
469
  disable_autocomplete: getInputValue('#naia-disable-autocomplete', 'checkbox'),
470
  // Global Ban
471
- global_ban: getInputValue('#naia-global-ban')
 
 
 
472
  };
473
 
474
  // Merge: use new value if not null, otherwise keep existing
@@ -995,6 +999,26 @@ LOCALSTORAGE_JS = """
995
  // Store token info for later use in insertion
996
  this.currentTokenInfo = tokenInfo;
997
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
998
  if (token.length < this.MIN_CHARS) {
999
  this.close();
1000
  return;
@@ -1014,6 +1038,49 @@ LOCALSTORAGE_JS = """
1014
  }
1015
  },
1016
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1017
  // Receive results from Gradio (called from JS parameter)
1018
  setResults(results) {
1019
  if (!results || results.length === 0) {
@@ -1044,15 +1111,21 @@ LOCALSTORAGE_JS = """
1044
  let categoryLabel = '';
1045
  if (category === 'artist') {
1046
  categoryColor = '#2196F3';
1047
- categoryLabel = '🎨';
1048
  } else if (category === 'character') {
1049
  categoryColor = '#4CAF50';
1050
- categoryLabel = '👤';
 
 
 
1051
  }
1052
 
1053
  // Format count
1054
  let countStr;
1055
- if (count >= 1000000) {
 
 
 
1056
  countStr = (count / 1000000).toFixed(1) + 'M';
1057
  } else if (count >= 1000) {
1058
  countStr = Math.round(count / 1000) + 'k';
@@ -1118,10 +1191,13 @@ LOCALSTORAGE_JS = """
1118
 
1119
  const [tag, count, category] = this.currentResults[index];
1120
 
1121
- // Add "artist:" prefix for artist tags
1122
  let insertTag = tag;
1123
  if (category === 'artist') {
1124
  insertTag = 'artist:' + tag;
 
 
 
1125
  }
1126
 
1127
  // Check if this is the character search input
@@ -1140,6 +1216,10 @@ LOCALSTORAGE_JS = """
1140
  searchBtn.click();
1141
  }
1142
  }, 100);
 
 
 
 
1143
  } else {
1144
  // Normal insert
1145
  this.insertTag(insertTag);
@@ -1147,6 +1227,49 @@ LOCALSTORAGE_JS = """
1147
  }
1148
  },
1149
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1150
  // Insert tag into textarea
1151
  // Handles NAI weight syntax preservation
1152
  insertTag(tag) {
@@ -1294,6 +1417,816 @@ LOCALSTORAGE_JS = """
1294
  // Start initialization after page is more likely to be ready
1295
  setTimeout(() => initAutocompleteWithRetry(1), 2000);
1296
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1297
  // ========== Output Image UI Customization ==========
1298
 
1299
  // Move download button into image container for overlay positioning
@@ -1313,11 +2246,23 @@ LOCALSTORAGE_JS = """
1313
  }
1314
  }
1315
 
 
 
 
 
 
 
 
 
 
 
 
1316
  // Initialize on page load - only load token
1317
  // Settings and Get Random Prompt are handled by app.load in Python
1318
  function startInit() {
1319
  loadToken();
1320
  setupOutputImageUI();
 
1321
  }
1322
 
1323
  // Handle various load states
 
217
  window.NAIAGenerate = {
218
  start: startGenerateTimer,
219
  stop: stopGenerateTimer,
220
+ getAvgTime: getAverageResponseTime,
221
+ isGenerating: () => generateStartTime !== null
222
  };
223
 
224
  // Token management functions
 
469
  // Disable Autocomplete
470
  disable_autocomplete: getInputValue('#naia-disable-autocomplete', 'checkbox'),
471
  // Global Ban
472
+ global_ban: getInputValue('#naia-global-ban'),
473
+ // Wildcard Mode
474
+ wildcard_mode: getInputValue('#naia-wildcard-mode', 'checkbox'),
475
+ wildcard_template: getInputValue('#naia-wildcard-template')
476
  };
477
 
478
  // Merge: use new value if not null, otherwise keep existing
 
999
  // Store token info for later use in insertion
1000
  this.currentTokenInfo = tokenInfo;
1001
 
1002
+ // Check if this is a wildcard search (starts with __)
1003
+ if (token.startsWith('__')) {
1004
+ this.isWildcardMode = true;
1005
+ const wildcardQuery = token.substring(2); // Remove leading __
1006
+
1007
+ // Search wildcard files
1008
+ const wildcardResults = this.searchWildcards(wildcardQuery);
1009
+ if (wildcardResults.length > 0) {
1010
+ this.currentResults = wildcardResults;
1011
+ this.selectedIndex = 0;
1012
+ this.renderDropdown();
1013
+ this.show();
1014
+ } else {
1015
+ this.close();
1016
+ }
1017
+ return;
1018
+ }
1019
+
1020
+ this.isWildcardMode = false;
1021
+
1022
  if (token.length < this.MIN_CHARS) {
1023
  this.close();
1024
  return;
 
1038
  }
1039
  },
1040
 
1041
+ // Search wildcard files from NAIAWildcard storage
1042
+ searchWildcards(query) {
1043
+ if (!window.NAIAWildcard) return [];
1044
+
1045
+ const results = [];
1046
+ const queryLower = query.toLowerCase();
1047
+ const categories = NAIAWildcard.categories || {};
1048
+
1049
+ for (const [category, files] of Object.entries(categories)) {
1050
+ for (const filename of Object.keys(files)) {
1051
+ // Build wildcard reference: category/filename (without .txt)
1052
+ const baseName = filename.replace(/\\.txt$/i, '');
1053
+ let wildcardRef;
1054
+ if (category === 'default') {
1055
+ wildcardRef = baseName;
1056
+ } else {
1057
+ wildcardRef = category + '/' + baseName;
1058
+ }
1059
+
1060
+ // Match against query
1061
+ if (queryLower === '' || wildcardRef.toLowerCase().includes(queryLower)) {
1062
+ const itemCount = files[filename]?.length || 0;
1063
+ // Format: [display_name, count, category_type]
1064
+ results.push([wildcardRef, itemCount, 'wildcard']);
1065
+ }
1066
+ }
1067
+ }
1068
+
1069
+ // Sort by relevance: exact prefix match first, then alphabetically
1070
+ results.sort((a, b) => {
1071
+ const aLower = a[0].toLowerCase();
1072
+ const bLower = b[0].toLowerCase();
1073
+ const aPrefix = aLower.startsWith(queryLower);
1074
+ const bPrefix = bLower.startsWith(queryLower);
1075
+
1076
+ if (aPrefix && !bPrefix) return -1;
1077
+ if (!aPrefix && bPrefix) return 1;
1078
+ return aLower.localeCompare(bLower);
1079
+ });
1080
+
1081
+ return results.slice(0, 15); // Limit to 15 results
1082
+ },
1083
+
1084
  // Receive results from Gradio (called from JS parameter)
1085
  setResults(results) {
1086
  if (!results || results.length === 0) {
 
1111
  let categoryLabel = '';
1112
  if (category === 'artist') {
1113
  categoryColor = '#2196F3';
1114
+ categoryLabel = '🎨 ';
1115
  } else if (category === 'character') {
1116
  categoryColor = '#4CAF50';
1117
+ categoryLabel = '👤 ';
1118
+ } else if (category === 'wildcard') {
1119
+ categoryColor = '#FF9800';
1120
+ categoryLabel = '🃏 ';
1121
  }
1122
 
1123
  // Format count
1124
  let countStr;
1125
+ if (category === 'wildcard') {
1126
+ // For wildcards, show item count
1127
+ countStr = count + ' items';
1128
+ } else if (count >= 1000000) {
1129
  countStr = (count / 1000000).toFixed(1) + 'M';
1130
  } else if (count >= 1000) {
1131
  countStr = Math.round(count / 1000) + 'k';
 
1191
 
1192
  const [tag, count, category] = this.currentResults[index];
1193
 
1194
+ // Format insert text based on category
1195
  let insertTag = tag;
1196
  if (category === 'artist') {
1197
  insertTag = 'artist:' + tag;
1198
+ } else if (category === 'wildcard') {
1199
+ // Wrap with __ for wildcard syntax
1200
+ insertTag = '__' + tag + '__';
1201
  }
1202
 
1203
  // Check if this is the character search input
 
1216
  searchBtn.click();
1217
  }
1218
  }, 100);
1219
+ } else if (this.isWildcardMode) {
1220
+ // Wildcard mode: replace from the __ start position
1221
+ this.insertWildcard(insertTag);
1222
+ this.close();
1223
  } else {
1224
  // Normal insert
1225
  this.insertTag(insertTag);
 
1227
  }
1228
  },
1229
 
1230
+ // Insert wildcard into textarea (replaces from __ position)
1231
+ insertWildcard(wildcardSyntax) {
1232
+ if (!this.activeTextarea) return;
1233
+
1234
+ const textarea = this.activeTextarea;
1235
+ const text = textarea.value;
1236
+ const tokenInfo = this.currentTokenInfo;
1237
+
1238
+ if (!tokenInfo) return;
1239
+
1240
+ // The tokenStart is where the full token begins (which includes __)
1241
+ const replaceStart = tokenInfo.tokenStart;
1242
+ const replaceEnd = tokenInfo.end;
1243
+
1244
+ // Build new text
1245
+ const before = text.substring(0, replaceStart);
1246
+ let after = text.substring(replaceEnd);
1247
+
1248
+ // Handle leading whitespace
1249
+ let leadingSpace = '';
1250
+ if (replaceStart > 0 && text[replaceStart - 1] === ',' && !before.endsWith(' ')) {
1251
+ leadingSpace = ' ';
1252
+ }
1253
+
1254
+ // Add comma+space suffix if appropriate
1255
+ let tailSuffix = '';
1256
+ if (!after.trim()) {
1257
+ tailSuffix = ', ';
1258
+ } else if (!after.startsWith(',') && !after.startsWith(' ')) {
1259
+ tailSuffix = ', ';
1260
+ }
1261
+
1262
+ const newText = before + leadingSpace + wildcardSyntax + tailSuffix + after;
1263
+ textarea.value = newText;
1264
+
1265
+ // Move cursor after inserted wildcard + suffix
1266
+ const newCursorPos = replaceStart + leadingSpace.length + wildcardSyntax.length + tailSuffix.length;
1267
+ textarea.setSelectionRange(newCursorPos, newCursorPos);
1268
+
1269
+ // Trigger input event to sync with Gradio
1270
+ textarea.dispatchEvent(new Event('input', { bubbles: true }));
1271
+ },
1272
+
1273
  // Insert tag into textarea
1274
  // Handles NAI weight syntax preservation
1275
  insertTag(tag) {
 
1417
  // Start initialization after page is more likely to be ready
1418
  setTimeout(() => initAutocompleteWithRetry(1), 2000);
1419
 
1420
+ // ========== Wildcard Module ==========
1421
+
1422
+ const NAIAWildcard = {
1423
+ STORAGE_KEY: 'naia_wildcards',
1424
+ TEMPLATE_KEY: 'naia_wildcard_template',
1425
+ MAX_DEPTH: 10,
1426
+
1427
+ // In-memory cache
1428
+ categories: {},
1429
+
1430
+ // Initialize from LocalStorage
1431
+ load() {
1432
+ try {
1433
+ const saved = localStorage.getItem(this.STORAGE_KEY);
1434
+ this.categories = saved ? JSON.parse(saved) : {};
1435
+ // Ensure 'default' category exists
1436
+ if (!this.categories['default']) {
1437
+ this.categories['default'] = {};
1438
+ }
1439
+ // Migrate old 'uncategorized' to 'default'
1440
+ if (this.categories['uncategorized']) {
1441
+ Object.assign(this.categories['default'], this.categories['uncategorized']);
1442
+ delete this.categories['uncategorized'];
1443
+ this.save();
1444
+ }
1445
+ } catch(e) {
1446
+ console.error('NAIA-WEB: Error loading wildcards:', e);
1447
+ this.categories = { 'default': {} };
1448
+ }
1449
+ },
1450
+
1451
+ // Save to LocalStorage
1452
+ save() {
1453
+ try {
1454
+ localStorage.setItem(this.STORAGE_KEY, JSON.stringify(this.categories));
1455
+ } catch(e) {
1456
+ console.error('NAIA-WEB: Error saving wildcards:', e);
1457
+ }
1458
+ },
1459
+
1460
+ // Get template from LocalStorage
1461
+ getTemplate() {
1462
+ try {
1463
+ return localStorage.getItem(this.TEMPLATE_KEY) || '';
1464
+ } catch(e) {
1465
+ return '';
1466
+ }
1467
+ },
1468
+
1469
+ // Save template to LocalStorage
1470
+ saveTemplate(template) {
1471
+ try {
1472
+ localStorage.setItem(this.TEMPLATE_KEY, template);
1473
+ } catch(e) {
1474
+ console.error('NAIA-WEB: Error saving template:', e);
1475
+ }
1476
+ },
1477
+
1478
+ // Get random item from array
1479
+ _randomChoice(arr) {
1480
+ return arr[Math.floor(Math.random() * arr.length)];
1481
+ },
1482
+
1483
+ // Resolve wildcard reference (e.g., "poses/standing" or "colors")
1484
+ _resolveWildcard(ref) {
1485
+ const parts = ref.split('/');
1486
+ let category, filename;
1487
+
1488
+ if (parts.length === 1) {
1489
+ // __colors__ → default/colors.txt
1490
+ category = 'default';
1491
+ filename = parts[0] + '.txt';
1492
+ } else {
1493
+ // __poses/standing__ → poses/standing.txt
1494
+ category = parts.slice(0, -1).join('/');
1495
+ filename = parts[parts.length - 1] + '.txt';
1496
+ }
1497
+
1498
+ const items = this.categories[category]?.[filename];
1499
+ return items && items.length > 0 ? this._randomChoice(items) : null;
1500
+ },
1501
+
1502
+ // Expand __wildcard__ references (recursive)
1503
+ // Note: ||opt1|opt2|| (NAI Prompt Randomizer) is NOT processed here
1504
+ // It's passed through to NAI API which handles it natively
1505
+ _expandReferences(text, depth = 0) {
1506
+ if (depth >= this.MAX_DEPTH) {
1507
+ console.warn('[NAIAWildcard] Max recursion depth reached');
1508
+ return text;
1509
+ }
1510
+
1511
+ // Match __name__ or __category/name__ pattern
1512
+ // Pattern explanation:
1513
+ // __ = start marker
1514
+ // (.+?) = capture group: any character (non-greedy), including Unicode (한글, 日本語, etc.)
1515
+ // __ = end marker
1516
+ // Non-greedy ensures we match the shortest possible string between __ and __
1517
+ const pattern = /__(.+?)__/g;
1518
+
1519
+ let result = text;
1520
+ let hasMatch = false;
1521
+
1522
+ // Process all wildcards in the text
1523
+ result = text.replace(pattern, (match, ref) => {
1524
+ // Skip if ref contains spaces (not a valid wildcard name)
1525
+ if (ref.includes(' ')) return match;
1526
+
1527
+ const resolved = this._resolveWildcard(ref.trim());
1528
+ if (resolved !== null) {
1529
+ hasMatch = true;
1530
+ return resolved;
1531
+ }
1532
+ return match; // Keep original if not found
1533
+ });
1534
+
1535
+ // If any wildcards were resolved, recursively process the result
1536
+ // This handles nested wildcards (wildcard content containing more wildcards)
1537
+ if (hasMatch && /__(.+?)__/.test(result)) {
1538
+ return this._expandReferences(result, depth + 1);
1539
+ }
1540
+
1541
+ return result;
1542
+ },
1543
+
1544
+ // Main entry point: expand all wildcards in prompt
1545
+ // Only expands __wildcard__ syntax
1546
+ // NAI Prompt Randomizer ||..|| is left untouched for API processing
1547
+ expand(prompt) {
1548
+ if (!prompt) return prompt;
1549
+ return this._expandReferences(prompt);
1550
+ },
1551
+
1552
+ // ========== Category Management ==========
1553
+
1554
+ addCategory(name) {
1555
+ if (!name || name === 'default') return false;
1556
+ name = name.trim().toLowerCase().replace(/[^a-z0-9_-]/g, '_');
1557
+ if (!this.categories[name]) {
1558
+ this.categories[name] = {};
1559
+ this.save();
1560
+ return true;
1561
+ }
1562
+ return false;
1563
+ },
1564
+
1565
+ removeCategory(name) {
1566
+ if (name === 'default' || !this.categories[name]) return false;
1567
+ delete this.categories[name];
1568
+ this.save();
1569
+ return true;
1570
+ },
1571
+
1572
+ renameCategory(oldName, newName) {
1573
+ if (oldName === 'default' || !this.categories[oldName]) return false;
1574
+ newName = newName.trim().toLowerCase().replace(/[^a-z0-9_-]/g, '_');
1575
+ if (this.categories[newName]) return false;
1576
+
1577
+ this.categories[newName] = this.categories[oldName];
1578
+ delete this.categories[oldName];
1579
+ this.save();
1580
+ return true;
1581
+ },
1582
+
1583
+ // ========== File Management ==========
1584
+
1585
+ addFile(category, filename, lines) {
1586
+ if (!this.categories[category]) {
1587
+ this.categories[category] = {};
1588
+ }
1589
+ // Ensure .txt extension
1590
+ if (!filename.endsWith('.txt')) {
1591
+ filename = filename + '.txt';
1592
+ }
1593
+ this.categories[category][filename] = lines;
1594
+ this.save();
1595
+ },
1596
+
1597
+ removeFile(category, filename) {
1598
+ if (this.categories[category]?.[filename]) {
1599
+ delete this.categories[category][filename];
1600
+ this.save();
1601
+ return true;
1602
+ }
1603
+ return false;
1604
+ },
1605
+
1606
+ updateFile(category, filename, lines) {
1607
+ if (this.categories[category]?.[filename] !== undefined) {
1608
+ this.categories[category][filename] = lines;
1609
+ this.save();
1610
+ return true;
1611
+ }
1612
+ return false;
1613
+ },
1614
+
1615
+ getFile(category, filename) {
1616
+ return this.categories[category]?.[filename] || null;
1617
+ },
1618
+
1619
+ // ========== Import/Export ==========
1620
+
1621
+ // Parse text file content (one item per line)
1622
+ parseFileContent(content) {
1623
+ return content.split('\\\\n')
1624
+ .map(line => line.trim())
1625
+ .filter(line => line && !line.startsWith('#'));
1626
+ },
1627
+
1628
+ // Export file as text
1629
+ exportFileAsText(category, filename) {
1630
+ const items = this.getFile(category, filename);
1631
+ return items ? items.join('\\\\n') : '';
1632
+ },
1633
+
1634
+ // Get all data for export
1635
+ exportAll() {
1636
+ return JSON.stringify(this.categories, null, 2);
1637
+ },
1638
+
1639
+ // Import all data (merge with existing, replacing duplicates)
1640
+ // Export format: { "category": { "file.txt": ["item1", "item2"] } }
1641
+ importAll(jsonString) {
1642
+ try {
1643
+ const data = JSON.parse(jsonString);
1644
+ // Merge imported data with existing
1645
+ for (const [cat, files] of Object.entries(data)) {
1646
+ if (!this.categories[cat]) {
1647
+ this.categories[cat] = {};
1648
+ }
1649
+ Object.assign(this.categories[cat], files);
1650
+ }
1651
+ // Ensure 'default' exists
1652
+ if (!this.categories['default']) {
1653
+ this.categories['default'] = {};
1654
+ }
1655
+ this.save();
1656
+ return true;
1657
+ } catch(e) {
1658
+ console.error('NAIA-WEB: Error importing wildcards:', e);
1659
+ return false;
1660
+ }
1661
+ },
1662
+
1663
+ // ========== Statistics ==========
1664
+
1665
+ getStats() {
1666
+ let totalFiles = 0;
1667
+ let totalItems = 0;
1668
+
1669
+ for (const cat of Object.values(this.categories)) {
1670
+ for (const items of Object.values(cat)) {
1671
+ totalFiles++;
1672
+ totalItems += items.length;
1673
+ }
1674
+ }
1675
+
1676
+ const storageSize = new Blob([JSON.stringify(this.categories)]).size;
1677
+ const formattedSize = storageSize > 1024
1678
+ ? (storageSize / 1024).toFixed(1) + ' KB'
1679
+ : storageSize + ' B';
1680
+
1681
+ return { totalFiles, totalItems, storageSize, formattedSize };
1682
+ },
1683
+
1684
+ // Get categories list for UI (default always first)
1685
+ getCategoryList() {
1686
+ return Object.keys(this.categories).sort((a, b) => {
1687
+ if (a === 'default') return -1;
1688
+ if (b === 'default') return 1;
1689
+ return a.localeCompare(b);
1690
+ });
1691
+ },
1692
+
1693
+ // Get file count in category
1694
+ getFileCount(category) {
1695
+ return this.categories[category] ? Object.keys(this.categories[category]).length : 0;
1696
+ },
1697
+
1698
+ // Get total item count in category
1699
+ getItemCount(category) {
1700
+ if (!this.categories[category]) return 0;
1701
+ return Object.values(this.categories[category]).reduce((sum, items) => sum + items.length, 0);
1702
+ },
1703
+
1704
+ // Get files in category for UI
1705
+ getFilesInCategory(category) {
1706
+ if (!this.categories[category]) return [];
1707
+ return Object.entries(this.categories[category])
1708
+ .map(([filename, items]) => ({
1709
+ filename,
1710
+ itemCount: items.length
1711
+ }))
1712
+ .sort((a, b) => a.filename.localeCompare(b.filename));
1713
+ },
1714
+
1715
+ // Check if wildcard mode is enabled
1716
+ isWildcardMode() {
1717
+ try {
1718
+ const saved = localStorage.getItem(SETTINGS_KEY);
1719
+ if (saved) {
1720
+ const settings = JSON.parse(saved);
1721
+ return settings.wildcard_mode === true;
1722
+ }
1723
+ } catch(e) {}
1724
+ return false;
1725
+ }
1726
+ };
1727
+
1728
+ // Initialize wildcards on load
1729
+ NAIAWildcard.load();
1730
+
1731
+ // Expose globally
1732
+ window.NAIAWildcard = NAIAWildcard;
1733
+
1734
+ // ========== Wildcard Manager UI ==========
1735
+
1736
+ const WildcardManagerUI = {
1737
+ selectedCategory: 'default',
1738
+ selectedFile: null,
1739
+
1740
+ // Initialize UI event handlers
1741
+ init() {
1742
+ this.bindEvents();
1743
+ this.render();
1744
+ },
1745
+
1746
+ bindEvents() {
1747
+ // Add Category button
1748
+ const addCatBtn = document.getElementById('naia-wc-add-category');
1749
+ if (addCatBtn) {
1750
+ addCatBtn.addEventListener('click', () => this.promptAddCategory());
1751
+ }
1752
+
1753
+ // Export All button
1754
+ const exportBtn = document.getElementById('naia-wc-export-all');
1755
+ if (exportBtn) {
1756
+ exportBtn.addEventListener('click', () => this.exportAll());
1757
+ }
1758
+
1759
+ // Category list click delegation
1760
+ const catContainer = document.getElementById('naia-wc-categories');
1761
+ if (catContainer) {
1762
+ catContainer.addEventListener('click', (e) => {
1763
+ const item = e.target.closest('.wc-category-item');
1764
+ if (item) {
1765
+ const deleteBtn = e.target.closest('.wc-cat-delete');
1766
+ if (deleteBtn) {
1767
+ this.deleteCategory(item.dataset.category);
1768
+ } else {
1769
+ this.selectCategory(item.dataset.category);
1770
+ }
1771
+ }
1772
+ });
1773
+ }
1774
+
1775
+ // File list click delegation
1776
+ const fileContainer = document.getElementById('naia-wc-files');
1777
+ if (fileContainer) {
1778
+ fileContainer.addEventListener('click', (e) => {
1779
+ const item = e.target.closest('.wc-file-item');
1780
+ if (item) {
1781
+ const deleteBtn = e.target.closest('.wc-file-delete');
1782
+ if (deleteBtn) {
1783
+ this.deleteFile(item.dataset.filename);
1784
+ } else {
1785
+ this.selectFile(item.dataset.filename);
1786
+ }
1787
+ }
1788
+ });
1789
+ }
1790
+
1791
+ // Editor buttons - find actual button element inside Gradio container
1792
+ const findButton = (containerId) => {
1793
+ const container = document.getElementById(containerId);
1794
+ return container ? (container.querySelector('button') || container) : null;
1795
+ };
1796
+
1797
+ const newFileBtn = findButton('naia-wc-new-file');
1798
+ if (newFileBtn) {
1799
+ newFileBtn.addEventListener('click', (e) => {
1800
+ e.preventDefault();
1801
+ e.stopPropagation();
1802
+ this.newFile();
1803
+ });
1804
+ }
1805
+
1806
+ const editSelectedBtn = findButton('naia-wc-edit-selected');
1807
+ if (editSelectedBtn) {
1808
+ editSelectedBtn.addEventListener('click', (e) => {
1809
+ e.preventDefault();
1810
+ e.stopPropagation();
1811
+ this.editSelected();
1812
+ });
1813
+ }
1814
+
1815
+ const saveEditBtn = findButton('naia-wc-save-edit');
1816
+ if (saveEditBtn) {
1817
+ saveEditBtn.addEventListener('click', (e) => {
1818
+ e.preventDefault();
1819
+ e.stopPropagation();
1820
+ console.log('[WildcardManagerUI] Save button clicked');
1821
+ this.saveFile();
1822
+ });
1823
+ }
1824
+
1825
+ const deleteFileBtn = findButton('naia-wc-delete-file');
1826
+ if (deleteFileBtn) {
1827
+ deleteFileBtn.addEventListener('click', (e) => {
1828
+ e.preventDefault();
1829
+ e.stopPropagation();
1830
+ this.deleteCurrentFile();
1831
+ });
1832
+ }
1833
+
1834
+ const downloadFileBtn = findButton('naia-wc-download-file');
1835
+ if (downloadFileBtn) {
1836
+ downloadFileBtn.addEventListener('click', (e) => {
1837
+ e.preventDefault();
1838
+ e.stopPropagation();
1839
+ this.downloadCurrentFile();
1840
+ });
1841
+ }
1842
+
1843
+ console.log('[WildcardManagerUI] Editor buttons bound:', {
1844
+ newFile: !!newFileBtn,
1845
+ editSelected: !!editSelectedBtn,
1846
+ save: !!saveEditBtn,
1847
+ delete: !!deleteFileBtn,
1848
+ download: !!downloadFileBtn
1849
+ });
1850
+ },
1851
+
1852
+ // Prompt user to add a new category
1853
+ promptAddCategory() {
1854
+ const name = prompt('Enter new category name:');
1855
+ if (name && name.trim()) {
1856
+ const cleanName = name.trim().toLowerCase().replace(/[^a-z0-9_-]/g, '_');
1857
+ if (cleanName === 'default') {
1858
+ alert('Cannot use "default" as category name.');
1859
+ return;
1860
+ }
1861
+ if (NAIAWildcard.addCategory(cleanName)) {
1862
+ this.render();
1863
+ this.selectCategory(cleanName);
1864
+ } else {
1865
+ alert('Category already exists or invalid name.');
1866
+ }
1867
+ }
1868
+ },
1869
+
1870
+ // Delete a category
1871
+ deleteCategory(name) {
1872
+ if (name === 'default') return;
1873
+ if (confirm(`Delete category "${name}" and all its files?`)) {
1874
+ NAIAWildcard.removeCategory(name);
1875
+ if (this.selectedCategory === name) {
1876
+ this.selectedCategory = 'default';
1877
+ }
1878
+ this.render();
1879
+ }
1880
+ },
1881
+
1882
+ // Select a category
1883
+ selectCategory(name) {
1884
+ this.selectedCategory = name;
1885
+ this.selectedFile = null;
1886
+ this.render();
1887
+ },
1888
+
1889
+ // Select a file to preview
1890
+ selectFile(filename) {
1891
+ this.selectedFile = filename;
1892
+ this.renderFileList();
1893
+ this.renderPreview();
1894
+ this.renderUsageHint();
1895
+ },
1896
+
1897
+ // Delete a file
1898
+ deleteFile(filename) {
1899
+ if (confirm(`Delete file "${filename}"?`)) {
1900
+ NAIAWildcard.removeFile(this.selectedCategory, filename);
1901
+ if (this.selectedFile === filename) {
1902
+ this.selectedFile = null;
1903
+ }
1904
+ this.render();
1905
+ }
1906
+ },
1907
+
1908
+ // Export all wildcards as JSON
1909
+ exportAll() {
1910
+ const data = NAIAWildcard.exportAll();
1911
+ const blob = new Blob([data], { type: 'application/json' });
1912
+ const url = URL.createObjectURL(blob);
1913
+ const a = document.createElement('a');
1914
+ a.href = url;
1915
+ a.download = 'naia_wildcards.json';
1916
+ a.click();
1917
+ URL.revokeObjectURL(url);
1918
+ },
1919
+
1920
+ // Render all UI components
1921
+ render() {
1922
+ this.renderCategoryList();
1923
+ this.renderFileList();
1924
+ this.renderPreview();
1925
+ this.renderUsageHint();
1926
+ this.renderStats();
1927
+ },
1928
+
1929
+ // Render category list
1930
+ renderCategoryList() {
1931
+ const container = document.getElementById('naia-wc-categories');
1932
+ const totalSpan = document.getElementById('wc-cat-total');
1933
+ if (!container) return;
1934
+
1935
+ const categories = NAIAWildcard.getCategoryList();
1936
+ let html = '';
1937
+
1938
+ for (const cat of categories) {
1939
+ const fileCount = NAIAWildcard.getFileCount(cat);
1940
+ const isSelected = cat === this.selectedCategory;
1941
+ const isDefault = cat === 'default';
1942
+ html += `<div class="wc-category-item ${isSelected ? 'selected' : ''} ${isDefault ? 'default-cat' : ''}" data-category="${cat}">
1943
+ <span class="wc-cat-name">${cat}</span>
1944
+ <span class="wc-cat-count">(${fileCount})</span>
1945
+ ${!isDefault ? '<span class="wc-cat-delete" title="Delete">×</span>' : ''}
1946
+ </div>`;
1947
+ }
1948
+
1949
+ container.innerHTML = html;
1950
+
1951
+ // Update total count
1952
+ if (totalSpan) {
1953
+ totalSpan.textContent = `(${categories.length})`;
1954
+ }
1955
+
1956
+ // Update current category label
1957
+ const currentCatSpan = document.getElementById('wc-current-category');
1958
+ if (currentCatSpan) {
1959
+ currentCatSpan.textContent = this.selectedCategory;
1960
+ }
1961
+ },
1962
+
1963
+ // Render file list for selected category
1964
+ renderFileList() {
1965
+ const container = document.getElementById('naia-wc-files');
1966
+ if (!container) return;
1967
+
1968
+ const files = NAIAWildcard.getFilesInCategory(this.selectedCategory);
1969
+
1970
+ if (files.length === 0) {
1971
+ container.innerHTML = '<p class="wc-empty-msg">No files. Upload .txt files.</p>';
1972
+ return;
1973
+ }
1974
+
1975
+ let html = '';
1976
+ for (const file of files) {
1977
+ const isSelected = file.filename === this.selectedFile;
1978
+ html += `<div class="wc-file-item ${isSelected ? 'selected' : ''}" data-filename="${file.filename}">
1979
+ <span class="wc-file-name">${file.filename}</span>
1980
+ <span class="wc-file-count">(${file.itemCount})</span>
1981
+ <span class="wc-file-delete" title="Delete">×</span>
1982
+ </div>`;
1983
+ }
1984
+
1985
+ container.innerHTML = html;
1986
+ },
1987
+
1988
+ // Render preview of selected file
1989
+ renderPreview() {
1990
+ const container = document.getElementById('naia-wc-preview');
1991
+ const filenameSpan = document.getElementById('wc-preview-filename');
1992
+ if (!container) return;
1993
+
1994
+ if (filenameSpan) {
1995
+ filenameSpan.textContent = this.selectedFile ? `- ${this.selectedFile}` : '';
1996
+ }
1997
+
1998
+ if (!this.selectedFile) {
1999
+ container.innerHTML = '<p class="wc-empty-msg">Click a file to preview.</p>';
2000
+ return;
2001
+ }
2002
+
2003
+ const items = NAIAWildcard.getFile(this.selectedCategory, this.selectedFile);
2004
+ if (!items || items.length === 0) {
2005
+ container.innerHTML = '<p class="wc-empty-msg">File is empty.</p>';
2006
+ return;
2007
+ }
2008
+
2009
+ // Show first 20 items as preview
2010
+ const previewItems = items.slice(0, 20);
2011
+ let html = previewItems.map(item => `<div class="wc-preview-item">${this.escapeHtml(item)}</div>`).join('');
2012
+ if (items.length > 20) {
2013
+ html += `<div class="wc-preview-item" style="color: var(--body-text-color-subdued);">... and ${items.length - 20} more</div>`;
2014
+ }
2015
+
2016
+ container.innerHTML = html;
2017
+ },
2018
+
2019
+ // Render usage hint (shows how to call selected wildcard)
2020
+ renderUsageHint() {
2021
+ const container = document.getElementById('naia-wc-usage-hint');
2022
+ if (!container) return;
2023
+
2024
+ if (!this.selectedFile) {
2025
+ container.innerHTML = 'Select a file to see usage';
2026
+ return;
2027
+ }
2028
+
2029
+ // Remove .txt extension for wildcard reference
2030
+ const baseName = this.selectedFile.replace(/\\.txt$/i, '');
2031
+
2032
+ // Build wildcard syntax
2033
+ let wildcardSyntax;
2034
+ if (this.selectedCategory === 'default') {
2035
+ // default category: __filename__
2036
+ wildcardSyntax = `__${baseName}__`;
2037
+ } else {
2038
+ // other categories: __category/filename__
2039
+ wildcardSyntax = `__${this.selectedCategory}/${baseName}__`;
2040
+ }
2041
+
2042
+ container.innerHTML = `Usage: <b>${wildcardSyntax}</b>`;
2043
+ },
2044
+
2045
+ // Render statistics
2046
+ renderStats() {
2047
+ const container = document.querySelector('.wc-stats');
2048
+ if (!container) return;
2049
+
2050
+ const stats = NAIAWildcard.getStats();
2051
+ container.innerHTML = `Total: ${stats.totalFiles} files, ${stats.totalItems} items | Storage: ${stats.formattedSize}`;
2052
+ },
2053
+
2054
+ // Escape HTML for safe display
2055
+ escapeHtml(text) {
2056
+ const div = document.createElement('div');
2057
+ div.textContent = text;
2058
+ return div.innerHTML;
2059
+ },
2060
+
2061
+ // Handle file upload (called from Gradio)
2062
+ handleFileUpload(files, category) {
2063
+ if (!files || files.length === 0) return;
2064
+
2065
+ for (const file of files) {
2066
+ const filename = file.name || 'uploaded.txt';
2067
+ // File content will be passed as text
2068
+ if (typeof file === 'string') {
2069
+ const lines = file.split('\\n').map(l => l.trim()).filter(l => l && !l.startsWith('#'));
2070
+ NAIAWildcard.addFile(category, filename, lines);
2071
+ }
2072
+ }
2073
+ this.render();
2074
+ },
2075
+
2076
+ // Handle JSON import (called from Gradio)
2077
+ handleImport(jsonContent) {
2078
+ if (NAIAWildcard.importAll(jsonContent)) {
2079
+ this.render();
2080
+ alert('Import successful!');
2081
+ } else {
2082
+ alert('Import failed. Invalid JSON format.');
2083
+ }
2084
+ },
2085
+
2086
+ // ========== Editor Functions ==========
2087
+
2088
+ // Get editor elements (try both input and textarea for compatibility)
2089
+ getEditorElements() {
2090
+ const filenameContainer = document.querySelector('#naia-wc-edit-filename');
2091
+ const textareaContainer = document.querySelector('#naia-wc-edit-textarea');
2092
+ return {
2093
+ filename: filenameContainer ? (filenameContainer.querySelector('input') || filenameContainer.querySelector('textarea')) : null,
2094
+ textarea: textareaContainer ? textareaContainer.querySelector('textarea') : null
2095
+ };
2096
+ },
2097
+
2098
+ // Helper to set value and trigger Gradio sync
2099
+ setInputValue(element, value) {
2100
+ if (!element) return;
2101
+ element.value = value;
2102
+ // Dispatch input event to sync with Gradio state
2103
+ element.dispatchEvent(new Event('input', { bubbles: true }));
2104
+ },
2105
+
2106
+ // New file - clear editor for new file creation
2107
+ newFile() {
2108
+ const { filename, textarea } = this.getEditorElements();
2109
+ this.setInputValue(filename, '');
2110
+ this.setInputValue(textarea, '');
2111
+ this.selectedFile = null;
2112
+ this.renderFileList();
2113
+ this.renderUsageHint();
2114
+ // Focus on filename input
2115
+ if (filename) filename.focus();
2116
+ },
2117
+
2118
+ // Edit selected file - load into editor
2119
+ editSelected() {
2120
+ if (!this.selectedFile) {
2121
+ alert('Please select a file first.');
2122
+ return;
2123
+ }
2124
+ const { filename, textarea } = this.getEditorElements();
2125
+ const items = NAIAWildcard.getFile(this.selectedCategory, this.selectedFile);
2126
+
2127
+ this.setInputValue(filename, this.selectedFile);
2128
+ const content = items ? items.join('\\n') : '';
2129
+ this.setInputValue(textarea, content);
2130
+
2131
+ // Fix scroll issue: focus and scroll to top after content is set
2132
+ if (textarea) {
2133
+ setTimeout(() => {
2134
+ textarea.focus();
2135
+ textarea.setSelectionRange(0, 0);
2136
+ textarea.scrollTop = 0;
2137
+ }, 50);
2138
+ }
2139
+ },
2140
+
2141
+ // Save file from editor
2142
+ saveFile() {
2143
+ const { filename, textarea } = this.getEditorElements();
2144
+ if (!filename || !textarea) {
2145
+ console.error('[WildcardManagerUI] saveFile: Editor elements not found', { filename, textarea });
2146
+ return;
2147
+ }
2148
+
2149
+ let fname = filename.value.trim();
2150
+ console.log('[WildcardManagerUI] saveFile: filename =', fname, 'category =', this.selectedCategory);
2151
+
2152
+ if (!fname) {
2153
+ alert('Please enter a filename.');
2154
+ filename.focus();
2155
+ return;
2156
+ }
2157
+
2158
+ // Ensure .txt extension
2159
+ if (!fname.endsWith('.txt')) {
2160
+ fname = fname + '.txt';
2161
+ }
2162
+
2163
+ // Parse content - keep non-empty lines (comments allowed, just trim)
2164
+ const content = textarea.value || '';
2165
+ const lines = content.split('\\n')
2166
+ .map(l => l.trim())
2167
+ .filter(l => l.length > 0); // Keep comments too for storage
2168
+
2169
+ console.log('[WildcardManagerUI] saveFile: saving', lines.length, 'lines to', this.selectedCategory + '/' + fname);
2170
+
2171
+ // Save to storage
2172
+ NAIAWildcard.addFile(this.selectedCategory, fname, lines);
2173
+
2174
+ // Update UI
2175
+ this.selectedFile = fname;
2176
+ this.render();
2177
+
2178
+ // Update filename input with normalized name
2179
+ this.setInputValue(filename, fname);
2180
+
2181
+ console.log('[WildcardManagerUI] saveFile: done');
2182
+ },
2183
+
2184
+ // Delete current file (from editor)
2185
+ deleteCurrentFile() {
2186
+ const { filename } = this.getEditorElements();
2187
+ const fname = filename ? filename.value.trim() : '';
2188
+
2189
+ if (!fname) {
2190
+ alert('No file to delete.');
2191
+ return;
2192
+ }
2193
+
2194
+ if (confirm(`Delete file "${fname}"?`)) {
2195
+ NAIAWildcard.removeFile(this.selectedCategory, fname);
2196
+ this.newFile(); // Clear editor
2197
+ this.render();
2198
+ }
2199
+ },
2200
+
2201
+ // Download current file
2202
+ downloadCurrentFile() {
2203
+ const { filename, textarea } = this.getEditorElements();
2204
+ if (!filename || !textarea) return;
2205
+
2206
+ let fname = filename.value.trim();
2207
+ if (!fname) {
2208
+ alert('Please enter a filename first.');
2209
+ return;
2210
+ }
2211
+
2212
+ if (!fname.endsWith('.txt')) {
2213
+ fname = fname + '.txt';
2214
+ }
2215
+
2216
+ const content = textarea.value || '';
2217
+ const blob = new Blob([content], { type: 'text/plain' });
2218
+ const url = URL.createObjectURL(blob);
2219
+ const a = document.createElement('a');
2220
+ a.href = url;
2221
+ a.download = fname;
2222
+ a.click();
2223
+ URL.revokeObjectURL(url);
2224
+ }
2225
+ };
2226
+
2227
+ // Expose for Gradio callbacks
2228
+ window.WildcardManagerUI = WildcardManagerUI;
2229
+
2230
  // ========== Output Image UI Customization ==========
2231
 
2232
  // Move download button into image container for overlay positioning
 
2246
  }
2247
  }
2248
 
2249
+ // Initialize Wildcard Manager UI
2250
+ function setupWildcardManagerUI() {
2251
+ const container = document.getElementById('naia-wc-categories');
2252
+ if (container) {
2253
+ WildcardManagerUI.init();
2254
+ } else {
2255
+ // Retry if Wildcard accordion not yet rendered
2256
+ setTimeout(setupWildcardManagerUI, 500);
2257
+ }
2258
+ }
2259
+
2260
  // Initialize on page load - only load token
2261
  // Settings and Get Random Prompt are handled by app.load in Python
2262
  function startInit() {
2263
  loadToken();
2264
  setupOutputImageUI();
2265
+ setupWildcardManagerUI();
2266
  }
2267
 
2268
  // Handle various load states
ui/components/wildcard_panel.py ADDED
@@ -0,0 +1,536 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ NAIA-WEB Wildcard Panel Component
3
+ Wildcard Manager UI for LocalStorage-based wildcard system
4
+ """
5
+
6
+ import gradio as gr
7
+
8
+
9
+ def create_wildcard_panel():
10
+ """
11
+ Create wildcard manager panel with category/file management.
12
+
13
+ Layout: 4-column grid
14
+ - Column 1 (1/4): Category list with selection
15
+ - Column 2-4 (3/4): File list for selected category
16
+
17
+ All data is stored in browser LocalStorage via JavaScript.
18
+ This Python component only provides the UI structure.
19
+
20
+ Returns:
21
+ Dict with UI components for event binding
22
+ """
23
+ # Action buttons row
24
+ with gr.Row():
25
+ add_category_btn = gr.Button(
26
+ "Add Category",
27
+ size="sm",
28
+ elem_id="naia-wc-add-category"
29
+ )
30
+ upload_file_btn = gr.UploadButton(
31
+ "Upload .txt",
32
+ file_count="multiple",
33
+ file_types=[".txt"],
34
+ size="sm",
35
+ elem_id="naia-wc-upload-file"
36
+ )
37
+ export_all_btn = gr.Button(
38
+ "Export All (.json)",
39
+ size="sm",
40
+ elem_id="naia-wc-export-all"
41
+ )
42
+ import_all_btn = gr.UploadButton(
43
+ "Import (.json)",
44
+ file_count="single",
45
+ file_types=[".json"],
46
+ size="sm",
47
+ elem_id="naia-wc-import-all"
48
+ )
49
+
50
+ # Main content area: 3-column layout (Categories | Files | Preview)
51
+ with gr.Row(elem_classes=["wc-main-row"]):
52
+ # Column 1: Category selector
53
+ with gr.Column(scale=1, min_width=100, elem_classes=["wc-category-col"]):
54
+ gr.HTML('<div class="wc-section-label">Categories <span id="wc-cat-total">(0)</span></div>')
55
+ category_list = gr.HTML(
56
+ value='<div id="naia-wc-categories" class="wc-category-list"><div class="wc-category-item selected default-cat" data-category="default"><span class="wc-cat-name">default</span><span class="wc-cat-count">(0)</span></div></div>',
57
+ elem_id="naia-wc-category-container"
58
+ )
59
+
60
+ # Column 2: File list for selected category
61
+ with gr.Column(scale=2, min_width=140, elem_classes=["wc-files-col"]):
62
+ gr.HTML('<div class="wc-section-label">Files in <span id="wc-current-category">default</span></div>')
63
+ file_list = gr.HTML(
64
+ value='<div id="naia-wc-files" class="wc-file-list"><p class="wc-empty-msg">No files. Upload .txt files.</p></div>',
65
+ elem_id="naia-wc-file-container"
66
+ )
67
+
68
+ # Column 3: Preview of selected file content
69
+ with gr.Column(scale=2, min_width=140, elem_classes=["wc-preview-col"]):
70
+ gr.HTML('<div class="wc-section-label">Preview <span id="wc-preview-filename"></span></div>')
71
+ preview_panel = gr.HTML(
72
+ value='<div id="naia-wc-preview" class="wc-preview-list"><p class="wc-empty-msg">Click a file to preview.</p></div>',
73
+ elem_id="naia-wc-preview-container"
74
+ )
75
+
76
+ # Bottom section: Usage hint + Editor
77
+ with gr.Row(elem_classes=["wc-bottom-row"]):
78
+ # Left: Usage hint
79
+ usage_hint = gr.HTML(
80
+ value='<div id="naia-wc-usage-hint" class="wc-usage-hint">Select a file to see usage</div>',
81
+ elem_id="naia-wc-usage-container"
82
+ )
83
+ # Right: Stats
84
+ stats_display = gr.HTML(
85
+ value='<div class="wc-stats">Total: 0 files, 0 items | Storage: 0 B</div>',
86
+ elem_id="naia-wc-stats"
87
+ )
88
+
89
+ # Editor panel (always visible)
90
+ with gr.Column(elem_id="naia-wc-edit-panel", elem_classes=["wc-edit-panel"]):
91
+ with gr.Row(elem_classes=["wc-edit-header-row"]):
92
+ gr.HTML('<span class="wc-edit-label">Editor</span>')
93
+ edit_filename = gr.Textbox(
94
+ value="",
95
+ placeholder="filename.txt",
96
+ show_label=False,
97
+ container=False,
98
+ elem_id="naia-wc-edit-filename",
99
+ elem_classes=["wc-edit-filename-input"],
100
+ scale=2
101
+ )
102
+ new_file_btn = gr.Button(
103
+ "New File",
104
+ size="sm",
105
+ elem_id="naia-wc-new-file"
106
+ )
107
+ edit_file_btn = gr.Button(
108
+ "Edit Selected",
109
+ size="sm",
110
+ elem_id="naia-wc-edit-selected"
111
+ )
112
+ edit_textarea = gr.Textbox(
113
+ label="",
114
+ placeholder="One item per line (lines starting with # are comments)",
115
+ lines=6,
116
+ max_lines=10,
117
+ elem_id="naia-wc-edit-textarea",
118
+ show_label=False,
119
+ interactive=True
120
+ )
121
+ with gr.Row():
122
+ save_edit_btn = gr.Button(
123
+ "💾 Save",
124
+ size="sm",
125
+ variant="primary",
126
+ elem_id="naia-wc-save-edit"
127
+ )
128
+ delete_file_btn = gr.Button(
129
+ "🗑️ Delete",
130
+ size="sm",
131
+ elem_id="naia-wc-delete-file"
132
+ )
133
+ download_file_btn = gr.Button(
134
+ "⬇️ Download",
135
+ size="sm",
136
+ elem_id="naia-wc-download-file"
137
+ )
138
+
139
+ # Hidden inputs
140
+ wc_action = gr.Textbox(
141
+ visible=True,
142
+ elem_id="naia-wc-action",
143
+ elem_classes=["hidden-component"]
144
+ )
145
+ selected_category = gr.Textbox(
146
+ value="default",
147
+ visible=True,
148
+ elem_id="naia-wc-selected-category",
149
+ elem_classes=["hidden-component"]
150
+ )
151
+
152
+ # Hidden file data holder for downloads
153
+ download_data = gr.Textbox(
154
+ visible=True,
155
+ elem_id="naia-wc-download-data",
156
+ elem_classes=["hidden-component"]
157
+ )
158
+
159
+ return {
160
+ "add_category_btn": add_category_btn,
161
+ "upload_file_btn": upload_file_btn,
162
+ "export_all_btn": export_all_btn,
163
+ "import_all_btn": import_all_btn,
164
+ "category_list": category_list,
165
+ "file_list": file_list,
166
+ "preview_panel": preview_panel,
167
+ "wc_action": wc_action,
168
+ "selected_category": selected_category,
169
+ "edit_textarea": edit_textarea,
170
+ "save_edit_btn": save_edit_btn,
171
+ "new_file_btn": new_file_btn,
172
+ "edit_file_btn": edit_file_btn,
173
+ "download_file_btn": download_file_btn,
174
+ "delete_file_btn": delete_file_btn,
175
+ "stats_display": stats_display,
176
+ "download_data": download_data
177
+ }
178
+
179
+
180
+ def create_wildcard_template_input():
181
+ """
182
+ Create Wildcard Template input that appears above Prompt section
183
+ when Wildcard Mode is enabled.
184
+
185
+ Returns:
186
+ Dict with template input component
187
+ """
188
+ with gr.Group(visible=False, elem_id="naia-wc-template-group") as template_group:
189
+ gr.HTML(
190
+ '<div class="wc-template-label"><b>Wildcard Template</b> <span>(replaces Quick Search random prompts)</span></div>',
191
+ elem_id="naia-wc-template-label"
192
+ )
193
+ template_input = gr.Textbox(
194
+ label="",
195
+ placeholder="Example: 1girl, __poses__, __category/file__, ||red|blue|green|| hair",
196
+ lines=3,
197
+ elem_id="naia-wildcard-template",
198
+ show_label=False,
199
+ interactive=True
200
+ )
201
+ gr.HTML(
202
+ '<div class="wildcard-hint">&nbsp;&nbsp;<code>__wildcard__</code> = LocalStorage random selection, &nbsp;<code>||a|b|c||</code> = NAI Prompt Randomizer</div>'
203
+ )
204
+
205
+ return {
206
+ "template_group": template_group,
207
+ "template_input": template_input
208
+ }
209
+
210
+
211
+ # CSS for Wildcard Panel
212
+ WILDCARD_CSS = """
213
+ /* ========== Wildcard Manager Layout ========== */
214
+
215
+ /* Main row: category + files + preview - equal height columns */
216
+ .wc-main-row {
217
+ min-height: 160px;
218
+ gap: 6px !important;
219
+ align-items: stretch !important;
220
+ }
221
+
222
+ .wc-main-row > div {
223
+ display: flex !important;
224
+ flex-direction: column !important;
225
+ }
226
+
227
+ .wc-main-row .wc-category-list,
228
+ .wc-main-row .wc-file-list,
229
+ .wc-main-row .wc-preview-list {
230
+ flex: 1 !important;
231
+ min-height: 120px !important;
232
+ }
233
+
234
+ /* Section labels */
235
+ .wc-section-label {
236
+ font-weight: 600;
237
+ font-size: 0.85em;
238
+ padding: 4px 8px;
239
+ background: var(--background-fill-secondary);
240
+ border-radius: 4px 4px 0 0;
241
+ border: 1px solid var(--border-color-primary);
242
+ border-bottom: none;
243
+ }
244
+
245
+ .wc-section-label span {
246
+ font-weight: normal;
247
+ color: var(--body-text-color-subdued);
248
+ }
249
+
250
+ /* Category column */
251
+ .wc-category-col {
252
+ min-width: 100px !important;
253
+ max-width: 140px !important;
254
+ }
255
+
256
+ /* Category list container */
257
+ .wc-category-list {
258
+ max-height: 160px;
259
+ overflow-y: auto;
260
+ border: 1px solid var(--border-color-primary);
261
+ border-radius: 0 0 4px 4px;
262
+ background: var(--background-fill-primary);
263
+ }
264
+
265
+ /* Category item */
266
+ .wc-category-item {
267
+ padding: 5px 8px;
268
+ cursor: pointer;
269
+ border-bottom: 1px solid var(--border-color-primary);
270
+ font-size: 0.85em;
271
+ display: flex;
272
+ justify-content: space-between;
273
+ align-items: center;
274
+ gap: 4px;
275
+ }
276
+
277
+ .wc-category-item:last-child {
278
+ border-bottom: none;
279
+ }
280
+
281
+ .wc-category-item:hover {
282
+ background: var(--background-fill-secondary);
283
+ }
284
+
285
+ .wc-category-item.selected {
286
+ background: var(--color-accent-soft);
287
+ font-weight: 600;
288
+ }
289
+
290
+ .wc-category-item .wc-cat-name {
291
+ flex: 1;
292
+ overflow: hidden;
293
+ text-overflow: ellipsis;
294
+ white-space: nowrap;
295
+ }
296
+
297
+ .wc-category-item .wc-cat-count {
298
+ font-size: 0.85em;
299
+ color: var(--body-text-color-subdued);
300
+ flex-shrink: 0;
301
+ }
302
+
303
+ .wc-category-item .wc-cat-delete {
304
+ display: none;
305
+ color: #e53935;
306
+ cursor: pointer;
307
+ padding: 0 2px;
308
+ font-size: 1em;
309
+ flex-shrink: 0;
310
+ }
311
+
312
+ .wc-category-item:hover .wc-cat-delete {
313
+ display: inline;
314
+ }
315
+
316
+ .wc-category-item.default-cat .wc-cat-delete {
317
+ display: none !important;
318
+ }
319
+
320
+ /* Files column */
321
+ .wc-files-col {
322
+ min-width: 120px !important;
323
+ }
324
+
325
+ /* File list container */
326
+ .wc-file-list {
327
+ max-height: 160px;
328
+ overflow-y: auto;
329
+ border: 1px solid var(--border-color-primary);
330
+ border-radius: 0 0 4px 4px;
331
+ background: var(--background-fill-primary);
332
+ padding: 3px;
333
+ }
334
+
335
+ .wc-empty-msg {
336
+ color: var(--body-text-color-subdued);
337
+ text-align: center;
338
+ padding: 15px 8px;
339
+ font-style: italic;
340
+ font-size: 0.85em;
341
+ }
342
+
343
+ /* File item */
344
+ .wc-file-item {
345
+ display: flex;
346
+ align-items: center;
347
+ justify-content: space-between;
348
+ padding: 4px 8px;
349
+ margin: 2px 0;
350
+ border-radius: 3px;
351
+ background: var(--background-fill-secondary);
352
+ cursor: pointer;
353
+ font-size: 0.85em;
354
+ }
355
+
356
+ .wc-file-item:hover {
357
+ background: var(--color-accent-soft);
358
+ }
359
+
360
+ .wc-file-item.selected {
361
+ background: var(--color-accent-soft);
362
+ font-weight: 600;
363
+ }
364
+
365
+ .wc-file-name {
366
+ flex: 1;
367
+ font-family: monospace;
368
+ font-size: 0.9em;
369
+ overflow: hidden;
370
+ text-overflow: ellipsis;
371
+ white-space: nowrap;
372
+ }
373
+
374
+ .wc-file-count {
375
+ color: var(--body-text-color-subdued);
376
+ font-size: 0.85em;
377
+ flex-shrink: 0;
378
+ margin-left: 4px;
379
+ }
380
+
381
+ .wc-file-delete {
382
+ display: none;
383
+ color: #e53935;
384
+ cursor: pointer;
385
+ padding: 0 2px;
386
+ font-size: 1em;
387
+ flex-shrink: 0;
388
+ margin-left: 4px;
389
+ }
390
+
391
+ .wc-file-item:hover .wc-file-delete {
392
+ display: inline;
393
+ }
394
+
395
+ /* Preview column */
396
+ .wc-preview-col {
397
+ min-width: 120px !important;
398
+ }
399
+
400
+ .wc-preview-list {
401
+ max-height: 160px;
402
+ overflow-y: auto;
403
+ border: 1px solid var(--border-color-primary);
404
+ border-radius: 0 0 4px 4px;
405
+ background: var(--background-fill-primary);
406
+ padding: 6px 8px;
407
+ font-size: 0.85em;
408
+ line-height: 1.5;
409
+ }
410
+
411
+ .wc-preview-list .wc-preview-item {
412
+ padding: 2px 0;
413
+ border-bottom: 1px dashed var(--border-color-primary);
414
+ }
415
+
416
+ .wc-preview-list .wc-preview-item:last-child {
417
+ border-bottom: none;
418
+ }
419
+
420
+ /* Bottom row: usage + stats */
421
+ .wc-bottom-row {
422
+ margin-top: 4px;
423
+ align-items: center;
424
+ }
425
+
426
+ /* Usage Hint */
427
+ .wc-usage-hint {
428
+ font-size: 0.9em;
429
+ color: var(--body-text-color-subdued);
430
+ padding: 4px 8px;
431
+ min-height: 24px;
432
+ flex: 1;
433
+ }
434
+
435
+ .wc-usage-hint b {
436
+ color: var(--body-text-color);
437
+ font-family: monospace;
438
+ background: var(--background-fill-secondary);
439
+ padding: 2px 6px;
440
+ border-radius: 3px;
441
+ }
442
+
443
+ /* Stats Display */
444
+ .wc-stats {
445
+ text-align: right;
446
+ font-size: 0.8em;
447
+ color: var(--body-text-color-subdued);
448
+ padding: 4px 8px;
449
+ }
450
+
451
+ /* Edit Panel */
452
+ .wc-edit-panel {
453
+ border: 1px solid var(--border-color-primary);
454
+ border-radius: 6px;
455
+ padding: 8px 10px;
456
+ margin-top: 8px;
457
+ background: var(--background-fill-secondary);
458
+ }
459
+
460
+ .wc-edit-header-row {
461
+ align-items: center;
462
+ gap: 8px !important;
463
+ margin-bottom: 6px;
464
+ }
465
+
466
+ .wc-edit-label {
467
+ font-weight: 600;
468
+ font-size: 0.9em;
469
+ padding-right: 8px;
470
+ }
471
+
472
+ .wc-edit-filename-input {
473
+ max-width: 200px !important;
474
+ }
475
+
476
+ .wc-edit-filename-input input {
477
+ font-family: monospace;
478
+ font-size: 0.9em;
479
+ padding: 4px 8px !important;
480
+ }
481
+
482
+ #naia-wc-edit-textarea textarea {
483
+ font-family: monospace;
484
+ font-size: 0.85em;
485
+ line-height: 1.4;
486
+ }
487
+
488
+ /* ========== Wildcard Template ========== */
489
+
490
+ #naia-wc-template-group {
491
+ margin-bottom: 12px;
492
+ padding: 10px 12px;
493
+ border: 1px solid var(--border-color-primary);
494
+ border-radius: 8px;
495
+ background: var(--background-fill-primary);
496
+ }
497
+
498
+ .wc-template-label {
499
+ padding-left: 8px;
500
+ margin-bottom: 8px;
501
+ font-size: 0.95em;
502
+ }
503
+
504
+ .wc-template-label span {
505
+ color: var(--body-text-color-subdued);
506
+ font-weight: normal;
507
+ }
508
+
509
+ /* Template textarea - vertical center alignment */
510
+ #naia-wildcard-template textarea {
511
+ padding-left: 16px !important;
512
+ line-height: 1.6 !important;
513
+ }
514
+
515
+ #naia-wildcard-template textarea::placeholder {
516
+ padding-left: 0 !important;
517
+ }
518
+
519
+ /* Hint text styling */
520
+ .wildcard-hint {
521
+ font-size: 0.85em;
522
+ color: var(--body-text-color-subdued);
523
+ margin-top: 6px;
524
+ padding: 4px 0;
525
+ display: flex;
526
+ align-items: center;
527
+ min-height: 24px;
528
+ }
529
+
530
+ .wildcard-hint code {
531
+ background: var(--background-fill-secondary);
532
+ padding: 2px 6px;
533
+ border-radius: 3px;
534
+ font-size: 0.9em;
535
+ }
536
+ """