Refactor prompt metadata and tag storage helpers

This commit is contained in:
0nikod
2026-05-13 21:03:20 +08:00
parent 13ba363e4a
commit 451587ca2c
4 changed files with 150 additions and 257 deletions
+28 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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, '\\$&');
}