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, String> { 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], Lorebook::from_row, ) @@ -272,12 +279,13 @@ pub fn upsert_lorebook(conn: &DbConnection, lorebook: &Lorebook) -> Result Result Result "recentMessageWindow", LorebookKeywordDetectionMode::LatestUserMessage => "latestUserMessage", - } + }, + "tags": lorebook.tags, }), ); JsonValue::Object(extensions) @@ -1080,6 +1090,18 @@ pub fn lorebook_import(app: tauri::AppHandle, import_json: String) -> Result LorebookKeywordDetectionMode::RecentMessageWindow, }) .unwrap_or(LorebookKeywordDetectionMode::RecentMessageWindow); + let tags = parsed + .extensions + .get("lettuceai") + .and_then(|value| value.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(); let lorebook = Lorebook { id: Uuid::new_v4().to_string(), name: parsed.name.trim().to_string(), @@ -1087,6 +1109,7 @@ pub fn lorebook_import(app: tauri::AppHandle, import_json: String) -> Result(11)?, r.get::<_, i64>(12)?, r.get::<_, i64>(13)?, + r.get::<_, Option>(14)?, )) }) .map_err(|e| crate::utils::err_to_string(module_path!(), line!(), e))?; @@ -47,6 +48,7 @@ where is_default, created_at, updated_at, + tags, ) = row.map_err(|e| crate::utils::err_to_string(module_path!(), line!(), e))?; let mut obj = JsonMap::new(); @@ -89,6 +91,11 @@ where if let Ok(parsed) = serde_json::from_str::>(&active_lorebook_ids) { obj.insert("activeLorebookIds".into(), serde_json::json!(parsed)); } + if let Some(value) = tags { + if let Ok(parsed) = serde_json::from_str::>(&value) { + obj.insert("tags".into(), serde_json::json!(parsed)); + } + } obj.insert("isDefault".into(), JsonValue::Bool(is_default != 0)); obj.insert("createdAt".into(), JsonValue::from(created_at)); obj.insert("updatedAt".into(), JsonValue::from(updated_at)); @@ -145,7 +152,7 @@ pub fn default_persona_id(app: tauri::AppHandle) -> Option { #[tauri::command] pub fn personas_list(app: tauri::AppHandle) -> Result { 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, COALESCE(active_lorebook_ids, '[]'), is_default, created_at, updated_at FROM personas ORDER BY created_at ASC").map_err(|e| crate::utils::err_to_string(module_path!(), line!(), e))?; + 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, COALESCE(active_lorebook_ids, '[]'), is_default, created_at, updated_at, tags FROM personas ORDER BY created_at ASC").map_err(|e| crate::utils::err_to_string(module_path!(), line!(), e))?; let rows = stmt .query_map([], |r| { Ok(( @@ -163,6 +170,7 @@ pub fn personas_list(app: tauri::AppHandle) -> Result { r.get::<_, i64>(11)?, r.get::<_, i64>(12)?, r.get::<_, i64>(13)?, + r.get::<_, Option>(14)?, )) }) .map_err(|e| crate::utils::err_to_string(module_path!(), line!(), e))?; @@ -183,6 +191,7 @@ pub fn personas_list(app: tauri::AppHandle) -> Result { is_default, created_at, updated_at, + tags, ) = row.map_err(|e| crate::utils::err_to_string(module_path!(), line!(), e))?; let mut obj = JsonMap::new(); if let Ok((lora_name, lora_strength)) = conn.query_row( @@ -227,6 +236,11 @@ pub fn personas_list(app: tauri::AppHandle) -> Result { if let Ok(parsed) = serde_json::from_str::>(&active_lorebook_ids) { obj.insert("activeLorebookIds".into(), serde_json::json!(parsed)); } + if let Some(value) = tags { + if let Ok(parsed) = serde_json::from_str::>(&value) { + obj.insert("tags".into(), serde_json::json!(parsed)); + } + } obj.insert("isDefault".into(), JsonValue::Bool(is_default != 0)); obj.insert("createdAt".into(), JsonValue::from(created_at)); obj.insert("updatedAt".into(), JsonValue::from(updated_at)); @@ -283,6 +297,10 @@ fn upsert_persona_value(app: &tauri::AppHandle, p: &JsonValue) -> Result = p + .get("tags") + .and_then(|v| v.as_array()) + .and_then(|arr| serde_json::to_string(arr).ok()); let is_default = p .get("isDefault") .and_then(|v| v.as_bool()) @@ -306,8 +324,8 @@ fn upsert_persona_value(app: &tauri::AppHandle, p: &JsonValue) -> Result Result Result Result>(&value) { + obj.insert("tags".into(), serde_json::json!(parsed)); + } + } if let Some(value) = lora_name { obj.insert("loraName".into(), JsonValue::String(value)); } @@ -412,8 +437,8 @@ pub fn persona_delete(app: tauri::AppHandle, id: String) -> Result<(), String> { #[tauri::command] pub fn persona_default_get(app: tauri::AppHandle) -> Result, String> { let conn = open_db(&app)?; - let row = conn.query_row("SELECT id, title, description, nickname, avatar_path, avatar_crop_x, avatar_crop_y, avatar_crop_scale, design_description, design_reference_image_ids, COALESCE(active_lorebook_ids, '[]'), is_default, created_at, updated_at FROM personas WHERE is_default = 1 LIMIT 1", [], |r| Ok(( - r.get::<_, String>(0)?, r.get::<_, String>(1)?, r.get::<_, String>(2)?, r.get::<_, Option>(3)?, r.get::<_, Option>(4)?, r.get::<_, Option>(5)?, r.get::<_, Option>(6)?, r.get::<_, Option>(7)?, r.get::<_, Option>(8)?, r.get::<_, Option>(9)?, r.get::<_, String>(10)?, r.get::<_, i64>(11)?, r.get::<_, i64>(12)?, r.get::<_, i64>(13)? + let row = conn.query_row("SELECT id, title, description, nickname, avatar_path, avatar_crop_x, avatar_crop_y, avatar_crop_scale, design_description, design_reference_image_ids, COALESCE(active_lorebook_ids, '[]'), is_default, created_at, updated_at, tags FROM personas WHERE is_default = 1 LIMIT 1", [], |r| Ok(( + r.get::<_, String>(0)?, r.get::<_, String>(1)?, r.get::<_, String>(2)?, r.get::<_, Option>(3)?, r.get::<_, Option>(4)?, r.get::<_, Option>(5)?, r.get::<_, Option>(6)?, r.get::<_, Option>(7)?, r.get::<_, Option>(8)?, r.get::<_, Option>(9)?, r.get::<_, String>(10)?, r.get::<_, i64>(11)?, r.get::<_, i64>(12)?, r.get::<_, i64>(13)?, r.get::<_, Option>(14)? ))).optional().map_err(|e| crate::utils::err_to_string(module_path!(), line!(), e))?; if let Some(( id, @@ -430,6 +455,7 @@ pub fn persona_default_get(app: tauri::AppHandle) -> Result, Stri is_default, created_at, updated_at, + tags, )) = row { let mut obj = JsonMap::new(); @@ -475,6 +501,11 @@ pub fn persona_default_get(app: tauri::AppHandle) -> Result, Stri if let Ok(parsed) = serde_json::from_str::>(&active_lorebook_ids) { obj.insert("activeLorebookIds".into(), serde_json::json!(parsed)); } + if let Some(value) = tags { + if let Ok(parsed) = serde_json::from_str::>(&value) { + obj.insert("tags".into(), serde_json::json!(parsed)); + } + } obj.insert("isDefault".into(), JsonValue::Bool(is_default != 0)); obj.insert("createdAt".into(), JsonValue::from(created_at)); obj.insert("updatedAt".into(), JsonValue::from(updated_at)); diff --git a/src-tauri/src/storage_manager/system_cards.rs b/src-tauri/src/storage_manager/system_cards.rs index 83b3aff23..8c0aa3552 100644 --- a/src-tauri/src/storage_manager/system_cards.rs +++ b/src-tauri/src/storage_manager/system_cards.rs @@ -189,6 +189,8 @@ pub struct UscLorebookPayload { pub description: Option, #[serde(skip_serializing_if = "Option::is_none")] pub keyword_detection_mode: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, pub entries: Vec, pub created_at: i64, pub updated_at: i64, @@ -503,6 +505,7 @@ pub fn create_lorebook_usc(lorebook: &Lorebook, entries: &[LorebookEntry]) -> Us "latestUserMessage".to_string() } }), + tags: lorebook.tags.clone(), entries: entries .iter() .map(|entry| UscLorebookEntry { diff --git a/src-tauri/src/sync/models.rs b/src-tauri/src/sync/models.rs index c20dca7b4..fd34d7344 100644 --- a/src-tauri/src/sync/models.rs +++ b/src-tauri/src/sync/models.rs @@ -57,6 +57,8 @@ pub struct Persona { pub is_default: i64, pub created_at: i64, pub updated_at: i64, + #[serde(default)] + pub tags: Option, } #[derive(Debug, Serialize, Deserialize)] @@ -296,6 +298,8 @@ pub struct SyncLorebook { pub keyword_detection_mode: String, pub created_at: i64, pub updated_at: i64, + #[serde(default)] + pub tags: Option, } #[derive(Debug, Serialize, Deserialize)] diff --git a/src-tauri/tests/system_cards_inline.rs b/src-tauri/tests/system_cards_inline.rs index ae6e32d4d..1edf47f0d 100644 --- a/src-tauri/tests/system_cards_inline.rs +++ b/src-tauri/tests/system_cards_inline.rs @@ -165,6 +165,7 @@ fn create_lorebook_omits_internal_lorebook_id_from_entries() { lettuceai_lib::storage_manager::lorebook::LorebookKeywordDetectionMode::RecentMessageWindow, created_at: 1, updated_at: 2, + tags: Vec::new(), }; let entries = vec![LorebookEntry { id: "entry-1".into(), diff --git a/src/core/i18n/localeParity.test.ts b/src/core/i18n/localeParity.test.ts new file mode 100644 index 000000000..9bfd7ae8c --- /dev/null +++ b/src/core/i18n/localeParity.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from "vitest"; +import { localeRegistry } from "./locales/registry"; + +/** + * Locales are typed `DeepPartialMessageTree`, so a key missing from a translation is not a + * compile error — `t()` silently falls back to English. This spec pins the keys added + * alongside persona/lorebook tags so that fallback can't quietly reappear. + * + * Scoped deliberately: the locales carry unrelated pre-existing gaps, so asserting full + * parity here would fail on work this area doesn't own. + */ +const REQUIRED_KEYS = [ + "library.sort.label", + "library.sort.recent", + "library.sort.alphabetical", + "library.tagFilter.clear", + "library.emptyStates.noTagMatches.title", + "library.emptyStates.noTagMatches.description", + "characters.lorebook.metadataTitle", + "characters.lorebook.tagsLabel", + "characters.lorebook.tagsPlaceholder", + "personas.edit.tagsLabel", + "personas.edit.tagsPlaceholder", + "personas.edit.tagsHint", +] as const; + +function lookup(messages: unknown, key: string): unknown { + return key + .split(".") + .reduce( + (node, part) => + node && typeof node === "object" ? (node as Record)[part] : undefined, + messages, + ); +} + +const locales = Object.keys(localeRegistry) as Array; + +describe("tag/sort translation parity", () => { + it("covers every registered locale", () => { + expect(locales.length).toBeGreaterThanOrEqual(20); + expect(locales).toContain("en"); + }); + + it.each(locales)("%s defines all tag and sort keys", (locale) => { + const { messages } = localeRegistry[locale]; + const missing = REQUIRED_KEYS.filter((key) => { + const value = lookup(messages, key); + return typeof value !== "string" || value.trim() === ""; + }); + expect(missing).toEqual([]); + }); +}); diff --git a/src/core/i18n/locales/de.ts b/src/core/i18n/locales/de.ts index 0f9cd79b7..bbeb56a63 100644 --- a/src/core/i18n/locales/de.ts +++ b/src/core/i18n/locales/de.ts @@ -3158,6 +3158,9 @@ export const deMessages: DeepPartialMessageTree = { generateEntryAria: "Lorebook-Eintrag generieren", exportLorebookAria: "Lorebook exportieren", regularExpression: "Reguläre Ausdrücke verwenden", + metadataTitle: "Lorebook bearbeiten", + tagsLabel: "Tags", + tagsPlaceholder: "z. B. Fantasy, Sci-Fi usw. ...", }, templates: { characterNotFound: "Charakter nicht gefunden", @@ -5337,6 +5340,9 @@ export const deMessages: DeepPartialMessageTree = { setAsDefault: "Als Standard festlegen", defaultDescription: "Diese Persona für alle neuen Unterhaltungen verwenden", exportButton: "Persona exportieren", + tagsLabel: "Tags", + tagsPlaceholder: "z. B. Fantasy, Sci-Fi usw. ...", + tagsHint: "Tags durch Kommas trennen.", }, designReferences: { title: "Designreferenzen", @@ -6856,6 +6862,10 @@ export const deMessages: DeepPartialMessageTree = { title: "Noch keine Lorebooks", description: "Lorebooks werden in den Einstellungen eines Charakters erstellt", }, + noTagMatches: { + title: "Keine Treffer", + description: "Keine Einträge stimmen mit den ausgewählten Tags überein.", + }, }, actions: { startChat: "Chat starten", @@ -6976,6 +6986,14 @@ export const deMessages: DeepPartialMessageTree = { exportLorebook: "Lorebook exportieren", }, }, + sort: { + label: "Sortieren", + recent: "Zuletzt", + alphabetical: "A–Z", + }, + tagFilter: { + clear: "Tags löschen", + }, }, onboarding: { loading: "Anbieter laden...", diff --git a/src/core/i18n/locales/el.ts b/src/core/i18n/locales/el.ts index 07e154154..4b7454001 100644 --- a/src/core/i18n/locales/el.ts +++ b/src/core/i18n/locales/el.ts @@ -3153,6 +3153,9 @@ export const elMessages: DeepPartialMessageTree = { generateEntryAria: "Δημιουργία εγγραφής lorebook", exportLorebookAria: "Εξαγωγή lorebook", regularExpression: "Χρήση κανονικών εκφράσεων", + metadataTitle: "Επεξεργασία Βιβλίου Γνώσης", + tagsLabel: "Ετικέτες", + tagsPlaceholder: "π.χ. φαντασία, sci-fi κ.λπ. ...", }, templates: { characterNotFound: "Ο χαρακτήρας δεν βρέθηκε", @@ -5332,6 +5335,9 @@ export const elMessages: DeepPartialMessageTree = { setAsDefault: "Ορισμός ως Προεπιλογή", defaultDescription: "Χρήση αυτής της περσόνας σε όλες τις νέες συνομιλίες", exportButton: "Εξαγωγή Περσόνας", + tagsLabel: "Ετικέτες", + tagsPlaceholder: "π.χ. φαντασία, sci-fi κ.λπ. ...", + tagsHint: "Διαχωρίστε τις ετικέτες με κόμματα.", }, designReferences: { title: "Σχεδιαστικές αναφορές", @@ -6851,6 +6857,10 @@ export const elMessages: DeepPartialMessageTree = { title: "Δεν υπάρχουν βιβλία γνώσης ακόμα", description: "Τα βιβλία γνώσης δημιουργούνται μέσα από τις ρυθμίσεις ενός χαρακτήρα", }, + noTagMatches: { + title: "Καμία αντιστοιχία", + description: "Κανένα στοιχείο δεν ταιριάζει με τις επιλεγμένες ετικέτες.", + }, }, actions: { startChat: "Έναρξη Συνομιλίας", @@ -6971,6 +6981,14 @@ export const elMessages: DeepPartialMessageTree = { exportLorebook: "Εξαγωγή βιβλίου γνώσης", }, }, + sort: { + label: "Ταξινόμηση", + recent: "Πρόσφατα", + alphabetical: "Α–Ω", + }, + tagFilter: { + clear: "Καθαρισμός ετικετών", + }, }, onboarding: { loading: "Φόρτωση παρόχων...", diff --git a/src/core/i18n/locales/en.ts b/src/core/i18n/locales/en.ts index e25668120..0ab4e781b 100644 --- a/src/core/i18n/locales/en.ts +++ b/src/core/i18n/locales/en.ts @@ -3168,8 +3168,11 @@ export const enMessages = { enabledForSession: "enabled for this session", tapToViewEntries: "Tap to view entries", newLorebookTitle: "New Lorebook", + metadataTitle: "Edit lorebook", nameLabel: "NAME", enterNamePlaceholder: "Enter lorebook name...", + tagsLabel: "Tags", + tagsPlaceholder: "e.g. Fantasy, Sci-Fi, etc...", lorebookExplanation: "Lorebooks contain lore entries that are injected into prompts when keywords match.", keywordDetectionMode: "KEYWORD DETECTION", @@ -5742,6 +5745,9 @@ export const enMessages = { "Describe how the AI should address you, your preferences, background, or communication style...", wordCount: "words", descriptionHint: "Be specific about how you want to be addressed", + tagsLabel: "Tags", + tagsPlaceholder: "e.g. Fantasy, Sci-Fi, etc...", + tagsHint: "Separate tags with commas.", setAsDefault: "Set as Default", defaultDescription: "Use this persona for all new conversations", exportButton: "Export Persona", @@ -7777,6 +7783,14 @@ export const enMessages = { images: "Images", audio: "Audio", }, + sort: { + label: "Sort", + recent: "Recent", + alphabetical: "A–Z", + }, + tagFilter: { + clear: "Clear tags", + }, audio: { filters: { all: "All", @@ -7822,6 +7836,10 @@ export const enMessages = { title: "No lorebooks yet", description: "Lorebooks are created from within a character's settings", }, + noTagMatches: { + title: "No matches", + description: "No items match the selected tags.", + }, }, actions: { startChat: "Start Chat", diff --git a/src/core/i18n/locales/es.ts b/src/core/i18n/locales/es.ts index 3aaf510f1..6934cdbda 100644 --- a/src/core/i18n/locales/es.ts +++ b/src/core/i18n/locales/es.ts @@ -3153,6 +3153,9 @@ export const esMessages: DeepPartialMessageTree = { generateEntryAria: "Generar entrada de lorebook", exportLorebookAria: "Exportar lorebook", regularExpression: "Usar expresiones regulares", + metadataTitle: "Editar libro de lore", + tagsLabel: "Etiquetas", + tagsPlaceholder: "p. ej. fantasía, ciencia ficción, etc...", }, templates: { characterNotFound: "Personaje no encontrado", @@ -5332,6 +5335,9 @@ export const esMessages: DeepPartialMessageTree = { setAsDefault: "Establecer como predeterminada", defaultDescription: "Usar esta persona para todas las conversaciones nuevas", exportButton: "Exportar persona", + tagsLabel: "Etiquetas", + tagsPlaceholder: "p. ej. fantasía, ciencia ficción, etc...", + tagsHint: "Separa las etiquetas con comas.", }, designReferences: { title: "Referencias de diseño", @@ -6851,6 +6857,10 @@ export const esMessages: DeepPartialMessageTree = { title: "No hay libros de lore aún", description: "Los libros de lore se crean desde los ajustes de un personaje", }, + noTagMatches: { + title: "Sin coincidencias", + description: "Ningún elemento coincide con las etiquetas seleccionadas.", + }, }, actions: { startChat: "Iniciar chat", @@ -6971,6 +6981,14 @@ export const esMessages: DeepPartialMessageTree = { exportLorebook: "Exportar lorebook", }, }, + sort: { + label: "Ordenar", + recent: "Reciente", + alphabetical: "A–Z", + }, + tagFilter: { + clear: "Borrar etiquetas", + }, }, onboarding: { loading: "Cargando proveedores...", diff --git a/src/core/i18n/locales/fil.ts b/src/core/i18n/locales/fil.ts index 4c67e2e78..9b8901365 100644 --- a/src/core/i18n/locales/fil.ts +++ b/src/core/i18n/locales/fil.ts @@ -3151,6 +3151,9 @@ export const filMessages: DeepPartialMessageTree = { generateEntryAria: "Gumawa ng lorebook entry", exportLorebookAria: "I-export ang lorebook", regularExpression: "Gumamit ng mga regular expression", + metadataTitle: "I-edit ang Lorebook", + tagsLabel: "Mga Tag", + tagsPlaceholder: "hal. fantasy, sci-fi, atbp...", }, templates: { characterNotFound: "Hindi nahanap ang karakter", @@ -5330,6 +5333,9 @@ export const filMessages: DeepPartialMessageTree = { setAsDefault: "Itakda bilang Default", defaultDescription: "Gamitin ang persona na ito para sa lahat ng bagong usapan", exportButton: "I-export ang Persona", + tagsLabel: "Mga Tag", + tagsPlaceholder: "hal. fantasy, sci-fi, atbp...", + tagsHint: "Paghiwalayin ang mga tag gamit ang kuwit.", }, designReferences: { title: "Mga reference ng disenyo", @@ -6849,6 +6855,10 @@ export const filMessages: DeepPartialMessageTree = { title: "Wala pang mga lorebook", description: "Ang mga lorebook ay ginagawa mula sa loob ng mga setting ng karakter", }, + noTagMatches: { + title: "Walang tugma", + description: "Walang item na tumutugma sa mga napiling tag.", + }, }, actions: { startChat: "Magsimula ng Chat", @@ -6969,6 +6979,14 @@ export const filMessages: DeepPartialMessageTree = { exportLorebook: "I-export ang lorebook", }, }, + sort: { + label: "Ayusin", + recent: "Kamakailan", + alphabetical: "A–Z", + }, + tagFilter: { + clear: "I-clear ang mga tag", + }, }, onboarding: { loading: "Naglo-load ng mga provider...", diff --git a/src/core/i18n/locales/fr.ts b/src/core/i18n/locales/fr.ts index d1002ac7d..40e7ad360 100644 --- a/src/core/i18n/locales/fr.ts +++ b/src/core/i18n/locales/fr.ts @@ -3153,6 +3153,9 @@ export const frMessages: DeepPartialMessageTree = { generateEntryAria: "Générer une entrée de lorebook", exportLorebookAria: "Exporter le lorebook", regularExpression: "Utiliser des expressions régulières", + metadataTitle: "Modifier l'encyclopédie", + tagsLabel: "Tags", + tagsPlaceholder: "p. ex. fantaisie, sci-fi, etc...", }, templates: { characterNotFound: "Personnage introuvable", @@ -5332,6 +5335,9 @@ export const frMessages: DeepPartialMessageTree = { setAsDefault: "Définir comme par défaut", defaultDescription: "Utiliser ce persona pour toutes les nouvelles conversations", exportButton: "Exporter le persona", + tagsLabel: "Tags", + tagsPlaceholder: "p. ex. fantaisie, sci-fi, etc...", + tagsHint: "Séparez les tags par des virgules.", }, designReferences: { title: "Références de design", @@ -6851,6 +6857,10 @@ export const frMessages: DeepPartialMessageTree = { title: "Pas encore d'encyclopédies", description: "Les encyclopédies sont créées dans les paramètres d'un personnage", }, + noTagMatches: { + title: "Aucun résultat", + description: "Aucun élément ne correspond aux tags sélectionnés.", + }, }, actions: { startChat: "Démarrer la discussion", @@ -6971,6 +6981,14 @@ export const frMessages: DeepPartialMessageTree = { exportLorebook: "Exporter le lorebook", }, }, + sort: { + label: "Trier", + recent: "Récents", + alphabetical: "A–Z", + }, + tagFilter: { + clear: "Effacer les tags", + }, }, onboarding: { loading: "Chargement des fournisseurs...", diff --git a/src/core/i18n/locales/hi.ts b/src/core/i18n/locales/hi.ts index 740fe12c8..971539cce 100644 --- a/src/core/i18n/locales/hi.ts +++ b/src/core/i18n/locales/hi.ts @@ -3151,6 +3151,9 @@ export const hiMessages: DeepPartialMessageTree = { generateEntryAria: "लोअरबुक प्रविष्टि जेनरेट करें", exportLorebookAria: "लोअरबुक एक्सपोर्ट करें", regularExpression: "रेगुलर एक्सप्रेशन का उपयोग करें", + metadataTitle: "लोरबुक संपादित करें", + tagsLabel: "टैग", + tagsPlaceholder: "जैसे फ़ैंटेसी, साई-फ़ाई, आदि...", }, templates: { characterNotFound: "कैरेक्टर नहीं मिला", @@ -4581,6 +4584,9 @@ export const hiMessages: DeepPartialMessageTree = { setAsDefault: "डिफ़ॉल्ट के रूप में सेट करें", defaultDescription: "सभी नई बातचीत के लिए इस पर्सोना का उपयोग करें", exportButton: "पर्सोना निर्यात करें", + tagsLabel: "टैग", + tagsPlaceholder: "जैसे फ़ैंटेसी, साई-फ़ाई, आदि...", + tagsHint: "टैग को अल्पविराम से अलग करें।", }, designReferences: { title: "डिज़ाइन संदर्भ", @@ -6100,6 +6106,10 @@ export const hiMessages: DeepPartialMessageTree = { title: "अभी तक कोई लोरबुक नहीं", description: "लोरबुक कैरेक्टर की सेटिंग्स के भीतर से बनाई जाती हैं", }, + noTagMatches: { + title: "कोई मेल नहीं", + description: "चयनित टैग से कोई आइटम मेल नहीं खाता।", + }, }, actions: { startChat: "चैट शुरू करें", @@ -6220,6 +6230,14 @@ export const hiMessages: DeepPartialMessageTree = { exportLorebook: "लोरबुक निर्यात करें", }, }, + sort: { + label: "क्रमबद्ध करें", + recent: "हाल के", + alphabetical: "A–Z", + }, + tagFilter: { + clear: "टैग हटाएँ", + }, }, onboarding: { loading: "प्रदाता लोड हो रहे हैं...", diff --git a/src/core/i18n/locales/id.ts b/src/core/i18n/locales/id.ts index 62d6b2d3d..4d8bda937 100644 --- a/src/core/i18n/locales/id.ts +++ b/src/core/i18n/locales/id.ts @@ -3151,6 +3151,9 @@ export const idMessages: DeepPartialMessageTree = { generateEntryAria: "Hasilkan entri lorebook", exportLorebookAria: "Ekspor lorebook", regularExpression: "Gunakan ekspresi reguler", + metadataTitle: "Edit Lorebook", + tagsLabel: "Tag", + tagsPlaceholder: "mis. fantasi, sci-fi, dll...", }, templates: { characterNotFound: "Karakter tidak ditemukan", @@ -4581,6 +4584,9 @@ export const idMessages: DeepPartialMessageTree = { setAsDefault: "Atur sebagai Bawaan", defaultDescription: "Gunakan persona ini untuk semua percakapan baru", exportButton: "Ekspor Persona", + tagsLabel: "Tag", + tagsPlaceholder: "mis. fantasi, sci-fi, dll...", + tagsHint: "Pisahkan tag dengan koma.", }, designReferences: { title: "Referensi desain", @@ -6100,6 +6106,10 @@ export const idMessages: DeepPartialMessageTree = { title: "Belum ada lorebook", description: "Lorebook dibuat dari dalam pengaturan karakter", }, + noTagMatches: { + title: "Tidak ada yang cocok", + description: "Tidak ada item yang cocok dengan tag yang dipilih.", + }, }, actions: { startChat: "Mulai Obrolan", @@ -6220,6 +6230,14 @@ export const idMessages: DeepPartialMessageTree = { exportLorebook: "Ekspor lorebook", }, }, + sort: { + label: "Urutkan", + recent: "Terbaru", + alphabetical: "A–Z", + }, + tagFilter: { + clear: "Hapus tag", + }, }, onboarding: { loading: "Memuat penyedia...", diff --git a/src/core/i18n/locales/it.ts b/src/core/i18n/locales/it.ts index 49a39234b..088d6e1bb 100644 --- a/src/core/i18n/locales/it.ts +++ b/src/core/i18n/locales/it.ts @@ -3151,6 +3151,9 @@ export const itMessages: DeepPartialMessageTree = { generateEntryAria: "Genera voce lorebook", exportLorebookAria: "Esporta lorebook", regularExpression: "Usa espressioni regolari", + metadataTitle: "Modifica lorebook", + tagsLabel: "Tag", + tagsPlaceholder: "es. fantasy, sci-fi, ecc...", }, templates: { characterNotFound: "Personaggio non trovato", @@ -4581,6 +4584,9 @@ export const itMessages: DeepPartialMessageTree = { setAsDefault: "Imposta come predefinita", defaultDescription: "Usa questa persona per tutte le nuove conversazioni", exportButton: "Esporta persona", + tagsLabel: "Tag", + tagsPlaceholder: "es. fantasy, sci-fi, ecc...", + tagsHint: "Separa i tag con le virgole.", }, designReferences: { title: "Riferimenti di design", @@ -6100,6 +6106,10 @@ export const itMessages: DeepPartialMessageTree = { title: "Nessun lorebook", description: "I lorebook vengono creati dalle impostazioni di un personaggio", }, + noTagMatches: { + title: "Nessun risultato", + description: "Nessun elemento corrisponde ai tag selezionati.", + }, }, actions: { startChat: "Inizia chat", @@ -6220,6 +6230,14 @@ export const itMessages: DeepPartialMessageTree = { exportLorebook: "Esporta lorebook", }, }, + sort: { + label: "Ordina", + recent: "Recenti", + alphabetical: "A–Z", + }, + tagFilter: { + clear: "Cancella tag", + }, }, onboarding: { loading: "Caricamento provider...", diff --git a/src/core/i18n/locales/ja.ts b/src/core/i18n/locales/ja.ts index 515936d11..92af89104 100644 --- a/src/core/i18n/locales/ja.ts +++ b/src/core/i18n/locales/ja.ts @@ -3151,6 +3151,9 @@ export const jaMessages: DeepPartialMessageTree = { generateEntryAria: "ロアブックエントリを生成", exportLorebookAria: "ロアブックをエクスポート", regularExpression: "正規表現を使用", + metadataTitle: "ロアブックを編集", + tagsLabel: "タグ", + tagsPlaceholder: "例: ファンタジー、SF など...", }, templates: { characterNotFound: "キャラクターが見つかりません", @@ -4580,6 +4583,9 @@ export const jaMessages: DeepPartialMessageTree = { setAsDefault: "デフォルトに設定", defaultDescription: "すべての新しい会話でこのペルソナを使用", exportButton: "ペルソナをエクスポート", + tagsLabel: "タグ", + tagsPlaceholder: "例: ファンタジー、SF など...", + tagsHint: "タグはカンマで区切ってください。", }, designReferences: { title: "デザインリファレンス", @@ -6099,6 +6105,10 @@ export const jaMessages: DeepPartialMessageTree = { title: "ロアブックがまだありません", description: "ロアブックはキャラクターの設定内から作成されます", }, + noTagMatches: { + title: "該当なし", + description: "選択したタグに一致する項目はありません。", + }, }, actions: { startChat: "チャット開始", @@ -6219,6 +6229,14 @@ export const jaMessages: DeepPartialMessageTree = { exportLorebook: "ロアブックをエクスポート", }, }, + sort: { + label: "並び替え", + recent: "最近", + alphabetical: "A–Z", + }, + tagFilter: { + clear: "タグをクリア", + }, }, onboarding: { loading: "プロバイダーを読み込み中...", diff --git a/src/core/i18n/locales/ko.ts b/src/core/i18n/locales/ko.ts index 4cd1a584e..25804428d 100644 --- a/src/core/i18n/locales/ko.ts +++ b/src/core/i18n/locales/ko.ts @@ -3151,6 +3151,9 @@ export const koMessages: DeepPartialMessageTree = { generateEntryAria: "로어북 항목 생성", exportLorebookAria: "로어북 내보내기", regularExpression: "정규 표현식 사용", + metadataTitle: "로어북 편집", + tagsLabel: "태그", + tagsPlaceholder: "예: 판타지, SF 등...", }, templates: { characterNotFound: "캐릭터를 찾을 수 없습니다", @@ -5329,6 +5332,9 @@ export const koMessages: DeepPartialMessageTree = { setAsDefault: "기본값으로 설정", defaultDescription: "모든 새 대화에 이 페르소나 사용", exportButton: "페르소나 내보내기", + tagsLabel: "태그", + tagsPlaceholder: "예: 판타지, SF 등...", + tagsHint: "태그는 쉼표로 구분하세요.", }, designReferences: { title: "디자인 참고자료", @@ -6848,6 +6854,10 @@ export const koMessages: DeepPartialMessageTree = { title: "아직 로어북이 없습니다", description: "로어북은 캐릭터 설정 내에서 만들 수 있습니다", }, + noTagMatches: { + title: "일치 항목 없음", + description: "선택한 태그와 일치하는 항목이 없습니다.", + }, }, actions: { startChat: "채팅 시작", @@ -6968,6 +6978,14 @@ export const koMessages: DeepPartialMessageTree = { exportLorebook: "로어북 내보내기", }, }, + sort: { + label: "정렬", + recent: "최근", + alphabetical: "가나다순", + }, + tagFilter: { + clear: "태그 지우기", + }, }, onboarding: { loading: "제공자 로딩 중...", diff --git a/src/core/i18n/locales/nl.ts b/src/core/i18n/locales/nl.ts index 5facabcac..77a0f3274 100644 --- a/src/core/i18n/locales/nl.ts +++ b/src/core/i18n/locales/nl.ts @@ -3151,6 +3151,9 @@ export const nlMessages: DeepPartialMessageTree = { generateEntryAria: "Lorebook-item genereren", exportLorebookAria: "Lorebook exporteren", regularExpression: "Reguliere expressies gebruiken", + metadataTitle: "Lorebook bewerken", + tagsLabel: "Tags", + tagsPlaceholder: "bijv. fantasy, sci-fi, enz...", }, templates: { characterNotFound: "Karakter niet gevonden", @@ -5330,6 +5333,9 @@ export const nlMessages: DeepPartialMessageTree = { setAsDefault: "Als standaard instellen", defaultDescription: "Gebruik deze persona voor alle nieuwe gesprekken", exportButton: "Persona exporteren", + tagsLabel: "Tags", + tagsPlaceholder: "bijv. fantasy, sci-fi, enz...", + tagsHint: "Scheid tags met komma's.", }, designReferences: { title: "Ontwerpverwijzingen", @@ -6849,6 +6855,10 @@ export const nlMessages: DeepPartialMessageTree = { title: "Nog geen lorebooks", description: "Lorebooks worden aangemaakt vanuit de instellingen van een karakter", }, + noTagMatches: { + title: "Geen resultaten", + description: "Geen items komen overeen met de geselecteerde tags.", + }, }, actions: { startChat: "Chat starten", @@ -6969,6 +6979,14 @@ export const nlMessages: DeepPartialMessageTree = { exportLorebook: "Lorebook exporteren", }, }, + sort: { + label: "Sorteren", + recent: "Recent", + alphabetical: "A–Z", + }, + tagFilter: { + clear: "Tags wissen", + }, }, onboarding: { loading: "Providers laden...", diff --git a/src/core/i18n/locales/no.ts b/src/core/i18n/locales/no.ts index f93653113..0d09f9443 100644 --- a/src/core/i18n/locales/no.ts +++ b/src/core/i18n/locales/no.ts @@ -3151,6 +3151,9 @@ export const noMessages: DeepPartialMessageTree = { generateEntryAria: "Generer lorebok-oppføring", exportLorebookAria: "Eksporter lorebok", regularExpression: "Bruk regulære uttrykk", + metadataTitle: "Rediger lorebok", + tagsLabel: "Tagger", + tagsPlaceholder: "f.eks. fantasi, sci-fi osv...", }, templates: { characterNotFound: "Karakter ikke funnet", @@ -5330,6 +5333,9 @@ export const noMessages: DeepPartialMessageTree = { setAsDefault: "Sett som standard", defaultDescription: "Bruk denne personaen for alle nye samtaler", exportButton: "Eksporter persona", + tagsLabel: "Tagger", + tagsPlaceholder: "f.eks. fantasi, sci-fi osv...", + tagsHint: "Skill tagger med komma.", }, designReferences: { title: "Designreferanser", @@ -6849,6 +6855,10 @@ export const noMessages: DeepPartialMessageTree = { title: "Ingen lorebøker ennå", description: "Lorebøker opprettes fra en karakters innstillinger", }, + noTagMatches: { + title: "Ingen treff", + description: "Ingen elementer samsvarer med de valgte taggene.", + }, }, actions: { startChat: "Start chat", @@ -6969,6 +6979,14 @@ export const noMessages: DeepPartialMessageTree = { exportLorebook: "Eksporter lorebok", }, }, + sort: { + label: "Sorter", + recent: "Nylige", + alphabetical: "A–Å", + }, + tagFilter: { + clear: "Fjern tagger", + }, }, onboarding: { loading: "Laster leverandører...", diff --git a/src/core/i18n/locales/pl.ts b/src/core/i18n/locales/pl.ts index d02bd14ad..f1d4582dc 100644 --- a/src/core/i18n/locales/pl.ts +++ b/src/core/i18n/locales/pl.ts @@ -3151,6 +3151,9 @@ export const plMessages: DeepPartialMessageTree = { generateEntryAria: "Generuj wpis lorebooka", exportLorebookAria: "Eksportuj lorebook", regularExpression: "Użyj wyrażeń regularnych", + metadataTitle: "Edytuj lorebook", + tagsLabel: "Tagi", + tagsPlaceholder: "np. fantasy, sci-fi itp...", }, templates: { characterNotFound: "Nie znaleziono postaci", @@ -4670,6 +4673,9 @@ export const plMessages: DeepPartialMessageTree = { setAsDefault: "Ustaw jako domyślną", defaultDescription: "Używaj tej persony we wszystkich nowych rozmowach", exportButton: "Eksportuj personę", + tagsLabel: "Tagi", + tagsPlaceholder: "np. fantasy, sci-fi itp...", + tagsHint: "Oddzielaj tagi przecinkami.", }, designReferences: { title: "Design references", @@ -6189,6 +6195,10 @@ export const plMessages: DeepPartialMessageTree = { title: "Brak lorebooków", description: "Lorebooki tworzy się z poziomu ustawień postaci", }, + noTagMatches: { + title: "Brak wyników", + description: "Żaden element nie pasuje do wybranych tagów.", + }, }, actions: { startChat: "Rozpocznij czat", @@ -6309,6 +6319,14 @@ export const plMessages: DeepPartialMessageTree = { exportLorebook: "Export lorebook", }, }, + sort: { + label: "Sortuj", + recent: "Ostatnie", + alphabetical: "A–Z", + }, + tagFilter: { + clear: "Wyczyść tagi", + }, }, onboarding: { loading: "Ładowanie dostawców...", diff --git a/src/core/i18n/locales/pt.ts b/src/core/i18n/locales/pt.ts index 9dd940c12..15c95625e 100644 --- a/src/core/i18n/locales/pt.ts +++ b/src/core/i18n/locales/pt.ts @@ -3151,6 +3151,9 @@ export const ptMessages: DeepPartialMessageTree = { generateEntryAria: "Gerar entrada de lorebook", exportLorebookAria: "Exportar lorebook", regularExpression: "Usar expressões regulares", + metadataTitle: "Editar Livro de Lore", + tagsLabel: "Tags", + tagsPlaceholder: "ex. fantasia, sci-fi, etc...", }, templates: { characterNotFound: "Personagem não encontrado", @@ -4581,6 +4584,9 @@ export const ptMessages: DeepPartialMessageTree = { setAsDefault: "Definir como Padrão", defaultDescription: "Usar esta persona para todas as novas conversas", exportButton: "Exportar Persona", + tagsLabel: "Tags", + tagsPlaceholder: "ex. fantasia, sci-fi, etc...", + tagsHint: "Separe as tags com vírgulas.", }, designReferences: { title: "Design references", @@ -6100,6 +6106,10 @@ export const ptMessages: DeepPartialMessageTree = { title: "Nenhum livro de lore ainda", description: "Livros de lore são criados nas configurações de um personagem", }, + noTagMatches: { + title: "Sem correspondências", + description: "Nenhum item corresponde às tags selecionadas.", + }, }, actions: { startChat: "Iniciar Conversa", @@ -6220,6 +6230,14 @@ export const ptMessages: DeepPartialMessageTree = { exportLorebook: "Export lorebook", }, }, + sort: { + label: "Ordenar", + recent: "Recentes", + alphabetical: "A–Z", + }, + tagFilter: { + clear: "Limpar tags", + }, }, onboarding: { loading: "Carregando provedores...", diff --git a/src/core/i18n/locales/ru.ts b/src/core/i18n/locales/ru.ts index 36bbd7634..61c339921 100644 --- a/src/core/i18n/locales/ru.ts +++ b/src/core/i18n/locales/ru.ts @@ -3151,6 +3151,9 @@ export const ruMessages: DeepPartialMessageTree = { generateEntryAria: "Сгенерировать запись лорбука", exportLorebookAria: "Экспортировать лорбук", regularExpression: "Использовать регулярные выражения", + metadataTitle: "Редактировать лорбук", + tagsLabel: "Теги", + tagsPlaceholder: "напр. фэнтези, фантастика и т. д. ...", }, templates: { characterNotFound: "Персонаж не найден", @@ -5330,6 +5333,9 @@ export const ruMessages: DeepPartialMessageTree = { setAsDefault: "Сделать по умолчанию", defaultDescription: "Использовать эту персону для всех новых разговоров", exportButton: "Экспорт персоны", + tagsLabel: "Теги", + tagsPlaceholder: "напр. фэнтези, фантастика и т. д. ...", + tagsHint: "Разделяйте теги запятыми.", }, designReferences: { title: "Ссылки на дизайн", @@ -6849,6 +6855,10 @@ export const ruMessages: DeepPartialMessageTree = { title: "Лорбуков пока нет", description: "Лорбуки создаются в настройках персонажа", }, + noTagMatches: { + title: "Ничего не найдено", + description: "Нет элементов, соответствующих выбранным тегам.", + }, }, actions: { startChat: "Начать чат", @@ -6969,6 +6979,14 @@ export const ruMessages: DeepPartialMessageTree = { exportLorebook: "Экспортировать книгу истории", }, }, + sort: { + label: "Сортировка", + recent: "Недавние", + alphabetical: "А–Я", + }, + tagFilter: { + clear: "Очистить теги", + }, }, onboarding: { loading: "Загрузка провайдеров...", diff --git a/src/core/i18n/locales/tr.ts b/src/core/i18n/locales/tr.ts index 1e55b28c5..c07aec17d 100644 --- a/src/core/i18n/locales/tr.ts +++ b/src/core/i18n/locales/tr.ts @@ -3151,6 +3151,9 @@ export const trMessages: DeepPartialMessageTree = { generateEntryAria: "Lorebook girişi oluştur", exportLorebookAria: "Lorebook'u dışa aktar", regularExpression: "Düzenli ifadeleri kullan", + metadataTitle: "Lore kitabını düzenle", + tagsLabel: "Etiketler", + tagsPlaceholder: "ör. fantezi, bilim kurgu vb...", }, templates: { characterNotFound: "Karakter bulunamadı", @@ -5331,6 +5334,9 @@ export const trMessages: DeepPartialMessageTree = { setAsDefault: "Varsayılan olarak ayarla", defaultDescription: "Tüm yeni konuşmalar için bu personayı kullan", exportButton: "Personayı dışa aktar", + tagsLabel: "Etiketler", + tagsPlaceholder: "ör. fantezi, bilim kurgu vb...", + tagsHint: "Etiketleri virgülle ayırın.", }, designReferences: { title: "Tasarım referansları", @@ -6850,6 +6856,10 @@ export const trMessages: DeepPartialMessageTree = { title: "Henüz lore kitabı yok", description: "Lore kitapları bir karakterin ayarlarından oluşturulur", }, + noTagMatches: { + title: "Eşleşme yok", + description: "Seçili etiketlerle eşleşen öğe yok.", + }, }, actions: { startChat: "Sohbet başlat", @@ -6970,6 +6980,14 @@ export const trMessages: DeepPartialMessageTree = { exportLorebook: "Bilgi kitabını dışa aktar", }, }, + sort: { + label: "Sırala", + recent: "Son", + alphabetical: "A–Z", + }, + tagFilter: { + clear: "Etiketleri temizle", + }, }, onboarding: { loading: "Sağlayıcılar yükleniyor...", diff --git a/src/core/i18n/locales/vi.ts b/src/core/i18n/locales/vi.ts index 022a561f1..19be1633c 100644 --- a/src/core/i18n/locales/vi.ts +++ b/src/core/i18n/locales/vi.ts @@ -3151,6 +3151,9 @@ export const viMessages: DeepPartialMessageTree = { generateEntryAria: "Tạo mục lorebook", exportLorebookAria: "Xuất lorebook", regularExpression: "Dùng biểu thức chính quy", + metadataTitle: "Chỉnh sửa sách lore", + tagsLabel: "Nhãn", + tagsPlaceholder: "vd. fantasy, sci-fi, v.v...", }, templates: { characterNotFound: "Không tìm thấy nhân vật", @@ -4581,6 +4584,9 @@ export const viMessages: DeepPartialMessageTree = { setAsDefault: "Đặt làm mặc định", defaultDescription: "Sử dụng persona này cho tất cả cuộc trò chuyện mới", exportButton: "Xuất Persona", + tagsLabel: "Nhãn", + tagsPlaceholder: "vd. fantasy, sci-fi, v.v...", + tagsHint: "Phân tách các nhãn bằng dấu phẩy.", }, designReferences: { title: "Design references", @@ -6100,6 +6106,10 @@ export const viMessages: DeepPartialMessageTree = { title: "Chưa có sách lore nào", description: "Sách lore được tạo từ trong cài đặt nhân vật", }, + noTagMatches: { + title: "Không có kết quả", + description: "Không có mục nào khớp với các nhãn đã chọn.", + }, }, actions: { startChat: "Bắt đầu trò chuyện", @@ -6220,6 +6230,14 @@ export const viMessages: DeepPartialMessageTree = { exportLorebook: "Export lorebook", }, }, + sort: { + label: "Sắp xếp", + recent: "Gần đây", + alphabetical: "A–Z", + }, + tagFilter: { + clear: "Xóa nhãn", + }, }, onboarding: { loading: "Đang tải nhà cung cấp...", diff --git a/src/core/i18n/locales/zh-Hans.ts b/src/core/i18n/locales/zh-Hans.ts index 0c43c8117..9b0b0c710 100644 --- a/src/core/i18n/locales/zh-Hans.ts +++ b/src/core/i18n/locales/zh-Hans.ts @@ -3150,6 +3150,9 @@ export const zhHansMessages: DeepPartialMessageTree = { generateEntryAria: "生成世界观条目", exportLorebookAria: "导出世界观书籍", regularExpression: "使用正则表达式", + metadataTitle: "编辑知识库", + tagsLabel: "标签", + tagsPlaceholder: "例如:奇幻、科幻等...", }, templates: { characterNotFound: "找不到角色", @@ -5328,6 +5331,9 @@ export const zhHansMessages: DeepPartialMessageTree = { setAsDefault: "设为默认", defaultDescription: "在所有新对话中使用此人设", exportButton: "导出人设", + tagsLabel: "标签", + tagsPlaceholder: "例如:奇幻、科幻等...", + tagsHint: "使用逗号分隔标签。", }, designReferences: { title: "设计参考", @@ -6847,6 +6853,10 @@ export const zhHansMessages: DeepPartialMessageTree = { title: "还没有知识库", description: "知识库从角色设置中创建", }, + noTagMatches: { + title: "无匹配项", + description: "没有条目与所选标签匹配。", + }, }, actions: { startChat: "开始聊天", @@ -6967,6 +6977,14 @@ export const zhHansMessages: DeepPartialMessageTree = { exportLorebook: "导出知识库", }, }, + sort: { + label: "排序", + recent: "最近", + alphabetical: "字母顺序", + }, + tagFilter: { + clear: "清除标签", + }, }, onboarding: { loading: "加载服务商中...", diff --git a/src/core/i18n/locales/zh-Hant.ts b/src/core/i18n/locales/zh-Hant.ts index d2ac697e1..91d84cccb 100644 --- a/src/core/i18n/locales/zh-Hant.ts +++ b/src/core/i18n/locales/zh-Hant.ts @@ -3150,6 +3150,9 @@ export const zhHantMessages: DeepPartialMessageTree = { generateEntryAria: "生成世界觀條目", exportLorebookAria: "匯出世界觀書籍", regularExpression: "使用正規表示式", + metadataTitle: "編輯設定集", + tagsLabel: "標籤", + tagsPlaceholder: "例如:奇幻、科幻等...", }, templates: { characterNotFound: "找不到角色", @@ -5328,6 +5331,9 @@ export const zhHantMessages: DeepPartialMessageTree = { setAsDefault: "設為預設", defaultDescription: "在所有新對話中使用此人設", exportButton: "匯出人設", + tagsLabel: "標籤", + tagsPlaceholder: "例如:奇幻、科幻等...", + tagsHint: "使用逗號分隔標籤。", }, designReferences: { title: "設計參考", @@ -6847,6 +6853,10 @@ export const zhHantMessages: DeepPartialMessageTree = { title: "尚無設定集", description: "設定集從角色設定中建立", }, + noTagMatches: { + title: "無相符項目", + description: "沒有項目符合所選標籤。", + }, }, actions: { startChat: "開始對話", @@ -6967,6 +6977,14 @@ export const zhHantMessages: DeepPartialMessageTree = { exportLorebook: "匯出設定集", }, }, + sort: { + label: "排序", + recent: "最近", + alphabetical: "字母順序", + }, + tagFilter: { + clear: "清除標籤", + }, }, onboarding: { loading: "載入供應商中...", diff --git a/src/core/storage/personaTransfer.ts b/src/core/storage/personaTransfer.ts index 71fda53a7..6f149f299 100644 --- a/src/core/storage/personaTransfer.ts +++ b/src/core/storage/personaTransfer.ts @@ -20,6 +20,7 @@ export interface PersonaExportData { nickname?: string; avatarCrop?: AvatarCrop; activeLorebookIds?: string[]; + tags?: string[]; } /** diff --git a/src/core/storage/repo.ts b/src/core/storage/repo.ts index 4b1c8b47c..347398546 100644 --- a/src/core/storage/repo.ts +++ b/src/core/storage/repo.ts @@ -1229,6 +1229,7 @@ export async function saveLorebook( keywordDetectionMode: lorebook.keywordDetectionMode ?? "recentMessageWindow", createdAt: lorebook.createdAt ?? timestamp, updatedAt: timestamp, + tags: lorebook.tags ?? [], }; const stored = await storageBridge.lorebookUpsert(entity); @@ -2267,6 +2268,7 @@ export async function savePersona( isDefault: p.isDefault ?? false, createdAt: p.createdAt ?? now(), updatedAt: now(), + tags: p.tags, } as Persona; const saved = await storageBridge.personaUpsert(entity); diff --git a/src/core/storage/schemas.ts b/src/core/storage/schemas.ts index 39c6b1e8c..e09f58843 100644 --- a/src/core/storage/schemas.ts +++ b/src/core/storage/schemas.ts @@ -3442,6 +3442,7 @@ export const LorebookSchema = z.object({ .default("recentMessageWindow"), createdAt: z.number().int(), updatedAt: z.number().int(), + tags: z.array(z.string()).optional(), }); export type Lorebook = z.infer; @@ -4022,6 +4023,7 @@ export const PersonaSchema = z.object({ isDefault: z.boolean().default(false), createdAt: z.number().int(), updatedAt: z.number().int(), + tags: z.array(z.string()).optional(), }); export type Persona = z.infer; diff --git a/src/core/utils/parseCommaSeparated.ts b/src/core/utils/parseCommaSeparated.ts new file mode 100644 index 000000000..c71ccb3e2 --- /dev/null +++ b/src/core/utils/parseCommaSeparated.ts @@ -0,0 +1,7 @@ +/** Split a comma-separated string into trimmed, non-empty tokens. */ +export function parseCommaSeparated(raw: string): string[] { + return raw + .split(",") + .map((item) => item.trim()) + .filter((item) => item.length > 0); +} diff --git a/src/ui/components/LorebookMetadataMenu.tsx b/src/ui/components/LorebookMetadataMenu.tsx index a0c870e9d..1cd780d1f 100644 --- a/src/ui/components/LorebookMetadataMenu.tsx +++ b/src/ui/components/LorebookMetadataMenu.tsx @@ -4,6 +4,7 @@ import type { Lorebook } from "../../core/storage/schemas"; import { useI18n, type TranslationKey } from "../../core/i18n/context"; import { BottomMenu } from "./BottomMenu"; import { LorebookAvatar } from "./LorebookAvatar"; +import { TagsInput } from "./TagsInput"; type LorebookMetadataMenuProps = { isOpen: boolean; @@ -21,6 +22,8 @@ type LorebookMetadataMenuProps = { onChooseFromLibrary?: () => void; keywordDetectionMode: Lorebook["keywordDetectionMode"]; onKeywordDetectionModeChange: (mode: Lorebook["keywordDetectionMode"]) => void; + tagsValue: string; + onTagsChange: (value: string) => void; onSave: () => void; saveDisabled: boolean; }; @@ -61,6 +64,8 @@ export function LorebookMetadataMenu({ onChooseFromLibrary, keywordDetectionMode, onKeywordDetectionModeChange, + tagsValue, + onTagsChange, onSave, saveDisabled, }: LorebookMetadataMenuProps) { @@ -152,6 +157,13 @@ export function LorebookMetadataMenu({ /> + +
-
- -
- onTagsTextChange(e.target.value)} - placeholder={t("characters.extras.tagsPlaceholder")} - className={cn( - "w-full border bg-surface-el/20 px-4 py-3.5 text-fg placeholder-fg/40 backdrop-blur-xl", - radius.md, - typography.body.size, - interactive.transition.default, - "focus:bg-surface-el/30 focus:outline-none", - tagsText.trim() - ? "border-fg/20 focus:border-fg/40" - : "border-fg/10 focus:border-fg/30", - )} - /> - {tagsText.trim() && ( - - - - )} -
-

