Refactor prompt metadata and tag storage helpers
This commit is contained in:
@@ -6,7 +6,7 @@ DuckDB 数据库管理模块
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from typing import Optional, Any
|
||||
from typing import Callable, Optional, Any
|
||||
|
||||
from . import config
|
||||
from .utils import get_sql_path
|
||||
@@ -145,6 +145,33 @@ def reinit_duckdb() -> None:
|
||||
init_duckdb()
|
||||
|
||||
|
||||
def rewrite_parquet(conn: Any, path: str, temp_table: str, mutate: Callable[[Any, str], None]) -> None:
|
||||
"""
|
||||
Rewrite a parquet file through a temporary DuckDB table.
|
||||
|
||||
Args:
|
||||
conn: DuckDB connection.
|
||||
path: Target parquet path.
|
||||
temp_table: Temporary table name controlled by the caller.
|
||||
mutate: Callback that mutates the temporary table.
|
||||
"""
|
||||
path_sql = path.replace("\\", "/")
|
||||
|
||||
with DB_WRITE_LOCK:
|
||||
try:
|
||||
conn.execute(f"DROP TABLE IF EXISTS {temp_table}")
|
||||
conn.execute(f"CREATE TABLE {temp_table} AS SELECT * FROM read_parquet('{path_sql}')")
|
||||
mutate(conn, temp_table)
|
||||
conn.execute(f"COPY {temp_table} TO '{path_sql}' (FORMAT PARQUET)")
|
||||
finally:
|
||||
try:
|
||||
conn.execute(f"DROP TABLE IF EXISTS {temp_table}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to drop temporary table {temp_table}: {e}")
|
||||
|
||||
reinit_duckdb()
|
||||
|
||||
|
||||
def ensure_parquet_exists(path: str, schema_sql: str) -> None:
|
||||
"""
|
||||
确保 Parquet 文件存在,如果不存在则创建空文件
|
||||
|
||||
+17
-45
@@ -8,7 +8,7 @@ import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from . import config
|
||||
from .database import DB_WRITE_LOCK, ensure_parquet_exists, reinit_duckdb
|
||||
from .database import ensure_parquet_exists, rewrite_parquet
|
||||
|
||||
logger = logging.getLogger("SimplePrompt")
|
||||
|
||||
@@ -254,18 +254,12 @@ def delete_tag(conn: Any, name: str, source: str) -> bool:
|
||||
return False
|
||||
|
||||
ensure_parquet_exists(path, config.TAGS_SCHEMA)
|
||||
path_sql = path.replace("\\", "/")
|
||||
|
||||
try:
|
||||
# Delete using temp table
|
||||
with DB_WRITE_LOCK:
|
||||
conn.execute("DROP TABLE IF EXISTS temp_del_tags")
|
||||
conn.execute(f"CREATE TABLE temp_del_tags AS SELECT * FROM read_parquet('{path_sql}')")
|
||||
conn.execute("DELETE FROM temp_del_tags WHERE name = ?", [name])
|
||||
conn.execute(f"COPY temp_del_tags TO '{path_sql}' (FORMAT PARQUET)")
|
||||
conn.execute("DROP TABLE temp_del_tags")
|
||||
def remove_tag(edit_conn: Any, table_name: str) -> None:
|
||||
edit_conn.execute(f"DELETE FROM {table_name} WHERE name = ?", [name])
|
||||
|
||||
reinit_duckdb() # Refresh view
|
||||
rewrite_parquet(conn, path, "temp_del_tags", remove_tag)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Delete tag failed: {e}")
|
||||
@@ -292,16 +286,12 @@ def add_tags(conn: Any, tags_data: List[Dict[str, Any]], source: str = "user") -
|
||||
return 0
|
||||
|
||||
ensure_parquet_exists(target_path, config.TAGS_SCHEMA)
|
||||
target_path_sql = target_path.replace("\\", "/")
|
||||
|
||||
try:
|
||||
# Create temp table from existing parquet
|
||||
with DB_WRITE_LOCK:
|
||||
conn.execute("DROP TABLE IF EXISTS temp_edit_tags")
|
||||
conn.execute(f"CREATE TABLE temp_edit_tags AS SELECT * FROM read_parquet('{target_path_sql}')")
|
||||
|
||||
count_added = 0
|
||||
count_added = 0
|
||||
|
||||
def upsert_tags(edit_conn: Any, table_name: str) -> None:
|
||||
nonlocal count_added
|
||||
for item in tags_data:
|
||||
name = item.get("name")
|
||||
if not name:
|
||||
@@ -311,21 +301,12 @@ def add_tags(conn: Any, tags_data: List[Dict[str, Any]], source: str = "user") -
|
||||
post_count = int(item.get("post_count", 0))
|
||||
alias = item.get("alias", [])
|
||||
|
||||
# Delete existing if any (overwrite logic)
|
||||
conn.execute("DELETE FROM temp_edit_tags WHERE name = ?", [name])
|
||||
|
||||
# Insert new tag
|
||||
conn.execute("INSERT INTO temp_edit_tags VALUES (?, ?, ?, ?)", [name, category, post_count, alias])
|
||||
edit_conn.execute(f"DELETE FROM {table_name} WHERE name = ?", [name])
|
||||
edit_conn.execute(f"INSERT INTO {table_name} VALUES (?, ?, ?, ?)", [name, category, post_count, alias])
|
||||
count_added += 1
|
||||
|
||||
# Write back to parquet
|
||||
conn.execute(f"COPY temp_edit_tags TO '{target_path_sql}' (FORMAT PARQUET)")
|
||||
conn.execute("DROP TABLE temp_edit_tags")
|
||||
|
||||
# Refresh Main View
|
||||
reinit_duckdb()
|
||||
|
||||
return count_added
|
||||
rewrite_parquet(conn, target_path, "temp_edit_tags", upsert_tags)
|
||||
return count_added
|
||||
except Exception as e:
|
||||
logger.error(f"Add tags failed: {e}")
|
||||
return 0
|
||||
@@ -357,15 +338,11 @@ def toggle_like_tag(
|
||||
return False
|
||||
|
||||
ensure_parquet_exists(config.LIKED_TAGS_PATH, config.TAGS_SCHEMA)
|
||||
liked_path_sql = config.LIKED_TAGS_PATH.replace("\\", "/")
|
||||
|
||||
try:
|
||||
with DB_WRITE_LOCK:
|
||||
conn.execute("DROP TABLE IF EXISTS temp_liked_tags")
|
||||
conn.execute(f"CREATE TABLE temp_liked_tags AS SELECT * FROM read_parquet('{liked_path_sql}')")
|
||||
|
||||
# Always remove first to avoid duplicates
|
||||
conn.execute("DELETE FROM temp_liked_tags WHERE name = ?", [name])
|
||||
def update_liked(edit_conn: Any, table_name: str) -> None:
|
||||
nonlocal category, post_count, alias
|
||||
edit_conn.execute(f"DELETE FROM {table_name} WHERE name = ?", [name])
|
||||
|
||||
if should_like:
|
||||
# If data is missing, try to find it in other sources
|
||||
@@ -373,7 +350,7 @@ def toggle_like_tag(
|
||||
for source_path in [config.USER_TAGS_PATH, config.DEFAULT_TAGS_PATH, config.TAGS_PARQUET_PATH]:
|
||||
if os.path.exists(source_path):
|
||||
src_sql = source_path.replace("\\", "/")
|
||||
res = conn.execute(
|
||||
res = edit_conn.execute(
|
||||
f"SELECT category, post_count, alias FROM read_parquet('{src_sql}') WHERE name = ?", [name]
|
||||
).fetchone()
|
||||
if res:
|
||||
@@ -390,14 +367,9 @@ def toggle_like_tag(
|
||||
if alias is None:
|
||||
alias = []
|
||||
|
||||
conn.execute("INSERT INTO temp_liked_tags VALUES (?, ?, ?, ?)", [name, category, post_count, alias])
|
||||
edit_conn.execute(f"INSERT INTO {table_name} VALUES (?, ?, ?, ?)", [name, category, post_count, alias])
|
||||
|
||||
# Write back
|
||||
conn.execute(f"COPY temp_liked_tags TO '{liked_path_sql}' (FORMAT PARQUET)")
|
||||
conn.execute("DROP TABLE temp_liked_tags")
|
||||
|
||||
# Refresh View
|
||||
reinit_duckdb()
|
||||
rewrite_parquet(conn, config.LIKED_TAGS_PATH, "temp_liked_tags", update_liked)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Toggle like failed: {e}")
|
||||
|
||||
+25
-107
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick, computed, onMounted, onUnmounted } from 'vue';
|
||||
import { ref, watch, nextTick, onUnmounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { DuckDBService } from '../utils/duckdbService';
|
||||
import { settings } from '../utils/settings';
|
||||
@@ -10,7 +10,6 @@ import OtherFunctions from './OtherFunctions.vue';
|
||||
import TagSearchModal from './TagSearchModal.vue';
|
||||
import { formatGroupedTags, textToTags, tagsToText } from '../utils/promptParser';
|
||||
import type { TagItem } from '../utils/types';
|
||||
import { metaService } from '../utils/metaService';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
@@ -318,28 +317,7 @@ const selectItem = (item: any) => {
|
||||
const el = textareaRef.value;
|
||||
const cursor = el.selectionEnd;
|
||||
const text = localValue.value;
|
||||
|
||||
// Get tag name and apply settings
|
||||
let tagName = item.name;
|
||||
|
||||
// Cache the category from the selected item
|
||||
if (item.category !== undefined) {
|
||||
categoryCache.value[tagName.toLowerCase()] = item.category;
|
||||
// Also cache alias if matched?
|
||||
}
|
||||
|
||||
// Apply text formatting settings
|
||||
if (settings.convertUnderscoreToSpace) {
|
||||
tagName = tagName.replace(/_/g, ' ');
|
||||
// If we converted space, we should cache the spaced version too for the parser
|
||||
if (item.category !== undefined) {
|
||||
categoryCache.value[tagName.toLowerCase()] = item.category;
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.escapeBrackets) {
|
||||
tagName = tagName.replace(/\(/g, '\\(').replace(/\)/g, '\\)');
|
||||
}
|
||||
const tagName = formatTagNameForInsertion(item.name, item.category);
|
||||
|
||||
// Replace the query with the selected tag name
|
||||
const prefix = text.substring(0, queryStartPos.value);
|
||||
@@ -374,6 +352,28 @@ const handleBlur = () => {
|
||||
}, 200);
|
||||
};
|
||||
|
||||
const cacheTagCategory = (tagName: string, category?: number) => {
|
||||
if (category !== undefined) {
|
||||
categoryCache.value[tagName.toLowerCase()] = category;
|
||||
}
|
||||
};
|
||||
|
||||
const formatTagNameForInsertion = (tagName: string, category?: number) => {
|
||||
let formattedTagName = tagName;
|
||||
cacheTagCategory(formattedTagName, category);
|
||||
|
||||
if (settings.convertUnderscoreToSpace) {
|
||||
formattedTagName = formattedTagName.replace(/_/g, ' ');
|
||||
cacheTagCategory(formattedTagName, category);
|
||||
}
|
||||
|
||||
if (settings.escapeBrackets) {
|
||||
formattedTagName = formattedTagName.replace(/\(/g, '\\(').replace(/\)/g, '\\)');
|
||||
}
|
||||
|
||||
return formattedTagName;
|
||||
};
|
||||
|
||||
// Handle search modal
|
||||
const openSearchModal = () => {
|
||||
showSearchModal.value = true;
|
||||
@@ -386,21 +386,7 @@ const closeSearchModal = () => {
|
||||
// Add tag from search
|
||||
const handleAddTag = (tagName: string, category: number) => {
|
||||
cancelTextProcessing();
|
||||
// Apply text formatting settings
|
||||
let formattedTagName = tagName;
|
||||
|
||||
// Cache the category
|
||||
categoryCache.value[tagName.toLowerCase()] = category;
|
||||
|
||||
if (settings.convertUnderscoreToSpace) {
|
||||
formattedTagName = formattedTagName.replace(/_/g, ' ');
|
||||
// Cache formatted version too
|
||||
categoryCache.value[formattedTagName.toLowerCase()] = category;
|
||||
}
|
||||
|
||||
if (settings.escapeBrackets) {
|
||||
formattedTagName = formattedTagName.replace(/\(/g, '\\(').replace(/\)/g, '\\)');
|
||||
}
|
||||
const formattedTagName = formatTagNameForInsertion(tagName, category);
|
||||
|
||||
// Create a new tag and add it to the list
|
||||
const newTag: TagItem = {
|
||||
@@ -434,9 +420,6 @@ const handleFormat = () => {
|
||||
// Replace comma followed immediately by non-space with comma space
|
||||
// Also trim multiple spaces
|
||||
newVal = newVal.replace(/,(\S)/g, ', $1');
|
||||
// Maybe normalize spaces around commas?
|
||||
// newVal = newVal.replace(/\s*,\s*/g, ', ');
|
||||
// Stick to explicit request: ",xxx" -> ", xxx"
|
||||
|
||||
if (newVal !== localValue.value) {
|
||||
localValue.value = newVal;
|
||||
@@ -553,61 +536,6 @@ const handleAutoMetaToggle = (val: boolean) => {
|
||||
settings.autoMetaEnabled = val;
|
||||
};
|
||||
|
||||
// Check Auto Meta on Save?
|
||||
// Actually, user said: "open time click save automatically add meta word"
|
||||
// This implies the PARENT calls save, and we should intercept it?
|
||||
// Or we just update the modelValue before emit('update:modelValue')?
|
||||
// The parent `App.vue` listens to `save` event from `ModalWrapper`.
|
||||
// `ModalWrapper` emits `save` when "Done" or "Save" is clicked.
|
||||
// `TextEditor` is inside `ModalWrapper`.
|
||||
// `TextEditor` syncs `v-model` (localValue) with Parent's `promptText`.
|
||||
// When `App.vue` calls `saveChanges`, it uses `promptText`.
|
||||
// So we need to apply Auto Meta to `promptText` whenever it changes? No, too aggressive.
|
||||
// "Click save automatically in end add meta word".
|
||||
// So when the user clicks "Save" / "Use" in the UI.
|
||||
// The UI buttons are in `ModalWrapper`?
|
||||
// `App.vue`:
|
||||
// <ModalWrapper @save="saveChanges"> ... </ModalWrapper>
|
||||
// const saveChanges = () => { emit('save', promptText.value); ... }
|
||||
// `ModalWrapper` has the buttons.
|
||||
// `TextEditor` doesn't control the Save button.
|
||||
//
|
||||
// Solution:
|
||||
// Watch `localValue`? No.
|
||||
// Hook into `onUnmounted`? No.
|
||||
// Since `TextEditor` updates `modelValue` in real-time (`@input`), parent always has latest text.
|
||||
// We need to intercept the Final Save.
|
||||
// BUT `TextEditor` doesn't know when Save happens.
|
||||
//
|
||||
// Maybe `TextEditor` should apply auto-meta continuously? No.
|
||||
// Maybe we can export a function `applyAutoMeta()` and `App.vue` calls it?
|
||||
// `App.vue` has a ref to `TextEditor`? Content is in slot.
|
||||
//
|
||||
// Alternative: `TextEditor` applies auto-meta whenever `localValue` changes IF enabled? No, "Click save".
|
||||
//
|
||||
// Maybe "Auto Meta" switch is just for *insertion*?
|
||||
// "Switch, open time click save automatically in end add".
|
||||
// This strongly implies "On Save".
|
||||
//
|
||||
// I can add a watcher on `settings.autoMetaEnabled`? No.
|
||||
//
|
||||
// I will assume `ModalWrapper` or `App.vue` needs modification?
|
||||
// Or `TextEditor` emits a special event or `App.vue` logic needs update.
|
||||
// `App.vue`:
|
||||
// <TextEditor v-model="promptText" />
|
||||
//
|
||||
// I can update `App.vue` to check `settings.autoMetaEnabled`.
|
||||
// And fetch `metaService.metaTags`.
|
||||
// And append them.
|
||||
//
|
||||
// So `TextEditor` manages the SWITCH (via `OtherFunctions` -> `settings`).
|
||||
// `App.vue` performs the logic on Save.
|
||||
// This separates concerns nicely.
|
||||
//
|
||||
// So `TextEditor` does NOT need `handleAutoMetaOnSave` logic.
|
||||
// It just updates the setting.
|
||||
// New tasks: Update `App.vue` to handle Auto Meta on save.
|
||||
|
||||
defineExpose({ focus });
|
||||
</script>
|
||||
|
||||
@@ -625,16 +553,6 @@ defineExpose({ focus });
|
||||
</div>
|
||||
|
||||
<!-- 2. Text Editor -->
|
||||
<!-- We adjust height using splitPercentage.
|
||||
Note: We subtract toolbar height roughly or rely on flex if we set flex-basis.
|
||||
But percentage of container is easiest if we treat toolbar as part of the % or separate.
|
||||
Let's try: Editor gets splitPercentage%, Visual gets rest.
|
||||
But toolbar breaks pure %.
|
||||
Better: Editor gets `height: calc( ${splitPercentage}% - 45px )` ? No.
|
||||
|
||||
Let's try flex-grow based on percentage? No.
|
||||
Let's just apply height in % to editor, and flex:1 to visual.
|
||||
-->
|
||||
<div class="sp-editor-area" :style="{ height: `calc(${splitPercentage}% - 40px)` }">
|
||||
<textarea
|
||||
ref="textareaRef"
|
||||
|
||||
+80
-104
@@ -1,6 +1,14 @@
|
||||
import type { TagItem } from './types';
|
||||
import { TagCategory } from './types';
|
||||
|
||||
interface PromptTagRange {
|
||||
text: string;
|
||||
fullMatch: string;
|
||||
start: number;
|
||||
end: number;
|
||||
weight: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse prompt text into TagItem array
|
||||
* Matches (text:weight) format or plain comma-separated text
|
||||
@@ -270,58 +278,33 @@ export function applyAutoMeta(
|
||||
): string {
|
||||
if (!metaTags.length) return text;
|
||||
|
||||
// Normalize for matching (lowercase)
|
||||
const metaLower = new Set(metaTags.map(t => t.toLowerCase()));
|
||||
const ratingLower = new Set(ratingTagNames.map(t => t.toLowerCase()));
|
||||
const foundTags = parsePromptTagRanges(text);
|
||||
const metaToRemove: PromptTagRange[] = [];
|
||||
const existingMetaWeights = new Map<string, number>();
|
||||
|
||||
// Parse to find tag positions and existing weights
|
||||
// We need to track: { tagText, startIndex, endIndex, weight }
|
||||
interface TagMatch {
|
||||
text: string;
|
||||
fullMatch: string;
|
||||
start: number;
|
||||
end: number;
|
||||
weight: number;
|
||||
for (const tag of foundTags) {
|
||||
const lower = tag.text.toLowerCase();
|
||||
if (metaLower.has(lower)) {
|
||||
metaToRemove.push(tag);
|
||||
existingMetaWeights.set(lower, tag.weight);
|
||||
}
|
||||
}
|
||||
|
||||
const foundTags: TagMatch[] = [];
|
||||
const withoutOldMeta = removeTagRanges(text, metaToRemove);
|
||||
const metaBlock = buildMetaBlock(metaTags, existingMetaWeights);
|
||||
const insertIndex = findFirstRatingIndex(withoutOldMeta, ratingTagNames, ratingLower);
|
||||
|
||||
// Regex patterns:
|
||||
// 1. Weighted tag: (text:weight)
|
||||
// 2. Plain tag: text between commas/newlines
|
||||
// We need to carefully find positions.
|
||||
return insertBlock(withoutOldMeta, insertIndex, metaBlock);
|
||||
}
|
||||
|
||||
// Split by delimiters but keep track of positions
|
||||
function parsePromptTagRanges(text: string): PromptTagRange[] {
|
||||
const foundTags: PromptTagRange[] = [];
|
||||
let i = 0;
|
||||
let currentStart = 0;
|
||||
let currentTag = '';
|
||||
|
||||
const processTag = (tagStr: string, fullMatch: string, start: number, end: number) => {
|
||||
const trimmed = tagStr.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
// Check for weight syntax: (text:weight)
|
||||
const weightMatch = trimmed.match(/^\((.+):(\d+\.?\d*)\)$/);
|
||||
let tagText = trimmed;
|
||||
let weight = 1.0;
|
||||
|
||||
if (weightMatch) {
|
||||
tagText = weightMatch[1].trim();
|
||||
weight = parseFloat(weightMatch[2]);
|
||||
}
|
||||
|
||||
// Unescape parentheses for matching
|
||||
const unescaped = tagText.replace(/\\([()])/g, '$1');
|
||||
|
||||
foundTags.push({
|
||||
text: unescaped,
|
||||
fullMatch,
|
||||
start,
|
||||
end,
|
||||
weight
|
||||
});
|
||||
};
|
||||
|
||||
while (i < text.length) {
|
||||
const char = text[i];
|
||||
|
||||
@@ -342,8 +325,7 @@ export function applyAutoMeta(
|
||||
|
||||
if (depth === 0) {
|
||||
const fullMatch = text.substring(i, j);
|
||||
const content = text.substring(i + 1, j - 1);
|
||||
processTag(fullMatch, fullMatch, i, j);
|
||||
addPromptTagRange(foundTags, fullMatch, fullMatch, i, j);
|
||||
i = j;
|
||||
currentStart = j;
|
||||
currentTag = '';
|
||||
@@ -361,7 +343,7 @@ export function applyAutoMeta(
|
||||
// Handle delimiter
|
||||
if (char === ',' || char === '\n') {
|
||||
if (currentTag.trim()) {
|
||||
processTag(currentTag.trim(), currentTag, currentStart, i);
|
||||
addPromptTagRange(foundTags, currentTag.trim(), currentTag, currentStart, i);
|
||||
}
|
||||
i++;
|
||||
currentStart = i;
|
||||
@@ -375,103 +357,93 @@ export function applyAutoMeta(
|
||||
|
||||
// Last tag
|
||||
if (currentTag.trim()) {
|
||||
processTag(currentTag.trim(), currentTag, currentStart, i);
|
||||
addPromptTagRange(foundTags, currentTag.trim(), currentTag, currentStart, i);
|
||||
}
|
||||
|
||||
// Identify which tags are meta and which are rating
|
||||
const metaToRemove: TagMatch[] = [];
|
||||
const existingMetaWeights = new Map<string, number>();
|
||||
let ratingStartIndex = text.length;
|
||||
return foundTags;
|
||||
}
|
||||
|
||||
for (const tag of foundTags) {
|
||||
const lower = tag.text.toLowerCase();
|
||||
function addPromptTagRange(
|
||||
foundTags: PromptTagRange[],
|
||||
tagStr: string,
|
||||
fullMatch: string,
|
||||
start: number,
|
||||
end: number
|
||||
) {
|
||||
const trimmed = tagStr.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
if (metaLower.has(lower)) {
|
||||
metaToRemove.push(tag);
|
||||
existingMetaWeights.set(lower, tag.weight);
|
||||
}
|
||||
const weightMatch = trimmed.match(/^\((.+):(\d+\.?\d*)\)$/);
|
||||
let tagText = trimmed;
|
||||
let weight = 1.0;
|
||||
|
||||
if (ratingLower.has(lower)) {
|
||||
if (tag.start < ratingStartIndex) {
|
||||
ratingStartIndex = tag.start;
|
||||
}
|
||||
}
|
||||
if (weightMatch) {
|
||||
tagText = weightMatch[1].trim();
|
||||
weight = parseFloat(weightMatch[2]);
|
||||
}
|
||||
|
||||
// Build result by removing meta tags
|
||||
// Sort removals by position descending to avoid index shifting
|
||||
const removals = [...metaToRemove].sort((a, b) => b.start - a.start);
|
||||
foundTags.push({
|
||||
text: tagText.replace(/\\([()])/g, '$1'),
|
||||
fullMatch,
|
||||
start,
|
||||
end,
|
||||
weight
|
||||
});
|
||||
}
|
||||
|
||||
function removeTagRanges(text: string, ranges: PromptTagRange[]): string {
|
||||
let result = text;
|
||||
const removals = [...ranges].sort((a, b) => b.start - a.start);
|
||||
|
||||
for (const removal of removals) {
|
||||
// Find the actual range to remove (including surrounding comma/space)
|
||||
let removeStart = removal.start;
|
||||
let removeEnd = removal.end;
|
||||
|
||||
// Look backward for comma/space
|
||||
while (removeStart > 0 && /[\s,]/.test(result[removeStart - 1])) {
|
||||
removeStart--;
|
||||
}
|
||||
|
||||
// Look forward for comma/space (but not newlines - preserve structure)
|
||||
while (removeEnd < result.length && result[removeEnd] === ' ') {
|
||||
removeEnd++;
|
||||
}
|
||||
if (removeEnd < result.length && result[removeEnd] === ',') {
|
||||
removeEnd++;
|
||||
// Also consume trailing space after comma
|
||||
while (removeEnd < result.length && result[removeEnd] === ' ') {
|
||||
removeEnd++;
|
||||
}
|
||||
}
|
||||
|
||||
// If we're removing from the start of a line, don't leave orphan comma
|
||||
// If there's content before and after, we need a comma
|
||||
const before = result.substring(0, removeStart);
|
||||
const after = result.substring(removeEnd);
|
||||
|
||||
result = before + after;
|
||||
|
||||
// Update ratingStartIndex if needed
|
||||
const removedLength = removeEnd - removeStart;
|
||||
if (ratingStartIndex > removal.start) {
|
||||
ratingStartIndex -= removedLength;
|
||||
}
|
||||
result = result.substring(0, removeStart) + result.substring(removeEnd);
|
||||
}
|
||||
|
||||
// Clean up any double commas or trailing commas before newlines
|
||||
result = result.replace(/,\s*,/g, ',');
|
||||
result = result.replace(/,\s*\n/g, ',\n');
|
||||
result = result.replace(/\n\s*,/g, '\n');
|
||||
return result
|
||||
.replace(/,\s*,/g, ',')
|
||||
.replace(/,\s*\n/g, ',\n')
|
||||
.replace(/\n\s*,/g, '\n');
|
||||
}
|
||||
|
||||
// Build meta block string
|
||||
const metaBlock = metaTags.map(m => {
|
||||
function buildMetaBlock(metaTags: string[], existingMetaWeights: Map<string, number>): string {
|
||||
return metaTags.map(m => {
|
||||
const weight = existingMetaWeights.get(m.toLowerCase()) || 1.0;
|
||||
// Escape parens
|
||||
const escaped = m.replace(/\(/g, '\\(').replace(/\)/g, '\\)');
|
||||
if (weight !== 1.0) {
|
||||
return `(${escaped}:${weight.toFixed(1)})`;
|
||||
}
|
||||
return escaped;
|
||||
}).join(', ') + ',';
|
||||
}
|
||||
|
||||
// Find insertion point (before rating section, or at end)
|
||||
// We need to recalculate ratingStartIndex in the modified string
|
||||
// Simpler approach: find first rating tag in modified text
|
||||
|
||||
const modifiedTags = textToTags(result);
|
||||
let insertIndex = result.length;
|
||||
function findFirstRatingIndex(text: string, ratingTagNames: string[], ratingLower: Set<string>): number {
|
||||
const modifiedTags = textToTags(text);
|
||||
let insertIndex = text.length;
|
||||
|
||||
for (let idx = 0; idx < modifiedTags.length; idx++) {
|
||||
const lower = modifiedTags[idx].text.toLowerCase();
|
||||
if (ratingLower.has(lower)) {
|
||||
// Find this tag's position in result
|
||||
// This is complex... let's use a simpler approach
|
||||
// Find the first occurrence of any rating tag
|
||||
for (const ratingName of ratingTagNames) {
|
||||
const escaped = ratingName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const escaped = escapeRegExp(ratingName);
|
||||
const regex = new RegExp(`(^|[,\\n\\s])${escaped}([,\\n]|$)`, 'i');
|
||||
const match = result.match(regex);
|
||||
const match = text.match(regex);
|
||||
if (match && match.index !== undefined) {
|
||||
const pos = match.index + match[1].length;
|
||||
if (pos < insertIndex) {
|
||||
@@ -483,17 +455,17 @@ export function applyAutoMeta(
|
||||
}
|
||||
}
|
||||
|
||||
// Find proper insertion point (before rating, after last content)
|
||||
// If there's content before insert point that doesn't end with newline, add newline
|
||||
let prefix = result.substring(0, insertIndex).trimEnd();
|
||||
let suffix = result.substring(insertIndex);
|
||||
return insertIndex;
|
||||
}
|
||||
|
||||
function insertBlock(text: string, insertIndex: number, block: string): string {
|
||||
let prefix = text.substring(0, insertIndex).trimEnd();
|
||||
let suffix = text.substring(insertIndex);
|
||||
|
||||
// Ensure proper separation
|
||||
if (prefix && !prefix.endsWith(',') && !prefix.endsWith('\n')) {
|
||||
prefix += ',';
|
||||
}
|
||||
|
||||
// Add newlines for separation if needed
|
||||
if (prefix && !prefix.endsWith('\n\n')) {
|
||||
if (prefix.endsWith('\n')) {
|
||||
prefix += '\n';
|
||||
@@ -506,5 +478,9 @@ export function applyAutoMeta(
|
||||
suffix = '\n\n' + suffix.trimStart();
|
||||
}
|
||||
|
||||
return prefix + metaBlock + suffix;
|
||||
return prefix + block + suffix;
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user