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
+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, '\\$&');
}