- {t("characters.extras.tagsDesc")} -

-
+ {/* Creator Notes */} diff --git a/src/ui/pages/characters/hooks/useCharacterForm.ts b/src/ui/pages/characters/hooks/useCharacterForm.ts index 2722e2bc6..a966ca1bf 100644 --- a/src/ui/pages/characters/hooks/useCharacterForm.ts +++ b/src/ui/pages/characters/hooks/useCharacterForm.ts @@ -22,6 +22,7 @@ import type { } from "../../../../core/storage/schemas"; import { listPromptTemplates } from "../../../../core/prompts/service"; import { processBackgroundImage } from "../../../../core/utils/image"; +import { parseCommaSeparated } from "../../../../core/utils/parseCommaSeparated"; import { invalidateAvatarCache } from "../../../hooks/useAvatar"; import { previewCharacterImportFile, @@ -969,11 +970,6 @@ export function useCharacterForm(draftCharacter?: any) { dispatch({ type: "SET_SAVING", payload: true }); dispatch({ type: "SET_ERROR", payload: null }); - const parseCommaSeparated = (raw: string): string[] => - raw - .split(",") - .map((item) => item.trim()) - .filter((item) => item.length > 0); const tags = parseCommaSeparated(state.tagsText); let creatorNotesMultilingual: Record | null | undefined = undefined; if (state.creatorNotesMultilingualText.trim()) { diff --git a/src/ui/pages/characters/hooks/useEditCharacterForm.ts b/src/ui/pages/characters/hooks/useEditCharacterForm.ts index 20db2578a..fb5356b68 100644 --- a/src/ui/pages/characters/hooks/useEditCharacterForm.ts +++ b/src/ui/pages/characters/hooks/useEditCharacterForm.ts @@ -13,6 +13,7 @@ import type { SystemPromptTemplate, } from "../../../../core/storage/schemas"; import { processBackgroundImage } from "../../../../core/utils/image"; +import { parseCommaSeparated } from "../../../../core/utils/parseCommaSeparated"; import { convertToImageRef, convertToImageUrl, deleteImageRef } from "../../../../core/storage/images"; import { AVATAR_BANNER_FILENAME, @@ -496,11 +497,6 @@ export function useEditCharacterForm(characterId: string | undefined) { ? (JSON.parse(initialStateRef.current.designReferenceImageIds) as string[]) : []; - const parseCommaSeparated = (raw: string): string[] => - raw - .split(",") - .map((item) => item.trim()) - .filter((item) => item.length > 0); const tags = parseCommaSeparated(state.tagsText); let creatorNotesMultilingual: Record | null | undefined = undefined; if (state.creatorNotesMultilingualText.trim()) { diff --git a/src/ui/pages/library/LibraryPage.tsx b/src/ui/pages/library/LibraryPage.tsx index 1b5a2e1e8..6aacb2dd4 100644 --- a/src/ui/pages/library/LibraryPage.tsx +++ b/src/ui/pages/library/LibraryPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, memo, useRef } from "react"; +import { useEffect, useMemo, useState, memo, useRef } from "react"; import { motion } from "framer-motion"; import { listCharacters, @@ -42,6 +42,9 @@ import { Paintbrush, Rocket, Settings, + ChevronsUpDown, + Tag, + X, } from "lucide-react"; import { exportCharacterWithFormat, @@ -54,6 +57,21 @@ import { importLorebook, readFileAsText } from "../../../core/storage/lorebookTr import { listen } from "@tauri-apps/api/event"; import { useI18n } from "../../../core/i18n/context"; import { isRenderableImageUrl } from "../../../core/utils/image"; +import { + type SortOption, + type EntityTab, + type Facet, + sortItems, + getItemName, + indexItems, + scopeByTab, + filterByName, + filterByTags, + deriveFacets, + isTagRowVisible, + tagControlsVisibility, + pruneSelectedTags, +} from "./libraryPipeline"; type FilterOption = "All" | "Characters" | "Personas" | "Lorebooks" | "Images" | "Audio"; type LibraryItem = (Character | Persona | Lorebook) & { @@ -78,10 +96,9 @@ function resolveLibraryFilter(search: string): FilterOption { return matched?.[0] ?? "All"; } -function getItemName(item: LibraryItem): string { - if (item.itemType === "character") return (item as Character).name; - if (item.itemType === "persona") return (item as Persona).title; - return (item as Lorebook).name; +function resolveLibrarySort(search: string): SortOption { + const value = new URLSearchParams(search).get("sort"); + return value === "alphabetical" ? "Alphabetical" : "Recent"; } function getItemDisableGradient(item: LibraryItem): boolean | undefined { @@ -97,6 +114,8 @@ export function LibraryPage() { const [loading, setLoading] = useState(() => !libraryPageCache); const [filter, setFilter] = useState(() => resolveLibraryFilter(location.search)); const [query, setQuery] = useState(""); + const [sort, setSort] = useState(() => resolveLibrarySort(location.search)); + const [selectedTags, setSelectedTags] = useState([]); const inlineHeader = useInlineHeader(); const railSettings = useRailSettings(); const [selectedItem, setSelectedItem] = useState(null); @@ -131,6 +150,8 @@ export function LibraryPage() { useEffect(() => { const next = resolveLibraryFilter(location.search); setFilter((current) => (current === next ? current : next)); + const nextSort = resolveLibrarySort(location.search); + setSort((current) => (current === nextSort ? current : nextSort)); }, [location.search]); const loadData = async () => { @@ -177,6 +198,7 @@ export function LibraryPage() { id: renameItem.id, name: renameName.trim(), avatarPath: renameItem.avatarPath, + tags: renameItem.tags, }); } setRenameItem(null); @@ -313,16 +335,53 @@ export function LibraryPage() { ...lorebooks.map((l) => ({ ...l, itemType: "lorebook" as const })), ]; - const needle = query.trim().toLowerCase(); - const filteredItems = allItems.filter((item) => { - if (needle && !getItemName(item).toLowerCase().includes(needle)) return false; - if (filter === "All") return true; - if (filter === "Characters") return item.itemType === "character"; - if (filter === "Personas") return item.itemType === "persona"; - if (filter === "Lorebooks") return item.itemType === "lorebook"; - return false; + const entityTab: EntityTab = + filter === "Characters" || filter === "Personas" || filter === "Lorebooks" ? filter : "All"; + + // eslint-disable-next-line react-hooks/exhaustive-deps -- allItems derives from these three arrays; depping it would rebuild every render + const indexed = useMemo(() => indexItems(allItems), [characters, personas, lorebooks]); + const scoped = useMemo(() => scopeByTab(indexed, entityTab), [indexed, entityTab]); + // Available keys come from the tab scope, not the search, so typing never prunes a selection. + const availableKeys = useMemo( + () => new Set(deriveFacets(scoped, scoped).map((f) => f.key)), + [scoped], + ); + const searched = useMemo(() => filterByName(scoped, query), [scoped, query]); + const filtered = useMemo( + () => filterByTags(searched, isTagRowVisible(entityTab) ? selectedTags : []), + [searched, entityTab, selectedTags], + ); + const facets = useMemo( + () => deriveFacets(scoped, filtered, selectedTags), + [scoped, filtered, selectedTags], + ); + const displayItems = useMemo( + () => sortItems(filtered.map((i) => i.item), sort), + [filtered, sort], + ); + // Images/Audio delegate to their own panels — never show entity tag controls there. + const { chips: tagChipsVisible, clear: clearTagsVisible } = tagControlsVisibility({ + scopeSupportsTags: filter !== "Images" && filter !== "Audio" && isTagRowVisible(entityTab), + facetCount: facets.length, + selectionCount: selectedTags.length, }); + useEffect(() => { + setSelectedTags((current) => { + const pruned = pruneSelectedTags(current, availableKeys); + return pruned.length === current.length ? current : pruned; + }); + }, [availableKeys]); + + const setLibrarySort = (next: SortOption) => { + setSort(next); + const params = new URLSearchParams(location.search); + if (next === "Alphabetical") params.set("sort", "alphabetical"); + else params.delete("sort"); + const search = params.toString(); + navigate({ pathname: "/library", search: search ? `?${search}` : "" }, { replace: true }); + }; + const setLibraryFilter = (next: FilterOption) => { setFilter(next); const params = new URLSearchParams(location.search); @@ -404,6 +463,55 @@ export function LibraryPage() {
)} + {filter !== "Images" && filter !== "Audio" && ( +
+ {tagChipsVisible && + facets.map((facet) => { + const active = selectedTags.includes(facet.key); + return ( + + ); + })} + {clearTagsVisible && ( + + )} + +
+ )} + {filter === "Images" ? ( ) : loading ? ( - ) : filteredItems.length === 0 ? ( + ) : clearTagsVisible && !query.trim() && displayItems.length === 0 ? ( +
+

+ {t("library.emptyStates.noTagMatches.title")} +

+

+ {t("library.emptyStates.noTagMatches.description")} +

+ +
+ ) : displayItems.length === 0 ? ( ) : (
- {filteredItems.map((item) => ( + {displayItems.map((item) => ( (null); const [keywordDetectionModeDraft, setKeywordDetectionModeDraft] = useState("recentMessageWindow"); + const [tagsDraft, setTagsDraft] = useState(""); const [isCreatingEntry, setIsCreatingEntry] = useState(false); const [isExporting, setIsExporting] = useState(false); const [showExportMenu, setShowExportMenu] = useState(false); @@ -656,6 +658,7 @@ export function StandaloneLorebookEditor() { name: newName.trim(), avatarPath: nextAvatarPath, keywordDetectionMode: keywordDetectionModeDraft, + tags: parseCommaSeparated(tagsDraft), }; const saved = await saveLorebook(updated); if (lorebook.avatarPath && lorebook.avatarPath !== saved.avatarPath) { @@ -666,6 +669,7 @@ export function StandaloneLorebookEditor() { setNewName(""); setAvatarDraftPath(saved.avatarPath ?? null); setKeywordDetectionModeDraft(saved.keywordDetectionMode); + setTagsDraft((saved.tags ?? []).join(", ")); } catch (error) { if (replacementAvatarPath) { await deleteImageRef(replacementAvatarPath); @@ -693,6 +697,7 @@ export function StandaloneLorebookEditor() { nameDraft: newName, keywordMode: keywordDetectionModeDraft, avatarDraft: avatarDraftPath, + tagsDraft, }; sessionStorage.setItem( `lorebook-metadata-draft:${returnPath}`, @@ -729,6 +734,7 @@ export function StandaloneLorebookEditor() { nameDraft?: string; keywordMode?: Lorebook["keywordDetectionMode"]; avatarDraft?: string | null; + tagsDraft?: string; } | null = null; if (rawDraft) { @@ -744,6 +750,7 @@ export function StandaloneLorebookEditor() { if (draft && draft.lorebookId === lorebook.id) { if (typeof draft.nameDraft === "string") setNewName(draft.nameDraft); if (draft.keywordMode) setKeywordDetectionModeDraft(draft.keywordMode); + if (typeof draft.tagsDraft === "string") setTagsDraft(draft.tagsDraft); } setAvatarDraftPath(dataUrl); setShowRenameMenu(true); @@ -859,6 +866,7 @@ export function StandaloneLorebookEditor() { setNewName(lorebook.name); setAvatarDraftPath(lorebook.avatarPath ?? null); setKeywordDetectionModeDraft(lorebook.keywordDetectionMode); + setTagsDraft((lorebook.tags ?? []).join(", ")); setShowRenameMenu(true); }} className="flex items-center px-[0.6em] py-[0.3em] justify-center rounded-full text-fg/70 hover:text-fg hover:bg-fg/10 transition" @@ -1047,8 +1055,9 @@ export function StandaloneLorebookEditor() { setNewName(""); setAvatarDraftPath(lorebook.avatarPath ?? null); setKeywordDetectionModeDraft(lorebook.keywordDetectionMode); + setTagsDraft(""); }} - title={t("library.actions.renameLorebook")} + title={t("characters.lorebook.metadataTitle")} nameValue={newName} previewName={newName.trim() || lorebook.name} namePlaceholder={t("characters.lorebook.enterNamePlaceholder")} @@ -1061,12 +1070,15 @@ export function StandaloneLorebookEditor() { onChooseFromLibrary={handleChooseAvatarFromLibrary} keywordDetectionMode={keywordDetectionModeDraft} onKeywordDetectionModeChange={setKeywordDetectionModeDraft} + tagsValue={tagsDraft} + onTagsChange={setTagsDraft} onSave={handleRename} saveDisabled={ !newName.trim() || (newName.trim() === lorebook.name && (avatarDraftPath ?? "") === (lorebook.avatarPath ?? "") && - keywordDetectionModeDraft === lorebook.keywordDetectionMode) + keywordDetectionModeDraft === lorebook.keywordDetectionMode && + parseCommaSeparated(tagsDraft).join(",") === (lorebook.tags ?? []).join(",")) } />
diff --git a/src/ui/pages/library/libraryPipeline.test.ts b/src/ui/pages/library/libraryPipeline.test.ts new file mode 100644 index 000000000..4f07f8645 --- /dev/null +++ b/src/ui/pages/library/libraryPipeline.test.ts @@ -0,0 +1,297 @@ +import { describe, it, expect } from "vitest"; +import { + tagKey, + getItemName, + getItemTags, + indexItems, + TAGGABLE_TYPES, + scopeByTab, + filterByName, + filterByTags, + scopeTypes, + isTagRowVisible, + tagControlsVisibility, + pruneSelectedTags, + deriveFacets, + sortItems, + type LibraryEntry, + type Facet, +} from "./libraryPipeline"; + +const char = (over: Partial = {}): LibraryEntry => ({ + id: "c1", + itemType: "character", + createdAt: 1, + name: "Aria", + ...over, +}); +const persona = (over: Partial = {}): LibraryEntry => ({ + id: "p1", + itemType: "persona", + createdAt: 1, + title: "Jen", + ...over, +}); + +describe("tagKey", () => { + it("lowercases and strips whitespace, hyphen, underscore", () => { + expect(tagKey("Sci-Fi")).toBe("scifi"); + expect(tagKey("sci fi")).toBe("scifi"); + expect(tagKey("SciFi")).toBe("scifi"); + expect(tagKey("sci_fi")).toBe("scifi"); + }); + it("returns empty for punctuation-only tags", () => { + expect(tagKey("-")).toBe(""); + expect(tagKey("___")).toBe(""); + expect(tagKey(" ")).toBe(""); + }); + it("preserves meaningful punctuation and non-Latin text", () => { + expect(tagKey("18+")).toBe("18+"); + expect(tagKey("日本語")).toBe("日本語"); + }); +}); + +describe("getItemName", () => { + it("reads name for characters and lorebooks, title for personas", () => { + expect(getItemName(char({ name: "Aria" }))).toBe("Aria"); + expect(getItemName(persona({ title: "Jen" }))).toBe("Jen"); + expect(getItemName({ id: "l1", itemType: "lorebook", createdAt: 1, name: "Lore" })).toBe("Lore"); + }); +}); + +describe("getItemTags", () => { + it("returns tags for taggable types", () => { + expect(getItemTags(char({ tags: ["Fluff"] }))).toEqual(["Fluff"]); + expect(getItemTags(persona({ tags: ["Fluff"] }))).toEqual(["Fluff"]); + }); + it("all entity types are taggable", () => { + expect(TAGGABLE_TYPES.has("character")).toBe(true); + expect(TAGGABLE_TYPES.has("persona")).toBe(true); + expect(TAGGABLE_TYPES.has("lorebook")).toBe(true); + }); +}); + +describe("indexItems", () => { + it("builds a normalized, deduped tag-key set per item", () => { + const [idx] = indexItems([char({ tags: ["Sci-Fi", "SciFi", "Fluff"] })]); + expect([...idx.tagKeys].sort()).toEqual(["fluff", "scifi"]); + }); + it("drops empty keys and untagged items get an empty set", () => { + expect(indexItems([char({ tags: ["-", " "] })])[0].tagKeys.size).toBe(0); + expect(indexItems([persona({ tags: undefined })])[0].tagKeys.size).toBe(0); + expect(indexItems([char({ tags: undefined })])[0].tagKeys.size).toBe(0); + }); +}); + +describe("scopeByTab", () => { + const items = indexItems([ + char({ id: "c1" }), + persona({ id: "p1" }), + { id: "l1", itemType: "lorebook", createdAt: 1, name: "Lore" }, + ]); + it("returns everything for All", () => { + expect(scopeByTab(items, "All").map((i) => i.item.id)).toEqual(["c1", "p1", "l1"]); + }); + it("filters to one type otherwise", () => { + expect(scopeByTab(items, "Personas").map((i) => i.item.id)).toEqual(["p1"]); + }); +}); + +describe("filterByTags (AND)", () => { + const scoped = indexItems([ + char({ id: "a", tags: ["Fluff", "Sci-Fi"] }), + char({ id: "b", tags: ["Fluff"] }), + char({ id: "c", tags: [] }), + ]); + it("no selection returns all", () => { + expect(filterByTags(scoped, []).map((i) => i.item.id)).toEqual(["a", "b", "c"]); + }); + it("narrows with each key (AND)", () => { + expect(filterByTags(scoped, ["fluff"]).map((i) => i.item.id)).toEqual(["a", "b"]); + expect(filterByTags(scoped, ["fluff", "scifi"]).map((i) => i.item.id)).toEqual(["a"]); + }); + it("excludes untagged items once any tag is selected", () => { + expect(filterByTags(scoped, ["fluff"]).some((i) => i.item.id === "c")).toBe(false); + }); +}); + +describe("filterByName", () => { + const scoped = indexItems([ + char({ id: "a", name: "Aria" }), + persona({ id: "p", title: "Marina" }), + { id: "l", itemType: "lorebook", createdAt: 1, name: "Lore" }, + ]); + it("blank or whitespace-only query returns all", () => { + expect(filterByName(scoped, "").map((i) => i.item.id)).toEqual(["a", "p", "l"]); + expect(filterByName(scoped, " ").map((i) => i.item.id)).toEqual(["a", "p", "l"]); + }); + it("matches case-insensitive substrings across name and title", () => { + expect(filterByName(scoped, "ri").map((i) => i.item.id)).toEqual(["a", "p"]); + expect(filterByName(scoped, "ARIA").map((i) => i.item.id)).toEqual(["a"]); + expect(filterByName(scoped, " lore ").map((i) => i.item.id)).toEqual(["l"]); + }); + it("returns empty when nothing matches", () => { + expect(filterByName(scoped, "zzz")).toEqual([]); + }); +}); + +// Regression: a selection surviving a tab switch can AND to zero. Facet counts derive from +// the filtered set, so an unguarded chip list empties too — and the clear affordance must +// not depend on it. +describe("zero-result selection after a tab switch", () => { + const indexed = indexItems([ + char({ id: "c1", tags: ["Sci-Fi", "Fluff"] }), + persona({ id: "p1", tags: ["Sci-Fi"] }), + persona({ id: "p2", tags: ["Fluff"] }), + ]); + const personaScope = scopeByTab(indexed, "Personas"); + + it("pruning keeps both keys because each exists in the new scope", () => { + const availableKeys = new Set(deriveFacets(personaScope, personaScope).map((f) => f.key)); + expect(pruneSelectedTags(["scifi", "fluff"], availableKeys)).toEqual(["scifi", "fluff"]); + }); + + it("but no persona carries both, so the result set is empty", () => { + expect(filterByTags(personaScope, ["scifi", "fluff"])).toEqual([]); + }); + + it("keeps the selected tags as zero-count chips so each stays deselectable", () => { + const filtered = filterByTags(personaScope, ["scifi", "fluff"]); + const facets = deriveFacets(personaScope, filtered, ["scifi", "fluff"]); + expect(facets.map((f) => [f.key, f.count])).toEqual([ + ["fluff", 0], + ["scifi", 0], + ]); + expect(facets.map((f) => f.display)).toEqual(["Fluff", "Sci-Fi"]); + }); + + it("still drops the chip list entirely when nothing is selected", () => { + expect(deriveFacets(personaScope, [], [])).toEqual([]); + }); + + it("does not inflate availableKeys, so pruning can still prune", () => { + const charScope = scopeByTab(indexed, "Characters"); + const availableKeys = new Set(deriveFacets(charScope, charScope).map((f) => f.key)); + expect(pruneSelectedTags(["scifi", "nonexistent"], availableKeys)).toEqual(["scifi"]); + }); +}); + +describe("tagControlsVisibility", () => { + it("hides both when the tab delegates to its own panel", () => { + expect( + tagControlsVisibility({ scopeSupportsTags: false, facetCount: 3, selectionCount: 2 }), + ).toEqual({ chips: false, clear: false }); + }); + + it("shows chips only when facets exist", () => { + expect( + tagControlsVisibility({ scopeSupportsTags: true, facetCount: 0, selectionCount: 0 }).chips, + ).toBe(false); + expect( + tagControlsVisibility({ scopeSupportsTags: true, facetCount: 1, selectionCount: 0 }).chips, + ).toBe(true); + }); + + // The bug: clear used to be gated on facetCount too, so it vanished exactly when needed. + it("keeps clear available with a selection even when no facets remain", () => { + expect( + tagControlsVisibility({ scopeSupportsTags: true, facetCount: 0, selectionCount: 2 }).clear, + ).toBe(true); + }); + + it("hides clear when nothing is selected", () => { + expect( + tagControlsVisibility({ scopeSupportsTags: true, facetCount: 5, selectionCount: 0 }).clear, + ).toBe(false); + }); +}); + +describe("scopeTypes / isTagRowVisible", () => { + it("All spans all three types; entity tabs span one", () => { + expect(scopeTypes("All").sort()).toEqual(["character", "lorebook", "persona"]); + expect(scopeTypes("Characters")).toEqual(["character"]); + }); + it("tag row shows on every entity tab now that all types are taggable", () => { + expect(isTagRowVisible("Characters")).toBe(true); + expect(isTagRowVisible("All")).toBe(true); + expect(isTagRowVisible("Personas")).toBe(true); + expect(isTagRowVisible("Lorebooks")).toBe(true); + }); +}); + +describe("pruneSelectedTags", () => { + it("keeps only keys present in the new scope", () => { + expect(pruneSelectedTags(["fluff", "scifi"], new Set(["fluff"]))).toEqual(["fluff"]); + expect(pruneSelectedTags(["fluff"], new Set())).toEqual([]); + }); +}); + +const keys = (f: Facet[]) => f.map((x) => x.key); + +describe("deriveFacets", () => { + it("counts distinct items in filtered, one per item even with dup keys", () => { + const scoped = indexItems([ + char({ id: "a", tags: ["Fluff", "Sci-Fi", "SciFi"] }), + char({ id: "b", tags: ["Fluff"] }), + ]); + const facets = deriveFacets(scoped, scoped); + expect(facets.find((f) => f.key === "scifi")?.count).toBe(1); // one item, dup keys counted once + expect(facets.find((f) => f.key === "fluff")?.count).toBe(2); + }); + it("display casing is the last raw spelling seen in scoped order", () => { + const scoped = indexItems([ + char({ id: "a", tags: ["Sci-Fi"] }), + char({ id: "b", tags: ["SciFi"] }), + ]); + expect(deriveFacets(scoped, scoped).find((f) => f.key === "scifi")?.display).toBe("SciFi"); + }); + it("returns facets sorted alphabetically by display", () => { + const scoped = indexItems([char({ tags: ["Zeta", "alpha", "Mid"] })]); + expect(keys(deriveFacets(scoped, scoped))).toEqual(["alpha", "mid", "zeta"]); + }); + it("counts come from filtered; a tag with zero filtered co-occurrence is absent", () => { + const scoped = indexItems([ + char({ id: "a", tags: ["Fluff", "Sci-Fi"] }), + char({ id: "b", tags: ["Smut"] }), + ]); + const filtered = filterByTags(scoped, ["fluff"]); // only item a + const facets = deriveFacets(scoped, filtered); + expect(facets.find((f) => f.key === "fluff")?.count).toBe(1); + expect(facets.find((f) => f.key === "scifi")?.count).toBe(1); + expect(facets.find((f) => f.key === "smut")).toBeUndefined(); // absent — dead-end + }); + it("display casing stays stable even when filtered shrinks (derived from scoped)", () => { + const scoped = indexItems([ + char({ id: "a", tags: ["Sci-Fi", "Fluff"] }), + char({ id: "b", tags: ["SciFi"] }), + ]); + const filtered = filterByTags(scoped, ["fluff"]); // only item a, whose spelling is "Sci-Fi" + // display still last-in-scoped ("SciFi"), not filtered's "Sci-Fi" + expect(deriveFacets(scoped, filtered).find((f) => f.key === "scifi")?.display).toBe("SciFi"); + }); +}); + +describe("sortItems", () => { + it("Recent = createdAt desc, id tie-break for stability", () => { + const items = [ + char({ id: "a", createdAt: 10 }), + char({ id: "c", createdAt: 20 }), + char({ id: "b", createdAt: 20 }), + ]; + expect(sortItems(items, "Recent").map((i) => i.id)).toEqual(["b", "c", "a"]); + }); + it("Alphabetical uses localeCompare across name/title, accents included", () => { + const items = [ + char({ id: "1", name: "Zoe" }), + persona({ id: "2", title: "ábel" }), + char({ id: "3", name: "Mia" }), + ]; + expect(sortItems(items, "Alphabetical").map((i) => i.id)).toEqual(["2", "3", "1"]); + }); + it("does not mutate the input array", () => { + const items = [char({ id: "a", createdAt: 1 }), char({ id: "b", createdAt: 2 })]; + const before = items.map((i) => i.id); + sortItems(items, "Recent"); + expect(items.map((i) => i.id)).toEqual(before); + }); +}); diff --git a/src/ui/pages/library/libraryPipeline.ts b/src/ui/pages/library/libraryPipeline.ts new file mode 100644 index 000000000..deb8a94ce --- /dev/null +++ b/src/ui/pages/library/libraryPipeline.ts @@ -0,0 +1,167 @@ +export type ItemType = "character" | "persona" | "lorebook"; + +export type EntityTab = "All" | "Characters" | "Personas" | "Lorebooks"; + +/** Structural supertype of LibraryPage's LibraryItem — only what the pipeline needs. */ +export type LibraryEntry = { + id: string; + itemType: ItemType; + createdAt: number; + name?: string; + title?: string; + tags?: string[]; +}; + +/** Which item types expose tags. */ +export const TAGGABLE_TYPES: ReadonlySet = new Set([ + "character", + "persona", + "lorebook", +]); + +/** Index-only normalization: lowercase, strip whitespace/hyphen/underscore. Never persisted. */ +export function tagKey(raw: string): string { + return raw.toLowerCase().replace(/[\s_-]+/g, ""); +} + +export function getItemName(item: LibraryEntry): string { + if (item.itemType === "persona") return item.title ?? ""; + return item.name ?? ""; +} + +export function getItemTags(item: LibraryEntry): string[] | undefined { + return TAGGABLE_TYPES.has(item.itemType) ? item.tags : undefined; +} + +export type IndexedItem = { item: T; tagKeys: Set }; + +/** Precompute each item's normalized tag-key set once (call when the data changes, not per interaction). */ +export function indexItems(items: T[]): IndexedItem[] { + return items.map((item) => { + const tagKeys = new Set(); + for (const raw of getItemTags(item) ?? []) { + const key = tagKey(raw); + if (key) tagKeys.add(key); + } + return { item, tagKeys }; + }); +} + +export function scopeTypes(tab: EntityTab): ItemType[] { + if (tab === "Characters") return ["character"]; + if (tab === "Personas") return ["persona"]; + if (tab === "Lorebooks") return ["lorebook"]; + return ["character", "persona", "lorebook"]; +} + +export function scopeByTab( + indexed: IndexedItem[], + tab: EntityTab, +): IndexedItem[] { + if (tab === "All") return indexed; + const types = new Set(scopeTypes(tab)); + return indexed.filter(({ item }) => types.has(item.itemType)); +} + +export function filterByTags( + scoped: IndexedItem[], + selectedKeys: string[], +): IndexedItem[] { + if (selectedKeys.length === 0) return scoped; + return scoped.filter(({ tagKeys }) => selectedKeys.every((k) => tagKeys.has(k))); +} + +/** Case-insensitive substring match on the item's display name. Blank query is a no-op. */ +export function filterByName( + scoped: IndexedItem[], + query: string, +): IndexedItem[] { + const needle = query.trim().toLowerCase(); + if (!needle) return scoped; + return scoped.filter(({ item }) => getItemName(item).toLowerCase().includes(needle)); +} + +/** Show the tag row only when every type in the tab's scope is taggable. */ +export function isTagRowVisible(tab: EntityTab): boolean { + return scopeTypes(tab).every((type) => TAGGABLE_TYPES.has(type)); +} + +export type TagControlsVisibility = { chips: boolean; clear: boolean }; + +/** + * Chips need facets to render; the clear affordance must not depend on them. A selection + * that matches nothing is exactly when the user needs a way out, and gating both on the + * facet count hid the escape hatch in the only case that needed it. + */ +export function tagControlsVisibility(input: { + /** False for tabs that delegate to their own panel (images, audio). */ + scopeSupportsTags: boolean; + facetCount: number; + selectionCount: number; +}): TagControlsVisibility { + return { + chips: input.scopeSupportsTags && input.facetCount > 0, + clear: input.scopeSupportsTags && input.selectionCount > 0, + }; +} + +/** Drop selected keys that no longer exist in the new scope's available keys. */ +export function pruneSelectedTags(selectedKeys: string[], availableKeys: Set): string[] { + return selectedKeys.filter((k) => availableKeys.has(k)); +} + +export type Facet = { key: string; display: string; count: number }; + +/** + * Build the chip list. Display casing comes from `scoped` (unfiltered, load order, + * last-raw-spelling-wins) so labels don't drift as the selection narrows; counts come + * from `filtered` (distinct items), so a chip's number is "results if you add this" and + * zero-co-occurrence tags simply don't appear. Sorted alphabetically by display. + * + * `selectedKeys` are always kept, at count 0 if they match nothing. Without that, a + * selection narrowing to zero empties `filtered` and so empties the chip list, leaving + * no way to deselect one tag — clear-everything would be the only way out. + */ +export function deriveFacets( + scoped: IndexedItem[], + filtered: IndexedItem[], + selectedKeys: readonly string[] = [], +): Facet[] { + // Display casing: last raw spelling per key, in scoped load order. + const display = new Map(); + for (const { item } of scoped) { + for (const raw of getItemTags(item) ?? []) { + const key = tagKey(raw); + if (key) display.set(key, raw); + } + } + // Counts: distinct filtered items per key (dedup keys within an item via its tagKeys set). + const counts = new Map(); + for (const { tagKeys } of filtered) { + for (const key of tagKeys) counts.set(key, (counts.get(key) ?? 0) + 1); + } + for (const key of selectedKeys) { + if (!counts.has(key)) counts.set(key, 0); + } + const facets: Facet[] = []; + for (const [key, count] of counts) { + facets.push({ key, display: display.get(key) ?? key, count }); + } + facets.sort((a, b) => a.display.localeCompare(b.display)); + return facets; +} + +export type SortOption = "Recent" | "Alphabetical"; + +export const SORT_OPTIONS: SortOption[] = ["Recent", "Alphabetical"]; + +export function sortItems(items: T[], sort: SortOption): T[] { + const next = [...items]; + if (sort === "Alphabetical") { + return next.sort( + (a, b) => getItemName(a).localeCompare(getItemName(b)) || a.id.localeCompare(b.id), + ); + } + // Recent: newest createdAt first, id tie-break for a stable order. + return next.sort((a, b) => b.createdAt - a.createdAt || a.id.localeCompare(b.id)); +} diff --git a/src/ui/pages/personas/EditPersona.tsx b/src/ui/pages/personas/EditPersona.tsx index 9328eadec..39066d004 100644 --- a/src/ui/pages/personas/EditPersona.tsx +++ b/src/ui/pages/personas/EditPersona.tsx @@ -10,6 +10,7 @@ import { } from "../../../core/storage/personaTransfer"; import { AvatarPicker } from "../../components/AvatarPicker"; import { Switch } from "../../components/Switch"; +import { TagsInput } from "../../components/TagsInput"; import { DesignReferenceEditor } from "../../components/DesignReferenceEditor"; import { LoraSelector } from "../../components/LoraSelector"; import { ActiveLorebooksSelector } from "../characters/components/ActiveLorebooksSelector"; @@ -42,6 +43,7 @@ export function EditPersonaPage() { loraName, loraStrength, activeLorebookIds, + tags, }, setTitle, setDescription, @@ -54,6 +56,7 @@ export function EditPersonaPage() { setDesignReferenceImageIds, setLora, setActiveLorebookIds, + setTags, handleSave, resetToInitial, canSave, @@ -236,6 +239,14 @@ export function EditPersonaPage() {

+ +
+ 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"], + }, +});