From 3eea5cc8f0b888dcfe46569397cdc124171e156b Mon Sep 17 00:00:00 2001
From: Apparao Parwatikar
Date: Thu, 6 Aug 2026 08:34:18 +0530
Subject: [PATCH] feat(tags): add tags to personas and lorebooks
Add tag support to personas and lorebooks, including editing UIs, library
sorting and faceted tag filtering, and round-tripping across sync, backup,
export and transfer. Migration v88 -> v89 adds the tags columns; sync v2
carries the new column automatically via full-row changesets.
---
bun.lock | 51 +++
package.json | 6 +-
.../lorebook_generator/commands.rs | 1 +
src-tauri/src/discovery/mod.rs | 1 +
src-tauri/src/migrations/mod.rs | 43 ++-
src-tauri/src/storage_manager/backup.rs | 16 +-
src-tauri/src/storage_manager/db.rs | 6 +-
.../storage_manager/entity_transfer/mod.rs | 58 +++-
.../src/storage_manager/group_characters.rs | 7 +-
.../src/storage_manager/group_sessions.rs | 7 +-
src-tauri/src/storage_manager/importer.rs | 12 +-
src-tauri/src/storage_manager/lorebook.rs | 35 ++-
src-tauri/src/storage_manager/personas.rs | 47 ++-
src-tauri/src/storage_manager/system_cards.rs | 3 +
src-tauri/src/sync/models.rs | 4 +
src-tauri/tests/system_cards_inline.rs | 1 +
src/core/i18n/localeParity.test.ts | 53 ++++
src/core/i18n/locales/de.ts | 18 ++
src/core/i18n/locales/el.ts | 18 ++
src/core/i18n/locales/en.ts | 18 ++
src/core/i18n/locales/es.ts | 18 ++
src/core/i18n/locales/fil.ts | 18 ++
src/core/i18n/locales/fr.ts | 18 ++
src/core/i18n/locales/hi.ts | 18 ++
src/core/i18n/locales/id.ts | 18 ++
src/core/i18n/locales/it.ts | 18 ++
src/core/i18n/locales/ja.ts | 18 ++
src/core/i18n/locales/ko.ts | 18 ++
src/core/i18n/locales/nl.ts | 18 ++
src/core/i18n/locales/no.ts | 18 ++
src/core/i18n/locales/pl.ts | 18 ++
src/core/i18n/locales/pt.ts | 18 ++
src/core/i18n/locales/ru.ts | 18 ++
src/core/i18n/locales/tr.ts | 18 ++
src/core/i18n/locales/vi.ts | 18 ++
src/core/i18n/locales/zh-Hans.ts | 18 ++
src/core/i18n/locales/zh-Hant.ts | 18 ++
src/core/storage/personaTransfer.ts | 1 +
src/core/storage/repo.ts | 2 +
src/core/storage/schemas.ts | 2 +
src/core/utils/parseCommaSeparated.ts | 7 +
src/ui/components/LorebookMetadataMenu.tsx | 12 +
src/ui/components/TagsInput.tsx | 55 ++++
src/ui/pages/characters/LorebookEditor.tsx | 16 +-
.../characters/components/ExtrasStep.tsx | 51 +--
.../characters/hooks/useCharacterForm.ts | 6 +-
.../characters/hooks/useEditCharacterForm.ts | 6 +-
src/ui/pages/library/LibraryPage.tsx | 155 ++++++++-
.../library/StandaloneLorebookEditor.tsx | 16 +-
src/ui/pages/library/libraryPipeline.test.ts | 297 ++++++++++++++++++
src/ui/pages/library/libraryPipeline.ts | 167 ++++++++++
src/ui/pages/personas/EditPersona.tsx | 11 +
.../personas/components/CreatePersonaForm.tsx | 9 +
.../personas/hooks/createPersonaReducer.ts | 9 +
.../hooks/usePersonaFormController.ts | 18 +-
vitest.config.ts | 8 +
56 files changed, 1446 insertions(+), 113 deletions(-)
create mode 100644 src/core/i18n/localeParity.test.ts
create mode 100644 src/core/utils/parseCommaSeparated.ts
create mode 100644 src/ui/components/TagsInput.tsx
create mode 100644 src/ui/pages/library/libraryPipeline.test.ts
create mode 100644 src/ui/pages/library/libraryPipeline.ts
create mode 100644 vitest.config.ts
diff --git a/bun.lock b/bun.lock
index 6722fde20..d589773be 100644
--- a/bun.lock
+++ b/bun.lock
@@ -42,6 +42,7 @@
"tailwindcss": "^4.2.2",
"typescript": "~6.0.2",
"vite": "^8.0.3",
+ "vitest": "^4.1.10",
},
},
},
@@ -180,6 +181,8 @@
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
+ "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
+
"@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
"@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
@@ -200,6 +203,8 @@
"@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="],
+ "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
+
"@types/dompurify": ["@types/dompurify@3.2.0", "", { "dependencies": { "dompurify": "*" } }, "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
@@ -226,16 +231,34 @@
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="],
+ "@vitest/expect": ["@vitest/expect@4.1.10", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA=="],
+
+ "@vitest/mocker": ["@vitest/mocker@4.1.10", "", { "dependencies": { "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow=="],
+
+ "@vitest/pretty-format": ["@vitest/pretty-format@4.1.10", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q=="],
+
+ "@vitest/runner": ["@vitest/runner@4.1.10", "", { "dependencies": { "@vitest/utils": "4.1.10", "pathe": "^2.0.3" } }, "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg=="],
+
+ "@vitest/snapshot": ["@vitest/snapshot@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw=="],
+
+ "@vitest/spy": ["@vitest/spy@4.1.10", "", {}, "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw=="],
+
+ "@vitest/utils": ["@vitest/utils@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA=="],
+
"ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="],
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
+ "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
+
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
+ "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
+
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
"character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
@@ -256,6 +279,8 @@
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
+ "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
+
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
"create-web-stream": ["create-web-stream@1.1.2", "", {}, "sha512-qtdh7eHXm5ityhyXhMGU1Jm7Wb49pLWo8OZsLGEm4Wf2sXADQXkouQxt6Pq4C8y3O0XyIVGf0Jun4ZbelxytoA=="],
@@ -306,14 +331,20 @@
"environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="],
+ "es-module-lexer": ["es-module-lexer@2.3.1", "", {}, "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA=="],
+
"es-toolkit": ["es-toolkit@1.45.1", "", {}, "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw=="],
"escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
"estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="],
+ "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
+
"eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
+ "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="],
+
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
@@ -506,12 +537,16 @@
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
+ "obug": ["obug@2.1.4", "", {}, "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA=="],
+
"onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
"parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
+ "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
+
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
@@ -570,6 +605,8 @@
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
+ "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
+
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"slice-ansi": ["slice-ansi@8.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="],
@@ -580,6 +617,10 @@
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
+ "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
+
+ "std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="],
+
"string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="],
"string-width": ["string-width@8.2.0", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw=="],
@@ -600,10 +641,14 @@
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
+ "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
+
"tinyexec": ["tinyexec@1.0.4", "", {}, "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
+ "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="],
+
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
@@ -636,8 +681,12 @@
"vite": ["vite@8.0.3", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.12", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ=="],
+ "vitest": ["vitest@4.1.10", "", { "dependencies": { "@vitest/expect": "4.1.10", "@vitest/mocker": "4.1.10", "@vitest/pretty-format": "4.1.10", "@vitest/runner": "4.1.10", "@vitest/snapshot": "4.1.10", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.10", "@vitest/browser-preview": "4.1.10", "@vitest/browser-webdriverio": "4.1.10", "@vitest/coverage-istanbul": "4.1.10", "@vitest/coverage-v8": "4.1.10", "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw=="],
+
"web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
+ "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
+
"wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
"yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="],
@@ -668,6 +717,8 @@
"vite/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
+ "vitest/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
+
"wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
}
}
diff --git a/package.json b/package.json
index b688d7513..e8b3dc2a0 100644
--- a/package.json
+++ b/package.json
@@ -26,7 +26,8 @@
"tauri:ios:prepare-onnxruntime": "node scripts/install-onnxruntime-ios.mjs ios-arm64 && node scripts/install-onnxruntime-ios.mjs ios-arm64_x86_64-simulator",
"tauri:ios:dev:ready": "node scripts/install-onnxruntime-ios.mjs ios-arm64 && node scripts/install-onnxruntime-ios.mjs ios-arm64_x86_64-simulator && tauri ios dev",
"tauri:ios:build:ready": "node scripts/install-onnxruntime-ios.mjs ios-arm64 && node scripts/install-onnxruntime-ios.mjs ios-arm64_x86_64-simulator && tauri ios build",
- "check": "tsc --noEmit && cd src-tauri && cargo check && echo 'All checks successful.'"
+ "check": "tsc --noEmit && cd src-tauri && cargo check && echo 'All checks successful.'",
+ "test": "vitest run"
},
"dependencies": {
"@tailwindcss/vite": "^4.2.2",
@@ -65,7 +66,8 @@
"prettier": "^3.8.1",
"tailwindcss": "^4.2.2",
"typescript": "~6.0.2",
- "vite": "^8.0.3"
+ "vite": "^8.0.3",
+ "vitest": "^4.1.10"
},
"lint-staged": {
"src/**/*.{ts,tsx,js,jsx,json,css,md}": [
diff --git a/src-tauri/src/chat_manager/lorebook_generator/commands.rs b/src-tauri/src/chat_manager/lorebook_generator/commands.rs
index add900976..06f3a5d89 100644
--- a/src-tauri/src/chat_manager/lorebook_generator/commands.rs
+++ b/src-tauri/src/chat_manager/lorebook_generator/commands.rs
@@ -508,6 +508,7 @@ pub async fn lorebook_gen_commit(app: AppHandle, args: CommitArgs) -> Result Result<
crate::storage_manager::lorebook::LorebookKeywordDetectionMode::RecentMessageWindow,
created_at: now,
updated_at: now,
+ tags: Vec::new(),
};
if let Err(err) = upsert_lorebook(&conn, &lorebook_record) {
diff --git a/src-tauri/src/migrations/mod.rs b/src-tauri/src/migrations/mod.rs
index 407d3cbbb..abbe72491 100644
--- a/src-tauri/src/migrations/mod.rs
+++ b/src-tauri/src/migrations/mod.rs
@@ -7,7 +7,7 @@ use crate::storage_manager::settings::{read_settings_typed, write_settings_typed
use crate::utils::{log_info, log_warn};
/// Current migration version
-pub const CURRENT_MIGRATION_VERSION: u32 = 95;
+pub const CURRENT_MIGRATION_VERSION: u32 = 96;
pub fn run_migrations(app: &AppHandle) -> Result<(), String> {
log_info(app, "migrations", "Starting migration check");
@@ -979,6 +979,16 @@ pub fn run_migrations(app: &AppHandle) -> Result<(), String> {
version = 95;
}
+ if version < 96 {
+ log_info(
+ app,
+ "migrations",
+ "Running migration v95 -> v96: Add tags column to personas and lorebooks",
+ );
+ migrate_v95_to_v96(app)?;
+ version = 96;
+ }
+
if version != CURRENT_MIGRATION_VERSION {
log_warn(
app,
@@ -4270,6 +4280,37 @@ fn migrate_v74_to_v75(app: &AppHandle) -> Result<(), String> {
Ok(())
}
+/// Add a column only if missing; returns Err on a genuine ALTER failure so the
+/// migration aborts (version not advanced) and retries next launch instead of wedging.
+fn add_column_if_missing(
+ conn: &rusqlite::Connection,
+ table: &str,
+ column: &str,
+ decl: &str,
+) -> Result<(), String> {
+ let mut stmt = conn
+ .prepare(&format!("PRAGMA table_info({table})"))
+ .map_err(|e| crate::utils::err_to_string(module_path!(), line!(), e))?;
+ let existing = stmt
+ .query_map([], |row| row.get::<_, String>(1))
+ .map_err(|e| crate::utils::err_to_string(module_path!(), line!(), e))?
+ .collect::, _>>()
+ .map_err(|e| crate::utils::err_to_string(module_path!(), line!(), e))?;
+ if existing.iter().any(|name| name == column) {
+ return Ok(());
+ }
+ conn.execute(&format!("ALTER TABLE {table} ADD COLUMN {column} {decl}"), [])
+ .map_err(|e| crate::utils::err_to_string(module_path!(), line!(), e))?;
+ Ok(())
+}
+
+fn migrate_v95_to_v96(app: &AppHandle) -> Result<(), String> {
+ let conn = crate::storage_manager::db::open_db(app)?;
+ add_column_if_missing(&conn, "personas", "tags", "TEXT")?;
+ add_column_if_missing(&conn, "lorebooks", "tags", "TEXT")?;
+ Ok(())
+}
+
fn migrate_v75_to_v76(app: &AppHandle) -> Result<(), String> {
let conn = crate::storage_manager::db::open_db(app)?;
diff --git a/src-tauri/src/storage_manager/backup.rs b/src-tauri/src/storage_manager/backup.rs
index dc355e94a..27135245a 100644
--- a/src-tauri/src/storage_manager/backup.rs
+++ b/src-tauri/src/storage_manager/backup.rs
@@ -439,7 +439,7 @@ fn export_prompt_templates(app: &tauri::AppHandle) -> Result, Str
fn export_personas(app: &tauri::AppHandle) -> Result, String> {
let conn = open_db(app)?;
let mut stmt = conn
- .prepare("SELECT id, title, description, nickname, avatar_path, avatar_crop_x, avatar_crop_y, avatar_crop_scale, design_description, design_reference_image_ids, lora_name, lora_strength, COALESCE(active_lorebook_ids, '[]'), is_default, created_at, updated_at FROM personas")
+ .prepare("SELECT id, title, description, nickname, avatar_path, avatar_crop_x, avatar_crop_y, avatar_crop_scale, design_description, design_reference_image_ids, lora_name, lora_strength, COALESCE(active_lorebook_ids, '[]'), is_default, created_at, updated_at, tags FROM personas")
.map_err(|e| crate::utils::err_to_string(module_path!(), line!(), e))?;
let rows = stmt
@@ -461,6 +461,7 @@ fn export_personas(app: &tauri::AppHandle) -> Result, String> {
"is_default": r.get::<_, i64>(13)? != 0,
"created_at": r.get::<_, i64>(14)?,
"updated_at": r.get::<_, i64>(15)?,
+ "tags": r.get::<_, Option>(16)?,
}))
})
.map_err(|e| crate::utils::err_to_string(module_path!(), line!(), e))?;
@@ -1137,7 +1138,7 @@ fn export_lorebooks(app: &tauri::AppHandle) -> Result, String> {
let conn = open_db(app)?;
let mut stmt = conn
- .prepare("SELECT id, name, avatar_path, keyword_detection_mode, created_at, updated_at FROM lorebooks")
+ .prepare("SELECT id, name, avatar_path, keyword_detection_mode, created_at, updated_at, tags FROM lorebooks")
.map_err(|e| crate::utils::err_to_string(module_path!(), line!(), e))?;
let lorebooks: Vec<(String, JsonValue)> = stmt
@@ -1150,6 +1151,7 @@ fn export_lorebooks(app: &tauri::AppHandle) -> Result, String> {
"keyword_detection_mode": r.get::<_, String>(3)?,
"created_at": r.get::<_, i64>(4)?,
"updated_at": r.get::<_, i64>(5)?,
+ "tags": r.get::<_, Option>(6)?,
});
Ok((id, json))
})
@@ -2152,8 +2154,8 @@ fn import_personas(app: &tauri::AppHandle, data: &JsonValue) -> Result<(), Strin
if let Some(arr) = data.as_array() {
for item in arr {
conn.execute(
- "INSERT INTO personas (id, title, description, nickname, avatar_path, avatar_crop_x, avatar_crop_y, avatar_crop_scale, design_description, design_reference_image_ids, lora_name, lora_strength, active_lorebook_ids, is_default, created_at, updated_at)
- VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
+ "INSERT INTO personas (id, title, description, nickname, avatar_path, avatar_crop_x, avatar_crop_y, avatar_crop_scale, design_description, design_reference_image_ids, lora_name, lora_strength, active_lorebook_ids, is_default, created_at, updated_at, tags)
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)",
params![
item.get("id").and_then(|v| v.as_str()),
item.get("title").and_then(|v| v.as_str()),
@@ -2171,6 +2173,7 @@ fn import_personas(app: &tauri::AppHandle, data: &JsonValue) -> Result<(), Strin
item.get("is_default").and_then(|v| v.as_bool()).unwrap_or(false) as i64,
item.get("created_at").and_then(|v| v.as_i64()),
item.get("updated_at").and_then(|v| v.as_i64()),
+ item.get("tags").and_then(|v| v.as_str()),
],
).map_err(|e| crate::utils::err_to_string(module_path!(), line!(), e))?;
}
@@ -3148,8 +3151,8 @@ fn import_lorebooks(app: &tauri::AppHandle, data: &JsonValue) -> Result<(), Stri
let lorebook_id = item.get("id").and_then(|v| v.as_str()).unwrap_or("");
conn.execute(
- "INSERT INTO lorebooks (id, name, avatar_path, keyword_detection_mode, created_at, updated_at)
- VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
+ "INSERT INTO lorebooks (id, name, avatar_path, keyword_detection_mode, created_at, updated_at, tags)
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
lorebook_id,
item.get("name").and_then(|v| v.as_str()),
@@ -3159,6 +3162,7 @@ fn import_lorebooks(app: &tauri::AppHandle, data: &JsonValue) -> Result<(), Stri
.unwrap_or("recent_message_window"),
item.get("created_at").and_then(|v| v.as_i64()),
item.get("updated_at").and_then(|v| v.as_i64()),
+ item.get("tags").and_then(|v| v.as_str()),
],
)
.map_err(|e| crate::utils::err_to_string(module_path!(), line!(), e))?;
diff --git a/src-tauri/src/storage_manager/db.rs b/src-tauri/src/storage_manager/db.rs
index 21b2a1aa7..26cdb5251 100644
--- a/src-tauri/src/storage_manager/db.rs
+++ b/src-tauri/src/storage_manager/db.rs
@@ -594,7 +594,8 @@ fn init_db_connection(conn: &Connection) -> Result<(), String> {
avatar_path TEXT,
keyword_detection_mode TEXT NOT NULL DEFAULT 'recent_message_window',
created_at INTEGER NOT NULL,
- updated_at INTEGER NOT NULL
+ updated_at INTEGER NOT NULL,
+ tags TEXT
);
-- Character <-> Lorebook mapping (many-to-many)
@@ -685,7 +686,8 @@ fn init_db_connection(conn: &Connection) -> Result<(), String> {
active_lorebook_ids TEXT NOT NULL DEFAULT '[]',
is_default INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
- updated_at INTEGER NOT NULL
+ updated_at INTEGER NOT NULL,
+ tags TEXT
);
-- Sessions and messages
diff --git a/src-tauri/src/storage_manager/entity_transfer/mod.rs b/src-tauri/src/storage_manager/entity_transfer/mod.rs
index 1bf02d1bf..a0f7cf884 100644
--- a/src-tauri/src/storage_manager/entity_transfer/mod.rs
+++ b/src-tauri/src/storage_manager/entity_transfer/mod.rs
@@ -237,6 +237,8 @@ pub struct PersonaExportData {
pub avatar_crop: Option,
#[serde(default)]
pub active_lorebook_ids: Vec,
+ #[serde(default)]
+ pub tags: Vec,
}
fn number_to_i64(value: &JsonValue) -> Option {
@@ -908,6 +910,19 @@ fn parse_uec_persona(value: &JsonValue) -> Result
.collect::>()
})
.unwrap_or_default();
+ let tags = uec
+ .app_specific_settings
+ .as_ref()
+ .and_then(|value| value.get("tags"))
+ .or_else(|| payload.get("tags"))
+ .and_then(|value| value.as_array())
+ .map(|items| {
+ items
+ .iter()
+ .filter_map(|item| item.as_str().map(|tag| tag.to_string()))
+ .collect::>()
+ })
+ .unwrap_or_default();
Ok(PersonaExportPackage {
version: 1,
@@ -919,6 +934,7 @@ fn parse_uec_persona(value: &JsonValue) -> Result
is_default,
avatar_crop,
active_lorebook_ids,
+ tags,
},
avatar_data,
})
@@ -2073,7 +2089,7 @@ fn import_lorebooks_for_character_package(
let new_lorebook_id = uuid::Uuid::new_v4().to_string();
lorebook_id_map.insert(bundled.lorebook.id.clone(), new_lorebook_id.clone());
tx.execute(
- "INSERT INTO lorebooks (id, name, avatar_path, keyword_detection_mode, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
+ "INSERT INTO lorebooks (id, name, avatar_path, keyword_detection_mode, created_at, updated_at, tags) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
&new_lorebook_id,
&bundled.lorebook.name,
@@ -2081,6 +2097,8 @@ fn import_lorebooks_for_character_package(
bundled.lorebook.keyword_detection_mode.as_db_value(),
now,
now,
+ serde_json::to_string(&bundled.lorebook.tags)
+ .map_err(|e| crate::utils::err_to_string(module_path!(), line!(), e))?,
],
)
.map_err(|e| {
@@ -3713,7 +3731,7 @@ pub fn persona_export(app: tauri::AppHandle, persona_id: String) -> Result,
@@ -3725,11 +3743,12 @@ pub fn persona_export(app: tauri::AppHandle, persona_id: String) -> Result,
) = conn
.query_row(
- "SELECT title, description, nickname, avatar_path, avatar_crop_x, avatar_crop_y, avatar_crop_scale, COALESCE(active_lorebook_ids, '[]'), is_default, created_at, updated_at FROM personas WHERE id = ?",
+ "SELECT title, description, nickname, avatar_path, avatar_crop_x, avatar_crop_y, avatar_crop_scale, COALESCE(active_lorebook_ids, '[]'), is_default, created_at, updated_at, tags FROM personas WHERE id = ?",
params![&persona_id],
- |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?, r.get(7)?, r.get(8)?, r.get(9)?, r.get(10)?)),
+ |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?, r.get(7)?, r.get(8)?, r.get(9)?, r.get(10)?, r.get(11)?)),
)
.map_err(|e| crate::utils::err_msg(module_path!(), line!(), format!("Persona not found: {}", e)))?;
@@ -3771,6 +3790,13 @@ pub fn persona_export(app: tauri::AppHandle, persona_id: String) -> Result>(raw).ok())
+ .filter(|parsed| !parsed.is_empty())
+ {
+ app_specific.insert("tags".into(), serde_json::json!(parsed));
+ }
let export_card = create_persona_uec(
payload,
@@ -3883,6 +3909,8 @@ pub fn persona_import(app: tauri::AppHandle, import_json: String) -> Result Result Result Result Result {
- let (title, description, avatar_path, avatar_crop_x, avatar_crop_y, avatar_crop_scale, active_lorebook_ids, is_default, created_at, updated_at):
- (String, String, Option, Option, Option, Option, String, i64, i64, i64) =
+ let (title, description, avatar_path, avatar_crop_x, avatar_crop_y, avatar_crop_scale, active_lorebook_ids, is_default, created_at, updated_at, tags):
+ (String, String, Option, Option, Option, Option, String, i64, i64, i64, Option) =
conn.query_row(
- "SELECT title, description, avatar_path, avatar_crop_x, avatar_crop_y, avatar_crop_scale, COALESCE(active_lorebook_ids, '[]'), is_default, created_at, updated_at FROM personas WHERE id = ?",
+ "SELECT title, description, avatar_path, avatar_crop_x, avatar_crop_y, avatar_crop_scale, COALESCE(active_lorebook_ids, '[]'), is_default, created_at, updated_at, tags FROM personas WHERE id = ?",
params![persona_id],
|r| Ok((
- r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?, r.get::<_, i64>(7)?, r.get(8)?, r.get(9)?
+ r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?, r.get::<_, i64>(7)?, r.get(8)?, r.get(9)?, r.get(10)?
)),
)
.map_err(|e| crate::utils::err_to_string(module_path!(), line!(), e))?;
@@ -3957,6 +3986,13 @@ fn read_imported_persona(conn: &rusqlite::Connection, persona_id: &str) -> Resul
if let Ok(parsed) = serde_json::from_str::>(&active_lorebook_ids) {
root.insert("activeLorebookIds".into(), serde_json::json!(parsed));
}
+ root.insert(
+ "tags".into(),
+ serde_json::json!(tags
+ .as_deref()
+ .and_then(|raw| serde_json::from_str::>(raw).ok())
+ .unwrap_or_default()),
+ );
root.insert("isDefault".into(), JsonValue::Bool(is_default != 0));
root.insert("createdAt".into(), JsonValue::from(created_at));
root.insert("updatedAt".into(), JsonValue::from(updated_at));
diff --git a/src-tauri/src/storage_manager/group_characters.rs b/src-tauri/src/storage_manager/group_characters.rs
index edbc91afe..4c441f745 100644
--- a/src-tauri/src/storage_manager/group_characters.rs
+++ b/src-tauri/src/storage_manager/group_characters.rs
@@ -376,9 +376,13 @@ fn load_lorebooks_for_ids(
for lorebook_id in lorebook_ids {
if let Some(lorebook) = conn
.query_row(
- "SELECT id, name, avatar_path, keyword_detection_mode, created_at, updated_at FROM lorebooks WHERE id = ?1",
+ "SELECT id, name, avatar_path, keyword_detection_mode, created_at, updated_at, tags FROM lorebooks WHERE id = ?1",
params![lorebook_id],
|row| {
+ let tags_json: Option = row.get(6)?;
+ let tags: Vec = tags_json
+ .and_then(|value| serde_json::from_str(&value).ok())
+ .unwrap_or_default();
Ok(Lorebook {
id: row.get(0)?,
name: row.get(1)?,
@@ -389,6 +393,7 @@ fn load_lorebooks_for_ids(
),
created_at: row.get(4)?,
updated_at: row.get(5)?,
+ tags,
})
},
)
diff --git a/src-tauri/src/storage_manager/group_sessions.rs b/src-tauri/src/storage_manager/group_sessions.rs
index d216d3497..da06af1fa 100644
--- a/src-tauri/src/storage_manager/group_sessions.rs
+++ b/src-tauri/src/storage_manager/group_sessions.rs
@@ -1029,9 +1029,13 @@ fn load_lorebooks_for_ids(
for lorebook_id in lorebook_ids {
if let Some(lorebook) = conn
.query_row(
- "SELECT id, name, avatar_path, keyword_detection_mode, created_at, updated_at FROM lorebooks WHERE id = ?1",
+ "SELECT id, name, avatar_path, keyword_detection_mode, created_at, updated_at, tags FROM lorebooks WHERE id = ?1",
params![lorebook_id],
|row| {
+ let tags_json: Option = row.get(6)?;
+ let tags: Vec = tags_json
+ .and_then(|value| serde_json::from_str(&value).ok())
+ .unwrap_or_default();
Ok(Lorebook {
id: row.get(0)?,
name: row.get(1)?,
@@ -1042,6 +1046,7 @@ fn load_lorebooks_for_ids(
),
created_at: row.get(4)?,
updated_at: row.get(5)?,
+ tags,
})
},
)
diff --git a/src-tauri/src/storage_manager/importer.rs b/src-tauri/src/storage_manager/importer.rs
index a56384ad3..7d9f570ba 100644
--- a/src-tauri/src/storage_manager/importer.rs
+++ b/src-tauri/src/storage_manager/importer.rs
@@ -365,9 +365,14 @@ fn import_personas(conn: &mut rusqlite::Connection, json: &str) -> Result<(), St
.unwrap_or(false) as i64;
let created_at = p.get("createdAt").and_then(|v| v.as_i64()).unwrap_or(now);
let updated_at = p.get("updatedAt").and_then(|v| v.as_i64()).unwrap_or(now);
+ let tags = p
+ .get("tags")
+ .and_then(|v| v.as_array())
+ .and_then(|arr| serde_json::to_string(arr).ok())
+ .unwrap_or_else(|| "[]".to_string());
tx.execute(
- r#"INSERT OR REPLACE INTO personas (id, title, description, avatar_path, avatar_crop_x, avatar_crop_y, avatar_crop_scale, active_lorebook_ids, is_default, created_at, updated_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#,
+ r#"INSERT OR REPLACE INTO personas (id, title, description, avatar_path, avatar_crop_x, avatar_crop_y, avatar_crop_scale, active_lorebook_ids, is_default, created_at, updated_at, tags)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#,
params![
&id,
title,
@@ -379,7 +384,8 @@ fn import_personas(conn: &mut rusqlite::Connection, json: &str) -> Result<(), St
active_lorebook_ids,
is_default,
created_at,
- updated_at
+ updated_at,
+ tags
],
).map_err(|e| crate::utils::err_to_string(module_path!(), line!(), e))?;
}
diff --git a/src-tauri/src/storage_manager/lorebook.rs b/src-tauri/src/storage_manager/lorebook.rs
index 9ba192809..1d7da3b0c 100644
--- a/src-tauri/src/storage_manager/lorebook.rs
+++ b/src-tauri/src/storage_manager/lorebook.rs
@@ -17,6 +17,8 @@ pub struct Lorebook {
pub keyword_detection_mode: LorebookKeywordDetectionMode,
pub created_at: i64,
pub updated_at: i64,
+ #[serde(default)]
+ pub tags: Vec,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
@@ -69,6 +71,10 @@ impl LorebookKeywordDetectionMode {
impl Lorebook {
fn from_row(row: &Row) -> rusqlite::Result {
+ let tags_json: Option = row.get(6)?;
+ let tags: Vec = tags_json
+ .and_then(|value| serde_json::from_str(&value).ok())
+ .unwrap_or_default();
Ok(Lorebook {
id: row.get(0)?,
name: row.get(1)?,
@@ -76,6 +82,7 @@ impl Lorebook {
keyword_detection_mode: LorebookKeywordDetectionMode::from_db_value(row.get(3)?),
created_at: row.get(4)?,
updated_at: row.get(5)?,
+ tags,
})
}
}
@@ -201,7 +208,7 @@ pub fn list_lorebooks(conn: &DbConnection) -> Result, String> {
let mut stmt = conn
.prepare(
r#"
- SELECT id, name, avatar_path, keyword_detection_mode, created_at, updated_at
+ SELECT id, name, avatar_path, keyword_detection_mode, created_at, updated_at, tags
FROM lorebooks
ORDER BY updated_at DESC
"#,
@@ -237,7 +244,7 @@ pub fn list_lorebooks(conn: &DbConnection) -> Result, String> {
pub fn get_lorebook(conn: &DbConnection, lorebook_id: &str) -> Result
+
+
+
dispatch({ type: "set_tags", value })}
+ label={t("personas.edit.tagsLabel")}
+ placeholder={t("personas.edit.tagsPlaceholder")}
+ hint={t("personas.edit.tagsHint")}
+ />
+
{/* Default Option */}
0 ? designReferenceImageIds : undefined,
activeLorebookIds: state.activeLorebookIds,
isDefault: state.isDefault,
+ tags: parseCommaSeparated(state.tags).length > 0
+ ? parseCommaSeparated(state.tags)
+ : undefined,
});
navigate("/library?view=personas", { replace: true });
diff --git a/src/ui/pages/personas/hooks/usePersonaFormController.ts b/src/ui/pages/personas/hooks/usePersonaFormController.ts
index a9e7ba5e5..a8d8958ee 100644
--- a/src/ui/pages/personas/hooks/usePersonaFormController.ts
+++ b/src/ui/pages/personas/hooks/usePersonaFormController.ts
@@ -6,6 +6,7 @@ import { loadAvatar, saveAvatar, recalculateGradient } from "../../../../core/st
import { convertToImageRef, deleteImageRef } from "../../../../core/storage/images";
import { invalidateAvatarCache } from "../../../hooks/useAvatar";
import { useI18n } from "../../../../core/i18n/context";
+import { parseCommaSeparated } from "../../../../core/utils/parseCommaSeparated";
import type { AvatarCrop } from "../../../../core/storage/schemas";
type PersonaFormState = {
@@ -24,6 +25,7 @@ type PersonaFormState = {
loraName: string | null;
loraStrength: number | null;
activeLorebookIds: string[];
+ tags: string;
};
type Action =
@@ -51,6 +53,7 @@ const initialState: PersonaFormState = {
loraName: null,
loraStrength: null,
activeLorebookIds: [],
+ tags: "",
};
function reducer(state: PersonaFormState, action: Action): PersonaFormState {
@@ -87,6 +90,7 @@ export function usePersonaFormController(personaId: string | undefined) {
loraName: string | null;
loraStrength: number | null;
activeLorebookIds: string;
+ tags: string;
} | null>(null);
const persistedAvatarRef = useRef<{ filename?: string; url?: string }>({});
@@ -140,6 +144,7 @@ export function usePersonaFormController(personaId: string | undefined) {
activeLorebookIds: Array.isArray(persona.activeLorebookIds)
? persona.activeLorebookIds
: [],
+ tags: Array.isArray(persona.tags) ? persona.tags.join(", ") : "",
},
});
@@ -157,6 +162,7 @@ export function usePersonaFormController(personaId: string | undefined) {
loraName: persona.loraName ?? null,
loraStrength: persona.loraStrength ?? null,
activeLorebookIds: JSON.stringify(persona.activeLorebookIds ?? []),
+ tags: Array.isArray(persona.tags) ? persona.tags.join(", ") : "",
};
dispatch({ type: "set_error", payload: null });
} catch (error) {
@@ -214,6 +220,10 @@ export function usePersonaFormController(personaId: string | undefined) {
dispatch({ type: "set_fields", payload: { activeLorebookIds: value } });
}, []);
+ const setTags = useCallback((value: string) => {
+ dispatch({ type: "set_fields", payload: { tags: value } });
+ }, []);
+
const setLora = useCallback((name: string | null, strength: number | null) => {
dispatch({ type: "set_fields", payload: { loraName: name, loraStrength: strength } });
}, []);
@@ -236,6 +246,7 @@ export function usePersonaFormController(personaId: string | undefined) {
loraName,
loraStrength,
activeLorebookIds,
+ tags,
} = state;
if (!title.trim() || !description.trim()) {
return;
@@ -303,6 +314,7 @@ export function usePersonaFormController(personaId: string | undefined) {
loraName: loraName ?? undefined,
loraStrength: loraName ? (loraStrength ?? undefined) : undefined,
activeLorebookIds,
+ tags: parseCommaSeparated(tags).length > 0 ? parseCommaSeparated(tags) : undefined,
});
// Update initial state to match current (for change detection)
@@ -319,6 +331,7 @@ export function usePersonaFormController(personaId: string | undefined) {
loraName: loraName ?? null,
loraStrength: loraStrength ?? null,
activeLorebookIds: JSON.stringify(activeLorebookIds),
+ tags,
};
// Sync trimmed values
@@ -367,6 +380,7 @@ export function usePersonaFormController(personaId: string | undefined) {
designDescription: initial.designDescription,
designReferenceImageIds: JSON.parse(initial.designReferenceImageIds) as string[],
activeLorebookIds: JSON.parse(initial.activeLorebookIds) as string[],
+ tags: initial.tags,
},
});
dispatch({ type: "set_error", payload: null });
@@ -392,7 +406,8 @@ export function usePersonaFormController(personaId: string | undefined) {
JSON.stringify(state.avatarRoundPath ?? null) !== initial.avatarRoundPath ||
state.designDescription !== initial.designDescription ||
JSON.stringify(state.designReferenceImageIds) !== initial.designReferenceImageIds ||
- JSON.stringify(state.activeLorebookIds) !== initial.activeLorebookIds;
+ JSON.stringify(state.activeLorebookIds) !== initial.activeLorebookIds ||
+ state.tags !== initial.tags;
return hasChanges;
})();
@@ -410,6 +425,7 @@ export function usePersonaFormController(personaId: string | undefined) {
setDesignReferenceImageIds,
setLora,
setActiveLorebookIds,
+ setTags,
handleSave,
resetToInitial,
canSave,
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 000000000..c1433e6ef
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,8 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ environment: "node",
+ include: ["src/**/*.test.ts"],
+ },
+});