feat: Added multiturn messaging and tracking of scenario/description changes.

This commit is contained in:
chill-protocol
2025-09-15 20:59:51 +12:00
parent 7c40eef29e
commit a089b7aab3
5 changed files with 1081 additions and 96 deletions

2
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,2 @@
{
}

View File

@@ -4,6 +4,7 @@ Check package.json for commands, I can't be bothered.
### Changelog
- 2.0: Multimessage support! Tracks changes to character descriptions and scenarios across multiple extractions. Shows version badges, message counts, and provides detailed change history viewer.
- 1.9: Not again. They changed stuff again. What is this?
- 1.8: Handling for new prompt structure, new instructions
- 1.7: Handling for nested XML character tags

286
package-lock.json generated
View File

@@ -108,17 +108,19 @@
}
},
"node_modules/@next/env": {
"version": "14.2.23",
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.23.tgz",
"integrity": "sha512-CysUC9IO+2Bh0omJ3qrb47S8DtsTKbFidGm6ow4gXIG6reZybqxbkH2nhdEm1tC8SmgzDdpq3BIML0PWsmyUYA=="
"version": "14.2.32",
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.32.tgz",
"integrity": "sha512-n9mQdigI6iZ/DF6pCTwMKeWgF2e8lg7qgt5M7HXMLtyhZYMnf/u905M18sSpPmHL9MKp9JHo56C6jrD2EvWxng==",
"license": "MIT"
},
"node_modules/@next/swc-darwin-arm64": {
"version": "14.2.23",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.23.tgz",
"integrity": "sha512-WhtEntt6NcbABA8ypEoFd3uzq5iAnrl9AnZt9dXdO+PZLACE32z3a3qA5OoV20JrbJfSJ6Sd6EqGZTrlRnGxQQ==",
"version": "14.2.32",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.32.tgz",
"integrity": "sha512-osHXveM70zC+ilfuFa/2W6a1XQxJTvEhzEycnjUaVE8kpUS09lDpiDDX2YLdyFCzoUbvbo5r0X1Kp4MllIOShw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
@@ -128,12 +130,13 @@
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "14.2.23",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.23.tgz",
"integrity": "sha512-vwLw0HN2gVclT/ikO6EcE+LcIN+0mddJ53yG4eZd0rXkuEr/RnOaMH8wg/sYl5iz5AYYRo/l6XX7FIo6kwbw1Q==",
"version": "14.2.32",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.32.tgz",
"integrity": "sha512-P9NpCAJuOiaHHpqtrCNncjqtSBi1f6QUdHK/+dNabBIXB2RUFWL19TY1Hkhu74OvyNQEYEzzMJCMQk5agjw1Qg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
@@ -143,12 +146,13 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "14.2.23",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.23.tgz",
"integrity": "sha512-uuAYwD3At2fu5CH1wD7FpP87mnjAv4+DNvLaR9kiIi8DLStWSW304kF09p1EQfhcbUI1Py2vZlBO2VaVqMRtpg==",
"version": "14.2.32",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.32.tgz",
"integrity": "sha512-v7JaO0oXXt6d+cFjrrKqYnR2ubrD+JYP7nQVRZgeo5uNE5hkCpWnHmXm9vy3g6foMO8SPwL0P3MPw1c+BjbAzA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -158,12 +162,13 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "14.2.23",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.23.tgz",
"integrity": "sha512-Mm5KHd7nGgeJ4EETvVgFuqKOyDh+UMXHXxye6wRRFDr4FdVRI6YTxajoV2aHE8jqC14xeAMVZvLqYqS7isHL+g==",
"version": "14.2.32",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.32.tgz",
"integrity": "sha512-tA6sIKShXtSJBTH88i0DRd6I9n3ZTirmwpwAqH5zdJoQF7/wlJXR8DkPmKwYl5mFWhEKr5IIa3LfpMW9RRwKmQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -173,12 +178,13 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "14.2.23",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.23.tgz",
"integrity": "sha512-Ybfqlyzm4sMSEQO6lDksggAIxnvWSG2cDWnG2jgd+MLbHYn2pvFA8DQ4pT2Vjk3Cwrv+HIg7vXJ8lCiLz79qoQ==",
"version": "14.2.32",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.32.tgz",
"integrity": "sha512-7S1GY4TdnlGVIdeXXKQdDkfDysoIVFMD0lJuVVMeb3eoVjrknQ0JNN7wFlhCvea0hEk0Sd4D1hedVChDKfV2jw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -188,12 +194,13 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "14.2.23",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.23.tgz",
"integrity": "sha512-OSQX94sxd1gOUz3jhhdocnKsy4/peG8zV1HVaW6DLEbEmRRtUCUQZcKxUD9atLYa3RZA+YJx+WZdOnTkDuNDNA==",
"version": "14.2.32",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.32.tgz",
"integrity": "sha512-OHHC81P4tirVa6Awk6eCQ6RBfWl8HpFsZtfEkMpJ5GjPsJ3nhPe6wKAJUZ/piC8sszUkAgv3fLflgzPStIwfWg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -203,12 +210,13 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "14.2.23",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.23.tgz",
"integrity": "sha512-ezmbgZy++XpIMTcTNd0L4k7+cNI4ET5vMv/oqNfTuSXkZtSA9BURElPFyarjjGtRgZ9/zuKDHoMdZwDZIY3ehQ==",
"version": "14.2.32",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.32.tgz",
"integrity": "sha512-rORQjXsAFeX6TLYJrCG5yoIDj+NKq31Rqwn8Wpn/bkPNy5rTHvOXkW8mLFonItS7QC6M+1JIIcLe+vOCTOYpvg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
@@ -218,12 +226,13 @@
}
},
"node_modules/@next/swc-win32-ia32-msvc": {
"version": "14.2.23",
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.23.tgz",
"integrity": "sha512-zfHZOGguFCqAJ7zldTKg4tJHPJyJCOFhpoJcVxKL9BSUHScVDnMdDuOU1zPPGdOzr/GWxbhYTjyiEgLEpAoFPA==",
"version": "14.2.32",
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.32.tgz",
"integrity": "sha512-jHUeDPVHrgFltqoAqDB6g6OStNnFxnc7Aks3p0KE0FbwAvRg6qWKYF5mSTdCTxA3axoSAUwxYdILzXJfUwlHhA==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
@@ -233,12 +242,13 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "14.2.23",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.23.tgz",
"integrity": "sha512-xCtq5BD553SzOgSZ7UH5LH+OATQihydObTrCTvVzOro8QiWYKdBVwcB2Mn2MLMo6DGW9yH1LSPw7jS7HhgJgjw==",
"version": "14.2.32",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.32.tgz",
"integrity": "sha512-2N0lSoU4GjfLSO50wvKpMQgKd4HdI2UHEhQPPPnlgfBJlOgJxkjpkYBqzk08f1gItBB6xF/n+ykso2hgxuydsA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
@@ -816,7 +826,8 @@
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/autoprefixer": {
"version": "10.4.20",
@@ -856,12 +867,13 @@
}
},
"node_modules/axios": {
"version": "1.7.9",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz",
"integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==",
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.0.tgz",
"integrity": "sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
@@ -890,9 +902,10 @@
}
},
"node_modules/brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
@@ -951,6 +964,19 @@
"node": ">=10.16.0"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/camelcase-css": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
@@ -1056,6 +1082,7 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
@@ -1135,6 +1162,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
@@ -1154,6 +1182,20 @@
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
@@ -1170,6 +1212,51 @@
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -1259,12 +1346,15 @@
}
},
"node_modules/form-data": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz",
"integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
@@ -1305,6 +1395,30 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-nonce": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
@@ -1313,6 +1427,19 @@
"node": ">=6"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/glob": {
"version": "10.4.5",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
@@ -1343,11 +1470,50 @@
"node": ">=10.13.0"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@@ -1503,6 +1669,15 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -1527,6 +1702,7 @@
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
@@ -1535,6 +1711,7 @@
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
@@ -1592,11 +1769,12 @@
}
},
"node_modules/next": {
"version": "14.2.23",
"resolved": "https://registry.npmjs.org/next/-/next-14.2.23.tgz",
"integrity": "sha512-mjN3fE6u/tynneLiEg56XnthzuYw+kD7mCujgVqioxyPqbmiotUCGJpIZGS/VaPg3ZDT1tvWxiVyRzeqJFm/kw==",
"version": "14.2.32",
"resolved": "https://registry.npmjs.org/next/-/next-14.2.32.tgz",
"integrity": "sha512-fg5g0GZ7/nFc09X8wLe6pNSU8cLWbLRG3TZzPJ1BJvi2s9m7eF991se67wliM9kR5yLHRkyGKU49MMx58s3LJg==",
"license": "MIT",
"dependencies": {
"@next/env": "14.2.23",
"@next/env": "14.2.32",
"@swc/helpers": "0.5.5",
"busboy": "1.6.0",
"caniuse-lite": "^1.0.30001579",
@@ -1611,15 +1789,15 @@
"node": ">=18.17.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "14.2.23",
"@next/swc-darwin-x64": "14.2.23",
"@next/swc-linux-arm64-gnu": "14.2.23",
"@next/swc-linux-arm64-musl": "14.2.23",
"@next/swc-linux-x64-gnu": "14.2.23",
"@next/swc-linux-x64-musl": "14.2.23",
"@next/swc-win32-arm64-msvc": "14.2.23",
"@next/swc-win32-ia32-msvc": "14.2.23",
"@next/swc-win32-x64-msvc": "14.2.23"
"@next/swc-darwin-arm64": "14.2.32",
"@next/swc-darwin-x64": "14.2.32",
"@next/swc-linux-arm64-gnu": "14.2.32",
"@next/swc-linux-arm64-musl": "14.2.32",
"@next/swc-linux-x64-gnu": "14.2.32",
"@next/swc-linux-x64-musl": "14.2.32",
"@next/swc-win32-arm64-msvc": "14.2.32",
"@next/swc-win32-ia32-msvc": "14.2.32",
"@next/swc-win32-x64-msvc": "14.2.32"
},
"peerDependencies": {
"@opentelemetry/api": "^1.1.0",

View File

@@ -1,9 +1,32 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
interface CardVersion {
version: number;
timestamp: number;
changes: {
description?: { old: string; new: string };
scenario?: { old: string; new: string };
};
changeType: 'initial' | 'update';
messageCount: number;
addedText?: {
description?: string;
scenario?: string;
};
removedText?: {
description?: string;
scenario?: string;
};
}
interface StoredCard extends CardData {
timestamp: number;
id: string;
versions: CardVersion[];
currentVersion: number;
messageCount: number;
conversationId: string;
}
let extractedCards: StoredCard[] = [];
@@ -26,6 +49,7 @@ interface Message {
interface CardData {
name: string;
trackingName: string;
first_mes: string;
description: string;
personality: string;
@@ -61,12 +85,23 @@ function extractCardData(messages: Message[]): CardData {
const nameContent = messages[3].content;
const lastColonIndex = nameContent.lastIndexOf(': ');
const nameFromUser = lastColonIndex !== -1 ? nameContent.substring(lastColonIndex + 2) : '';
const nameFromUser = lastColonIndex !== -1 ? nameContent.substring(lastColonIndex + 2).trim() : '';
let content = messages[0].content.replace(/{user}/g, '{{user}}');
const inferredName = extractPersonaName(content);
content = removePersonaTags(content);
const name = nameFromUser === '.' && inferredName ? inferredName : nameFromUser;
// Use inferred name for tracking, but keep user input for display
const trackingName = inferredName || nameFromUser || 'Unknown Character';
let displayName = nameFromUser;
if (nameFromUser === '.' || nameFromUser === '') {
displayName = inferredName || 'Unknown Character';
}
// Clean up tracking name
const cleanTrackingName = trackingName.replace(/[^a-zA-Z0-9\s]/g, '').trim();
console.log(`Name extraction: user="${nameFromUser}", inferred="${inferredName}", tracking="${cleanTrackingName}", display="${displayName}"`);
if (!content.includes('<.>') || !content.includes('<UserPersona>.</UserPersona>')) {
throw new Error('Required substrings not found');
@@ -93,7 +128,8 @@ function extractCardData(messages: Message[]): CardData {
const description = content.trim();
return {
name,
name: displayName,
trackingName: cleanTrackingName,
first_mes,
description,
personality: '',
@@ -102,6 +138,82 @@ function extractCardData(messages: Message[]): CardData {
};
}
function generateConversationId(messages: Message[]): string {
// Create a simple hash from the character name in the persona tag to identify conversations
const content = messages[0]?.content || '';
const personaMatch = content.match(/<([^<>]+?)\s*'s\s+Persona>/i);
if (personaMatch) {
return personaMatch[1].trim().toLowerCase().replace(/[^a-zA-Z0-9]/g, '');
}
// Fallback to content-based ID
return content.substring(0, 50).replace(/[^a-zA-Z0-9]/g, '').toLowerCase();
}
function detectChanges(newCard: CardData, existingCard: StoredCard): { description?: { old: string; new: string }; scenario?: { old: string; new: string } } | null {
const changes: { description?: { old: string; new: string }; scenario?: { old: string; new: string } } = {};
let hasChanges = false;
if (newCard.description.trim() !== existingCard.description.trim()) {
changes.description = { old: existingCard.description, new: newCard.description };
hasChanges = true;
}
if (newCard.scenario.trim() !== existingCard.scenario.trim()) {
changes.scenario = { old: existingCard.scenario, new: newCard.scenario };
hasChanges = true;
}
return hasChanges ? changes : null;
}
function findExistingCard(trackingName: string): StoredCard | null {
// Find by tracking name (inferred character name) to group same characters
return extractedCards.find(card => card.trackingName === trackingName) || null;
}
function updateCardWithVersion(existingCard: StoredCard, newCard: CardData, changes: { description?: { old: string; new: string }; scenario?: { old: string; new: string } }): void {
const addedText: { description?: string; scenario?: string } = {};
const removedText: { description?: string; scenario?: string } = {};
// Extract only the different text
if (changes.description) {
const added = extractAddedText(changes.description.old, changes.description.new);
const removed = extractRemovedText(changes.description.old, changes.description.new);
if (added) addedText.description = added;
if (removed) removedText.description = removed;
}
if (changes.scenario) {
const added = extractAddedText(changes.scenario.old, changes.scenario.new);
const removed = extractRemovedText(changes.scenario.old, changes.scenario.new);
if (added) addedText.scenario = added;
if (removed) removedText.scenario = removed;
}
const newVersion: CardVersion = {
version: existingCard.currentVersion + 1,
timestamp: Date.now(),
changes,
changeType: 'update',
messageCount: existingCard.messageCount + 1,
addedText: Object.keys(addedText).length > 0 ? addedText : undefined,
removedText: Object.keys(removedText).length > 0 ? removedText : undefined
};
existingCard.versions.push(newVersion);
existingCard.currentVersion = newVersion.version;
existingCard.timestamp = Date.now();
existingCard.messageCount += 1;
// Update the main card data
if (changes.description) {
existingCard.description = changes.description.new;
}
if (changes.scenario) {
existingCard.scenario = changes.scenario.new;
}
}
export async function POST(request: NextRequest) {
if (request.method === "OPTIONS") {
return new NextResponse(null, {
@@ -117,7 +229,14 @@ export async function POST(request: NextRequest) {
try {
const body = await request.json();
// Check if this is a streaming request (JanitorAI expects SSE)
const acceptHeader = request.headers.get('accept');
const isStreamingRequest = acceptHeader?.includes('text/event-stream') || body.stream === true;
if (!body.messages || body.messages.length < 2) {
if (isStreamingRequest) {
return createSSEErrorResponse("Missing messages or insufficient message count");
}
return NextResponse.json(
{ error: "Missing messages or insufficient message count" },
{
@@ -130,56 +249,243 @@ export async function POST(request: NextRequest) {
}
const cardData = extractCardData(body.messages);
extractedCards.push({
const conversationId = generateConversationId(body.messages);
const existingCard = findExistingCard(cardData.trackingName);
console.log(`Conversation ID: ${conversationId}`);
let responseMessage = "Got it.";
let changesSummary = "";
console.log(`Processing card: "${cardData.name}" (tracking: "${cardData.trackingName}"), ConversationID: ${conversationId}`);
console.log(`Existing cards: ${extractedCards.map(c => `"${c.name}" (tracking: "${c.trackingName}", v${c.currentVersion})`).join(', ')}`);
console.log(`Found existing card: ${existingCard ? `YES - v${existingCard.currentVersion}` : 'NO'}`);
if (existingCard) {
const changes = detectChanges(cardData, existingCard);
console.log(`Changes detected:`, changes ? 'YES' : 'NO');
if (changes) {
console.log(`Updating from v${existingCard.currentVersion} to v${existingCard.currentVersion + 1}`);
updateCardWithVersion(existingCard, cardData, changes);
// Keep the original display name (don't update it)
// existingCard.name stays the same
// Create a summary of changes for the response
const changeTypes = [];
if (changes.description) changeTypes.push("description");
if (changes.scenario) changeTypes.push("scenario");
changesSummary = ` Changes detected in ${changeTypes.join(" and ")}.`;
responseMessage = `Character updated (v${existingCard.currentVersion}).${changesSummary}`;
} else {
existingCard.messageCount += 1;
// Keep the original display name (don't update it)
// existingCard.name stays the same
responseMessage = `Character data unchanged (v${existingCard.currentVersion}, message ${existingCard.messageCount}).`;
}
} else {
// Create new card with initial version
const newCard: StoredCard = {
...cardData,
timestamp: Date.now(),
id: generateId(),
});
conversationId,
messageCount: 1,
versions: [{
version: 1,
timestamp: Date.now(),
changes: {
description: { old: "", new: cardData.description },
scenario: { old: "", new: cardData.scenario }
},
changeType: 'initial',
messageCount: 1
}],
currentVersion: 1
};
extractedCards.push(newCard);
responseMessage = `New character "${cardData.trackingName}" created (v1).`;
}
cleanupExpiredCards();
// Return SSE response if requested, otherwise JSON
if (isStreamingRequest) {
return createSSEResponse(responseMessage);
}
// Return proper OpenAI-compatible response
return NextResponse.json(
{
id: `chatcmpl-${generateId()}`,
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model: "sucker-v2",
choices: [{
index: 0,
message: {
content: "Got it."
role: "assistant",
content: responseMessage
},
finish_reason: "stop"
}],
usage: {
prompt_tokens: 0,
completion_tokens: responseMessage.split(' ').length,
total_tokens: responseMessage.split(' ').length
}
}]
},
{
headers: {
"Access-Control-Allow-Origin": "*",
"Content-Type": "application/json",
},
}
);
} catch (error) {
console.error("Error processing request:", error);
const errorMessage = "You dingus, read the directions on sucker before trying again.";
// Check if this was a streaming request
const acceptHeader = request.headers.get('accept');
const isStreamingRequest = acceptHeader?.includes('text/event-stream');
if (isStreamingRequest) {
return createSSEErrorResponse(errorMessage);
}
return NextResponse.json(
{
id: `chatcmpl-${generateId()}`,
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model: "sucker-v2",
choices: [{
index: 0,
message: {
content: "You dingus, read the directions on sucker before trying again."
role: "assistant",
content: errorMessage
},
finish_reason: "stop"
}],
usage: {
prompt_tokens: 0,
completion_tokens: errorMessage.split(' ').length,
total_tokens: errorMessage.split(' ').length
}
}]
},
{
status: 400,
status: 200, // Change to 200 so Janitor AI accepts it
headers: {
"Access-Control-Allow-Origin": "*",
"Content-Type": "application/json",
},
}
);
}
}
export async function GET() {
function getInitialCardVersion(card: StoredCard): CardData {
// Get the initial version (v1) of the card
const initialVersion = card.versions.find(v => v.version === 1);
if (initialVersion && initialVersion.changes.description && initialVersion.changes.scenario) {
return {
name: card.name,
trackingName: card.trackingName,
first_mes: card.first_mes,
description: initialVersion.changes.description.new,
personality: card.personality,
mes_example: card.mes_example,
scenario: initialVersion.changes.scenario.new,
};
}
// Fallback to current version if initial not found
return {
name: card.name,
trackingName: card.trackingName,
first_mes: card.first_mes,
description: card.description,
personality: card.personality,
mes_example: card.mes_example,
scenario: card.scenario,
};
}
export async function GET(request: NextRequest) {
cleanupExpiredCards();
const url = new URL(request.url);
const isChangesRequest = url.searchParams.get('changes') === 'true';
const cardId = url.searchParams.get('cardId');
if (isChangesRequest && cardId) {
const card = extractedCards.find(c => c.id === cardId);
if (!card || !card.versions) {
return NextResponse.json(
{ error: "Card not found or no version history available" },
{
status: 404,
headers: {
"Access-Control-Allow-Origin": "*",
},
}
);
}
const changesReport = {
cardName: card.name,
cardId: card.id,
totalVersions: card.versions.length,
currentVersion: card.currentVersion,
versions: card.versions.map((version, index) => {
const result: any = {
version: version.version,
timestamp: new Date(version.timestamp).toISOString(),
changeType: version.changeType,
changes: version.changes
};
// Add extracted text information
if (version.addedText) {
result.addedText = version.addedText;
}
if (version.removedText) {
result.removedText = version.removedText;
}
return result;
}),
summary: generateChangesSummary(card.versions)
};
// Sanitize filename for download
const sanitizedName = card.name.replace(/[^a-zA-Z0-9\-_]/g, '_');
return NextResponse.json(changesReport, {
headers: {
"Access-Control-Allow-Origin": "*",
"Content-Disposition": `attachment; filename="${sanitizedName}_changes.json"`,
},
});
}
return NextResponse.json(
{
status: "online",
cards: extractedCards.map(({ timestamp, ...card }) => card),
cards: extractedCards.map((card) => {
const { timestamp, versions, ...cardData } = card;
const initialVersion = getInitialCardVersion(card);
return {
...cardData,
hasVersions: versions && versions.length > 1,
versionCount: versions ? versions.length : 0,
messageCount: card.messageCount || 1,
initialVersion: initialVersion
};
}),
},
{
headers: {
@@ -189,6 +495,231 @@ export async function GET() {
);
}
interface DiffResult {
type: 'added' | 'removed' | 'unchanged';
text: string;
}
function extractAddedText(oldText: string, newText: string): string {
// Split by double newlines to get paragraphs, then by single newlines to get lines
const oldParagraphs = oldText.split(/\n\s*\n/);
const newParagraphs = newText.split(/\n\s*\n/);
const addedBlocks: string[] = [];
// Find paragraphs that exist in new but not in old
for (const newPara of newParagraphs) {
const newParaTrimmed = newPara.trim();
if (!newParaTrimmed) continue;
// Check if this paragraph (or a very similar one) exists in old text
let found = false;
for (const oldPara of oldParagraphs) {
const oldParaTrimmed = oldPara.trim();
if (!oldParaTrimmed) continue;
// Check for exact match or high similarity (80% of words match)
if (oldParaTrimmed === newParaTrimmed || calculateSimilarity(oldParaTrimmed, newParaTrimmed) > 0.8) {
found = true;
break;
}
}
if (!found) {
addedBlocks.push(newParaTrimmed);
}
}
return addedBlocks.join('\n\n');
}
function extractRemovedText(oldText: string, newText: string): string {
// Split by double newlines to get paragraphs
const oldParagraphs = oldText.split(/\n\s*\n/);
const newParagraphs = newText.split(/\n\s*\n/);
const removedBlocks: string[] = [];
// Find paragraphs that exist in old but not in new
for (const oldPara of oldParagraphs) {
const oldParaTrimmed = oldPara.trim();
if (!oldParaTrimmed) continue;
// Check if this paragraph (or a very similar one) exists in new text
let found = false;
for (const newPara of newParagraphs) {
const newParaTrimmed = newPara.trim();
if (!newParaTrimmed) continue;
// Check for exact match or high similarity (80% of words match)
if (oldParaTrimmed === newParaTrimmed || calculateSimilarity(oldParaTrimmed, newParaTrimmed) > 0.8) {
found = true;
break;
}
}
if (!found) {
removedBlocks.push(oldParaTrimmed);
}
}
return removedBlocks.join('\n\n');
}
function calculateSimilarity(text1: string, text2: string): number {
const words1 = text1.toLowerCase().split(/\s+/);
const words2 = text2.toLowerCase().split(/\s+/);
const set1 = new Set(words1);
const set2 = new Set(words2);
const intersection = new Set([...set1].filter(x => set2.has(x)));
const union = new Set([...set1, ...set2]);
return intersection.size / union.size;
}
function generateChangesSummary(versions: CardVersion[]) {
const summary = {
descriptionChanges: 0,
scenarioChanges: 0,
totalMessages: 0,
firstChange: null as string | null,
lastChange: null as string | null,
};
versions.forEach(version => {
if (version.changes.description !== undefined) {
summary.descriptionChanges++;
}
if (version.changes.scenario !== undefined) {
summary.scenarioChanges++;
}
summary.totalMessages = Math.max(summary.totalMessages, version.messageCount || 0);
});
if (versions.length > 0) {
summary.firstChange = new Date(versions[0].timestamp).toISOString();
summary.lastChange = new Date(versions[versions.length - 1].timestamp).toISOString();
}
return summary;
}
// SSE Helper Functions
function createSSEResponse(content: string): Response {
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
// Send the message in OpenAI streaming format
const id = `chatcmpl-${generateId()}`;
const timestamp = Math.floor(Date.now() / 1000);
// Send initial chunk with message
const chunk = {
id,
object: "chat.completion.chunk",
created: timestamp,
model: "sucker-v2",
choices: [{
index: 0,
delta: {
role: "assistant",
content: content
},
finish_reason: null
}]
};
controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
// Send final chunk to indicate completion
const finalChunk = {
id,
object: "chat.completion.chunk",
created: timestamp,
model: "sucker-v2",
choices: [{
index: 0,
delta: {},
finish_reason: "stop"
}]
};
controller.enqueue(encoder.encode(`data: ${JSON.stringify(finalChunk)}\n\n`));
controller.enqueue(encoder.encode(`data: [DONE]\n\n`));
controller.close();
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS, GET',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
});
}
function createSSEErrorResponse(errorMessage: string): Response {
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
const id = `chatcmpl-${generateId()}`;
const timestamp = Math.floor(Date.now() / 1000);
// Send error as a normal message chunk
const chunk = {
id,
object: "chat.completion.chunk",
created: timestamp,
model: "sucker-v2",
choices: [{
index: 0,
delta: {
role: "assistant",
content: errorMessage
},
finish_reason: null
}]
};
controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
// Send final chunk
const finalChunk = {
id,
object: "chat.completion.chunk",
created: timestamp,
model: "sucker-v2",
choices: [{
index: 0,
delta: {},
finish_reason: "stop"
}]
};
controller.enqueue(encoder.encode(`data: ${JSON.stringify(finalChunk)}\n\n`));
controller.enqueue(encoder.encode(`data: [DONE]\n\n`));
controller.close();
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS, GET',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
});
}
export async function OPTIONS() {
return new NextResponse(null, {
status: 204,

View File

@@ -35,6 +35,17 @@ interface Card {
mes_example: string;
scenario: string;
avatarUrl?: string;
hasVersions?: boolean;
versionCount?: number;
messageCount?: number;
initialVersion?: {
name: string;
first_mes: string;
description: string;
personality: string;
mes_example: string;
scenario: string;
};
}
export default function Home() {
@@ -48,6 +59,9 @@ export default function Home() {
const [avatarPath, setAvatarPath] = useState("");
const [isMetadataOpen, setIsMetadataOpen] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const [changesDialogOpen, setChangesDialogOpen] = useState(false);
const [selectedChanges, setSelectedChanges] = useState<any>(null);
const [showFullText, setShowFullText] = useState(false);
const fetchCards = async () => {
try {
@@ -76,17 +90,74 @@ export default function Home() {
}, []);
const downloadJson = (card: Card) => {
// Use initial version for download, or current version if no initial version available
const downloadData = card.initialVersion ? {
name: card.initialVersion.name,
first_mes: card.initialVersion.first_mes,
description: card.initialVersion.description,
personality: card.initialVersion.personality,
mes_example: card.initialVersion.mes_example,
scenario: card.initialVersion.scenario,
} : {
name: card.name,
first_mes: card.first_mes,
description: card.description,
personality: card.personality,
mes_example: card.mes_example,
scenario: card.scenario,
};
const element = document.createElement("a");
const file = new Blob([JSON.stringify(card, null, 2)], {
const file = new Blob([JSON.stringify(downloadData, null, 2)], {
type: "application/json",
});
element.href = URL.createObjectURL(file);
element.download = `${card.name.replace(/\s+/g, "_")}.json`;
element.download = `${card.name.replace(/[^a-zA-Z0-9\-_]/g, '_')}.json`;
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
};
const downloadChanges = async (card: Card) => {
try {
const response = await fetch(`/api/proxy?changes=true&cardId=${card.id}`);
if (!response.ok) {
throw new Error('Failed to fetch changes');
}
const changesData = await response.json();
const element = document.createElement("a");
const file = new Blob([JSON.stringify(changesData, null, 2)], {
type: "application/json",
});
element.href = URL.createObjectURL(file);
element.download = `${card.name.replace(/[^a-zA-Z0-9\-_]/g, '_')}_changes.json`;
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
} catch (error) {
console.error("Error downloading changes:", error);
alert("Failed to download changes. The card may not have version history.");
}
};
const viewChanges = async (card: Card) => {
try {
const response = await fetch(`/api/proxy?changes=true&cardId=${card.id}`);
if (!response.ok) {
throw new Error('Failed to fetch changes');
}
const changesData = await response.json();
setSelectedChanges(changesData);
setShowFullText(false); // Reset to diff view by default
setChangesDialogOpen(true);
} catch (error) {
console.error("Error fetching changes:", error);
alert("Failed to fetch changes. The card may not have version history.");
}
};
const downloadPng = async (card: Card, cardId: string) => {
if (!card.avatarUrl) return;
@@ -115,18 +186,28 @@ export default function Home() {
const arrayBuffer = await pngBlob.arrayBuffer();
const cardData = JSON.stringify({
// Use initial version for PNG embedding, or current version if no initial version available
const pngData = card.initialVersion ? {
name: card.initialVersion.name,
first_mes: card.initialVersion.first_mes,
description: card.initialVersion.description,
personality: card.initialVersion.personality,
mes_example: card.initialVersion.mes_example,
scenario: card.initialVersion.scenario,
} : {
name: card.name,
first_mes: card.first_mes,
description: card.description,
personality: card.personality,
mes_example: card.mes_example,
scenario: card.scenario,
});
};
const cardData = JSON.stringify(pngData);
const newImageData = Png.Generate(arrayBuffer, cardData);
const newFileName = `${
card.name.replace(/\s+/g, "_") || "character"
card.name.replace(/[^a-zA-Z0-9\-_]/g, '_') || "character"
}.png`;
const newFile = new File([newImageData], newFileName, {
type: "image/png",
@@ -191,9 +272,9 @@ export default function Home() {
<div className="container mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-4">
<div>
<h1 className="text-3xl font-bold">Sucker v1.9</h1>
<h1 className="text-3xl font-bold">Sucker v2.0</h1>
<p className="text-sm text-muted-foreground">
Handles the new prompt structure (again). See instructions below, you'll need it.
Now with multimessage support! Tracks changes to character descriptions and scenarios across multiple extractions.
</p>
</div>
<Button
@@ -210,10 +291,10 @@ export default function Home() {
<div className="bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<div className="flex flex-col justify-between">
<span className="text-lg font-semibold text-blue-800 dark:text-blue-200">
Heads-up.
New: Multimessage Support
</span>
<p className="text-sm text-muted-foreground">
Same instructions as 1.8 if you used it before, except this time you can send a dot to let sucker infer char name, or send anything else and that'll be used to name it.
Sucker now tracks changes to character descriptions and scenarios across multiple messages. Cards with multiple versions show a version badge and offer a "Download Changes" button to get the change history.
</p>
</div>
</div>
@@ -277,6 +358,12 @@ export default function Home() {
discarded. Reloading the page will remove any attached avatars.
I'm not storing shit.
</p>
<p className="mb-2">
<strong>New:</strong> If you send multiple messages with the same character name,
Sucker will track changes to the description and scenario fields. Cards with
multiple versions will show a version badge and offer a "Download Changes"
button to get a detailed change history with timestamps.
</p>
</div>
</CollapsibleContent>
</Collapsible>
@@ -295,23 +382,37 @@ export default function Home() {
<Accordion type="single" collapsible className="w-full">
<AccordionItem value={`card-${index}`}>
<AccordionTrigger className="text-xl font-semibold">
<div className="flex items-center gap-2">
{card.name || "Unnamed Card"}
<div className="flex gap-1">
{card.hasVersions && (
<span className="text-sm bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 px-2 py-1 rounded-full">
v{card.versionCount}
</span>
)}
{card.messageCount && card.messageCount > 1 && (
<span className="text-sm bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 px-2 py-1 rounded-full">
{card.messageCount} msgs
</span>
)}
</div>
</div>
</AccordionTrigger>
<AccordionContent>
<div id={`card-${index}`} className="space-y-4 mt-4">
{card.description && (
{(card.initialVersion?.description || card.description) && (
<Accordion type="single" collapsible>
<AccordionItem value="description">
<AccordionTrigger>Description</AccordionTrigger>
<AccordionContent>
<div className="flex justify-between">
<pre className="whitespace-pre-wrap font-sans text-sm">{card.description}</pre>
<pre className="whitespace-pre-wrap font-sans text-sm">{card.initialVersion?.description || card.description}</pre>
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
copyToClipboard(card.description);
copyToClipboard(card.initialVersion?.description || card.description);
}}
>
<Copy className="h-4 w-4" />
@@ -321,7 +422,7 @@ export default function Home() {
</AccordionItem>
</Accordion>
)}
{card.first_mes && (
{(card.initialVersion?.first_mes || card.first_mes) && (
<Accordion type="single" collapsible>
<AccordionItem value="first-message">
<AccordionTrigger>
@@ -329,13 +430,13 @@ export default function Home() {
</AccordionTrigger>
<AccordionContent>
<div className="flex justify-between">
<pre className="whitespace-pre-wrap font-sans text-sm">{card.first_mes}</pre>
<pre className="whitespace-pre-wrap font-sans text-sm">{card.initialVersion?.first_mes || card.first_mes}</pre>
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
copyToClipboard(card.first_mes);
copyToClipboard(card.initialVersion?.first_mes || card.first_mes);
}}
>
<Copy className="h-4 w-4" />
@@ -345,19 +446,19 @@ export default function Home() {
</AccordionItem>
</Accordion>
)}
{card.scenario && (
{(card.initialVersion?.scenario || card.scenario) && (
<Accordion type="single" collapsible>
<AccordionItem value="scenario">
<AccordionTrigger>Scenario</AccordionTrigger>
<AccordionContent>
<div className="flex justify-between">
<pre className="whitespace-pre-wrap font-sans text-sm">{card.scenario}</pre>
<pre className="whitespace-pre-wrap font-sans text-sm">{card.initialVersion?.scenario || card.scenario}</pre>
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
copyToClipboard(card.scenario);
copyToClipboard(card.initialVersion?.scenario || card.scenario);
}}
>
<Copy className="h-4 w-4" />
@@ -367,7 +468,7 @@ export default function Home() {
</AccordionItem>
</Accordion>
)}
{card.mes_example && (
{(card.initialVersion?.mes_example || card.mes_example) && (
<Accordion type="single" collapsible>
<AccordionItem value="example-messages">
<AccordionTrigger>
@@ -375,13 +476,13 @@ export default function Home() {
</AccordionTrigger>
<AccordionContent>
<div className="flex justify-between">
<pre className="whitespace-pre-wrap font-sans text-sm">{card.mes_example}</pre>
<pre className="whitespace-pre-wrap font-sans text-sm">{card.initialVersion?.mes_example || card.mes_example}</pre>
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
copyToClipboard(card.mes_example);
copyToClipboard(card.initialVersion?.mes_example || card.mes_example);
}}
>
<Copy className="h-4 w-4" />
@@ -391,19 +492,19 @@ export default function Home() {
</AccordionItem>
</Accordion>
)}
{card.personality && (
{(card.initialVersion?.personality || card.personality) && (
<Accordion type="single" collapsible>
<AccordionItem value="personality">
<AccordionTrigger>Personality</AccordionTrigger>
<AccordionContent>
<div className="flex justify-between">
<pre className="whitespace-pre-wrap font-sans text-sm">{card.personality}</pre>
<pre className="whitespace-pre-wrap font-sans text-sm">{card.initialVersion?.personality || card.personality}</pre>
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
copyToClipboard(card.personality);
copyToClipboard(card.initialVersion?.personality || card.personality);
}}
>
<Copy className="h-4 w-4" />
@@ -424,6 +525,22 @@ export default function Home() {
>
Download JSON
</Button>
{card.hasVersions && (
<>
<Button
onClick={() => viewChanges(card)}
variant="outline"
>
View Changes
</Button>
<Button
onClick={() => downloadChanges(card)}
variant="outline"
>
Download Changes
</Button>
</>
)}
{!card.avatarUrl ? (
<Button
onClick={() => handleOpenDialog(index)}
@@ -495,6 +612,162 @@ export default function Home() {
)}
</DialogContent>
</Dialog>
<Dialog open={changesDialogOpen} onOpenChange={setChangesDialogOpen}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
Change History: {selectedChanges?.cardName}
</DialogTitle>
<DialogDescription className="flex items-center justify-between">
<span>Version history showing changes to description and scenario fields</span>
<Button
variant="outline"
size="sm"
onClick={() => setShowFullText(!showFullText)}
>
{showFullText ? 'Show Changes Only' : 'Show Full Text'}
</Button>
</DialogDescription>
</DialogHeader>
{selectedChanges && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<strong>Total Versions:</strong> {selectedChanges.totalVersions}
</div>
<div>
<strong>Current Version:</strong> {selectedChanges.currentVersion}
</div>
<div>
<strong>Description Changes:</strong> {selectedChanges.summary.descriptionChanges}
</div>
<div>
<strong>Scenario Changes:</strong> {selectedChanges.summary.scenarioChanges}
</div>
</div>
<Separator />
<div className="space-y-4">
<h3 className="text-lg font-semibold">Version History</h3>
{selectedChanges.versions.map((version: any, index: number) => (
<div key={version.version} className="border rounded-lg p-4">
<div className="flex justify-between items-center mb-2">
<h4 className="font-semibold">
Version {version.version} ({version.changeType})
</h4>
<div className="text-sm text-muted-foreground">
{new Date(version.timestamp).toLocaleString()}
{version.messageCount && ` • Message ${version.messageCount}`}
</div>
</div>
{version.changes.description && (
<div className="mb-3">
<h5 className="font-medium text-sm mb-1">Description Change:</h5>
{version.changeType === 'initial' ? (
<div className="bg-blue-50 dark:bg-blue-950 p-2 rounded text-sm">
<strong>Initial Content:</strong> {version.changes.description.new}
</div>
) : (
<div className="space-y-2">
{version.addedText?.description && (
<div className="bg-green-50 dark:bg-green-950 p-2 rounded text-sm">
<div className="flex justify-between items-start">
<div>
<strong>Added:</strong> {version.addedText.description}
</div>
<Button
variant="ghost"
size="icon"
className="ml-2 h-6 w-6"
onClick={(e) => {
e.stopPropagation();
copyToClipboard(version.addedText.description);
}}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
)}
{version.removedText?.description && (
<div className="bg-red-50 dark:bg-red-950 p-2 rounded text-sm">
<strong>Removed:</strong> {version.removedText.description}
</div>
)}
{showFullText && (
<div className="space-y-1 mt-2 pt-2 border-t">
<div className="bg-gray-50 dark:bg-gray-950 p-2 rounded text-xs">
<strong>Full Old:</strong> {version.changes.description.old}
</div>
<div className="bg-gray-50 dark:bg-gray-950 p-2 rounded text-xs">
<strong>Full New:</strong> {version.changes.description.new}
</div>
</div>
)}
</div>
)}
</div>
)}
{version.changes.scenario && (
<div>
<h5 className="font-medium text-sm mb-1">Scenario Change:</h5>
{version.changeType === 'initial' ? (
<div className="bg-blue-50 dark:bg-blue-950 p-2 rounded text-sm">
<strong>Initial Content:</strong> {version.changes.scenario.new}
</div>
) : (
<div className="space-y-2">
{version.addedText?.scenario && (
<div className="bg-green-50 dark:bg-green-950 p-2 rounded text-sm">
<div className="flex justify-between items-start">
<div>
<strong>Added:</strong> {version.addedText.scenario}
</div>
<Button
variant="ghost"
size="icon"
className="ml-2 h-6 w-6"
onClick={(e) => {
e.stopPropagation();
copyToClipboard(version.addedText.scenario);
}}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
)}
{version.removedText?.scenario && (
<div className="bg-red-50 dark:bg-red-950 p-2 rounded text-sm">
<strong>Removed:</strong> {version.removedText.scenario}
</div>
)}
{showFullText && (
<div className="space-y-1 mt-2 pt-2 border-t">
<div className="bg-gray-50 dark:bg-gray-950 p-2 rounded text-xs">
<strong>Full Old:</strong> {version.changes.scenario.old}
</div>
<div className="bg-gray-50 dark:bg-gray-950 p-2 rounded text-xs">
<strong>Full New:</strong> {version.changes.scenario.new}
</div>
</div>
)}
</div>
)}
</div>
)}
</div>
))}
</div>
</div>
)}
</DialogContent>
</Dialog>
</main>
);
}