feat: add prompt comparison feature (#704)
# PR Checklist - [ ] Have you checked if it works normally in all models? *Ignore this if it doesn't use models.* - [x] Have you checked if it works normally in all web, local, and node hosted versions? If it doesn't, have you blocked it in those versions? - [x] Have you added type definitions? # Description This PR adds a **prompt comparison feature** to the prompt preset interface. Now, users can compare two different prompts to identify differences. ## How to Use 1. Open the prompt preset window. 2. Click the diff button (next to the copy button) for the first prompt to use as the baseline. The button will turn green, indicating selection. <img width="487" alt="c" src="https://github.com/user-attachments/assets/c2dcf5fa-c4ee-4b3f-8e52-3f0866b12bc4" /> 3. Click the diff button for the second prompt to compare against the baseline. A diff view will appear. 4. Clicking the same diff button twice will clear the selection. ## Diff Display Details - Line-level comparison - Modified lines: blue vertical line. - Deleted content: red text on red background with red vertical line. - Added content: green text on light green background with green vertical line. <img width="597" alt="b" src="https://github.com/user-attachments/assets/0d026e9e-a7a0-4a17-9b80-a2b57c74d7f9" /> - If the prompt content is identical, the following message will be displayed at the top <img width="600" alt="a" src="https://github.com/user-attachments/assets/dd5f36f2-9e96-4279-9f9f-79a17f9e4c89" /> ## Implementation Details 1. `handleDiffMode` manages prompt selection and clearing. 2. `checkDiff` compares prompts and uses `highlightChanges` to mark differences. 3. Special characters are escaped with `escapeHtml` to ensure the text is displayed as-is. 4. `resultHtml` is rendered via `alertMd`. ## Notes - This feature uses the `jsdiff` library to compare prompts efficiently. - The comparison includes the role, type1, and type2 fields (e.g., ## system; plain; main). Even if the prompts' text is identical, differences in these fields will be treated as a mismatch. - The rendering process in `alertMd` appears to sanitize potentially dangerous content. However, additional escaping is applied to ensure that the text is displayed as-is. - `botpreset.svelte` grew significantly due to this feature; modularization was considered but not implemented. - The reason for using the "Prompt Preset" window instead of the "Prompt Preview" feature is that "Prompt Preview" displays the final form with CBS processing applied. Even if the content in "Prompt Preview" appears identical, the actual prompts can differ significantly. If this feature, its implementation, or any other issue doesn't fit the project's vision, feel free to reject this PR! Thank you for reviewing!
This commit is contained in:
@@ -47,6 +47,7 @@
|
||||
"core-js": "^3.35.0",
|
||||
"cors": "^2.8.5",
|
||||
"crc": "^4.3.2",
|
||||
"diff": "^7.0.0",
|
||||
"dompurify": "^3.0.8",
|
||||
"eventsource-parser": "^1.1.2",
|
||||
"exifr": "^7.1.3",
|
||||
|
||||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@@ -101,6 +101,9 @@ importers:
|
||||
crc:
|
||||
specifier: ^4.3.2
|
||||
version: 4.3.2(buffer@6.0.3)
|
||||
diff:
|
||||
specifier: ^7.0.0
|
||||
version: 7.0.0
|
||||
dompurify:
|
||||
specifier: ^3.0.8
|
||||
version: 3.0.8
|
||||
@@ -1888,6 +1891,10 @@ packages:
|
||||
resolution: {integrity: sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==}
|
||||
engines: {node: '>=0.3.1'}
|
||||
|
||||
diff@7.0.0:
|
||||
resolution: {integrity: sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==}
|
||||
engines: {node: '>=0.3.1'}
|
||||
|
||||
dir-glob@3.0.1:
|
||||
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -5561,6 +5568,8 @@ snapshots:
|
||||
|
||||
diff@5.1.0: {}
|
||||
|
||||
diff@7.0.0: {}
|
||||
|
||||
dir-glob@3.0.1:
|
||||
dependencies:
|
||||
path-type: 4.0.0
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { alertCardExport, alertConfirm, alertError } from "../../ts/alert";
|
||||
import { alertCardExport, alertConfirm, alertError, alertMd, alertWait } from "../../ts/alert";
|
||||
import { language } from "../../lang";
|
||||
import { changeToPreset, copyPreset, downloadPreset, importPreset } from "../../ts/storage/database.svelte";
|
||||
import { changeToPreset, copyPreset, downloadPreset, importPreset, getDatabase } from "../../ts/storage/database.svelte";
|
||||
import { DBState } from 'src/ts/stores.svelte';
|
||||
import { CopyIcon, Share2Icon, PencilIcon, FolderUpIcon, PlusIcon, TrashIcon, XIcon } from "lucide-svelte";
|
||||
import { CopyIcon, Share2Icon, PencilIcon, FolderUpIcon, PlusIcon, TrashIcon, XIcon, GitCompare } from "lucide-svelte";
|
||||
import TextInput from "../UI/GUI/TextInput.svelte";
|
||||
import { prebuiltPresets } from "src/ts/process/templates/templates";
|
||||
import { ShowRealmFrameStore } from "src/ts/stores.svelte";
|
||||
import type { PromptItem, PromptItemPlain, PromptItemChatML, PromptItemTyped, PromptItemAuthorNote, PromptItemChat } from "src/ts/process/prompt.ts";
|
||||
import { diffWordsWithSpace, diffLines } from 'diff';
|
||||
|
||||
let editMode = $state(false)
|
||||
interface Props {
|
||||
@@ -15,6 +17,191 @@
|
||||
|
||||
let { close = () => {} }: Props = $props();
|
||||
|
||||
let diffMode = false
|
||||
let selectedPrompts: string[] = []
|
||||
let selectedDiffPreset = $state(-1)
|
||||
|
||||
function isPromptItemPlain(item: PromptItem): item is PromptItemPlain {
|
||||
return (
|
||||
item.type === 'plain' || item.type === 'jailbreak' || item.type === 'cot'
|
||||
);
|
||||
}
|
||||
|
||||
function isPromptItemChatML(item: PromptItem): item is PromptItemChatML {
|
||||
return item.type === 'chatML'
|
||||
}
|
||||
|
||||
function isPromptItemTyped(item: PromptItem): item is PromptItemTyped {
|
||||
return (
|
||||
item.type === 'persona' ||
|
||||
item.type === 'description' ||
|
||||
item.type === 'lorebook' ||
|
||||
item.type === 'postEverything' ||
|
||||
item.type === 'memory'
|
||||
)
|
||||
}
|
||||
|
||||
function isPromptItemAuthorNote(item: PromptItem): item is PromptItemAuthorNote {
|
||||
return item.type === 'authornote'
|
||||
}
|
||||
|
||||
function isPromptItemChat(item: PromptItem): item is PromptItemChat {
|
||||
return item.type === 'chat'
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/\//g, '/')
|
||||
.replace(/\\/g, '\')
|
||||
.replace(/`/g, '`')
|
||||
.replace(/ /g, ' \u200B')
|
||||
.replace(/\n/g, '<br>')
|
||||
}
|
||||
|
||||
function getPrompt(id: number): string {
|
||||
const db = getDatabase()
|
||||
const formated = safeStructuredClone(db.botPresets[id].promptTemplate)
|
||||
let prompt = ''
|
||||
|
||||
for(let i=0;i<formated.length;i++){
|
||||
const item = formated[i]
|
||||
|
||||
switch (true) {
|
||||
case isPromptItemPlain(item):{
|
||||
prompt += '## ' + (item.role ?? 'Unknown') + '; ' + item.type + '; ' + item.type2 + '\n'
|
||||
prompt += '\n' + item.text.replaceAll('```', '\\`\\`\\`') + '\n\n'
|
||||
break
|
||||
}
|
||||
|
||||
case isPromptItemChatML(item):{
|
||||
prompt += '## ' + item.type + '\n'
|
||||
prompt += '\n' + item.text.replaceAll('```', '\\`\\`\\`') + '\n\n'
|
||||
break
|
||||
}
|
||||
|
||||
case isPromptItemTyped(item):{
|
||||
prompt += '## ' + 'system' + '; ' + item.type + '\n'
|
||||
if(item.innerFormat){
|
||||
prompt += '\n' + item.innerFormat.replaceAll('```', '\\`\\`\\`') + '\n\n'
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case isPromptItemAuthorNote(item):{
|
||||
prompt += '## ' + 'system' + '; ' + item.type + '\n'
|
||||
if(item.innerFormat){
|
||||
prompt += '\n' + item.innerFormat.replaceAll('```', '\\`\\`\\`') + '\n\n'
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case isPromptItemChat(item):{
|
||||
prompt += '## ' + 'chat' + '; ' + item.type + '\n'
|
||||
prompt += '\n' + item.rangeStart + ' - ' + item.rangeEnd + '\n\n'
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return prompt
|
||||
}
|
||||
|
||||
function checkDiff(prompt1: string, prompt2: string): string {
|
||||
const lineDiffs = diffLines(prompt1, prompt2)
|
||||
|
||||
let resultHtml = '';
|
||||
|
||||
for (let i = 0; i < lineDiffs.length; i++) {
|
||||
const linePart = lineDiffs[i]
|
||||
|
||||
if(linePart.removed){
|
||||
const nextPart = lineDiffs[i + 1]
|
||||
if(nextPart?.added){
|
||||
resultHtml += `<div style="border-left: 4px solid blue; padding-left: 8px;">${highlightChanges(linePart.value, nextPart.value)}</div>`
|
||||
i++;
|
||||
}
|
||||
else{
|
||||
resultHtml += `<div style="color: red; background-color: #ffe6e6; border-left: 4px solid red; padding-left: 8px;">${escapeHtml(linePart.value)}</div>`
|
||||
}
|
||||
}
|
||||
else if(linePart.added){
|
||||
resultHtml += `<div style="color: green; background-color: #e6ffe6; border-left: 4px solid green; padding-left: 8px;">${escapeHtml(linePart.value)}</div>`
|
||||
}
|
||||
else{
|
||||
resultHtml += `<div>${escapeHtml(linePart.value)}</div>`
|
||||
}
|
||||
}
|
||||
|
||||
if(lineDiffs.length === 1 && !lineDiffs[0].added && !lineDiffs[0].removed) {
|
||||
resultHtml = `<div style="background-color: #4caf50; color: white; padding: 10px 20px; border-radius: 5px; text-align: center;">No differences detected.</div>` + resultHtml
|
||||
}
|
||||
else{
|
||||
resultHtml = `<div style="background-color: #ff9800; color: white; padding: 10px 20px; border-radius: 5px; text-align: center;">Differences detected. Please review the changes.</div>` + resultHtml
|
||||
}
|
||||
|
||||
return resultHtml
|
||||
}
|
||||
|
||||
function highlightChanges(string1: string, string2: string) {
|
||||
const charDiffs = diffWordsWithSpace(string1, string2)
|
||||
|
||||
return charDiffs
|
||||
.map(charPart => {
|
||||
const escapedText = escapeHtml(charPart.value)
|
||||
|
||||
if (charPart.added) {
|
||||
return `<span style="color: green; background-color: #e6ffe6;">${escapedText}</span>`
|
||||
}
|
||||
else if(charPart.removed) {
|
||||
return `<span style="color: red; background-color: #ffe6e6;">${escapedText}</span>`
|
||||
}
|
||||
else{
|
||||
return escapedText
|
||||
}
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
|
||||
|
||||
function handleDiffMode(id: number) {
|
||||
if (selectedDiffPreset === id) {
|
||||
selectedDiffPreset = -1
|
||||
selectedPrompts = []
|
||||
diffMode = !diffMode
|
||||
return
|
||||
} else {
|
||||
selectedDiffPreset = id
|
||||
}
|
||||
|
||||
const prompt = getPrompt(id)
|
||||
|
||||
if(!prompt){
|
||||
return
|
||||
}
|
||||
|
||||
if(!diffMode){
|
||||
selectedPrompts = [prompt]
|
||||
}
|
||||
else if(selectedPrompts.length === 0){
|
||||
return
|
||||
}
|
||||
else{
|
||||
alertWait("Loading...")
|
||||
const result = checkDiff(selectedPrompts[0], prompt)
|
||||
alertMd(result)
|
||||
selectedDiffPreset = -1
|
||||
selectedPrompts = []
|
||||
}
|
||||
|
||||
diffMode = !diffMode
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<div class="absolute w-full h-full z-40 bg-black bg-opacity-50 flex justify-center items-center">
|
||||
@@ -47,6 +234,16 @@
|
||||
<span>{preset.name}</span>
|
||||
{/if}
|
||||
<div class="flex-grow flex justify-end">
|
||||
<div class="{selectedDiffPreset === i ? 'text-green-500' : 'text-textcolor2 hover:text-green-500'} cursor-pointer mr-2" role="button" tabindex="0" onclick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDiffMode(i)
|
||||
}} onkeydown={(e) => {
|
||||
if(e.key === 'Enter'){
|
||||
e.currentTarget.click()
|
||||
}
|
||||
}}>
|
||||
<GitCompare size={18}/>
|
||||
</div>
|
||||
<div class="text-textcolor2 hover:text-green-500 cursor-pointer mr-2" role="button" tabindex="0" onclick={(e) => {
|
||||
e.stopPropagation()
|
||||
copyPreset(i)
|
||||
|
||||
Reference in New Issue
Block a user