diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0bf4abd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,76 @@ +# Stage 1: Build Client +FROM node:24-alpine AS client-builder + +RUN corepack enable && corepack prepare yarn@4.12.0 --activate + +WORKDIR /app + +COPY SillyInnkeeper/package.json ./ +COPY SillyInnkeeper/client ./client + +WORKDIR /app/client + +RUN yarn install --immutable +RUN yarn build + +# Stage 2: Build Server +FROM node:24-alpine AS server-builder + +RUN corepack enable && corepack prepare yarn@4.12.0 --activate + +# Install build dependencies for native modules +RUN apk add --no-cache python3 make g++ + +WORKDIR /app/server + +COPY SillyInnkeeper/server ./ + +RUN yarn install +RUN yarn build + +# Stage 3: Production Runtime +FROM node:24-alpine + +# Install runtime dependencies +RUN apk add --no-cache \ + shadow \ + python3 \ + make \ + g++ \ + su-exec + +RUN corepack enable && corepack prepare yarn@4.12.0 --activate + +WORKDIR /app + +# Copy server package files and install production dependencies +COPY SillyInnkeeper/server/package.json SillyInnkeeper/server/yarn.lock ./ +RUN NODE_ENV=production yarn install + +# Copy built artifacts +COPY --from=server-builder /app/server/dist ./server/dist +COPY --from=client-builder /app/client/dist ./client/dist + +# Create data directory +RUN mkdir -p /app/data && chown node:node /app/data + +# Environment variables +ENV NODE_ENV=production \ + INNKEEPER_HOST=0.0.0.0 \ + INNKEEPER_PORT=48912 + +EXPOSE 48912 + +# Create entrypoint script +RUN echo '#!/bin/sh' > /entrypoint.sh && \ + echo 'PUID=${PUID:-1000}' >> /entrypoint.sh && \ + echo 'PGID=${PGID:-1000}' >> /entrypoint.sh && \ + echo 'echo "Running as UID:GID $PUID:$PGID"' >> /entrypoint.sh && \ + echo 'groupmod -o -g "$PGID" node' >> /entrypoint.sh && \ + echo 'usermod -o -u "$PUID" node' >> /entrypoint.sh && \ + echo 'chown -R node:node /app/data' >> /entrypoint.sh && \ + echo 'exec su-exec node node server/dist/server.js' >> /entrypoint.sh && \ + chmod +x /entrypoint.sh + +ENTRYPOINT ["/entrypoint.sh"] + diff --git a/README.md b/README.md index 4aaaaec..2cb7799 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ > 🔗 **SillyTavern Extension**: [ST-Extension-SillyInnkeeper](https://github.com/dmitryplyaskin/ST-Extension-SillyInnkeeper) — Install this extension in SillyTavern to integrate with SillyInnkeeper. +> 📢 **Telegram Channel**: [@SillyInnkeeper](https://t.me/SillyInnkeeper) — news, updates, and project discussions. + [![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](https://opensource.org/licenses/AGPL-3.0) ![Main img](assets/main.webp) @@ -24,21 +26,57 @@ SillyInnkeeper solves all these problems by providing a fast and convenient way ## ✨ Key Features -### Card Library Management +### Full SillyTavern Integration -- **Automatic scanning**: Just specify the folder with cards — the application will find and index all PNG files automatically -- **Automatic updates**: When new cards are added or existing ones are changed, the application will automatically update the information -- **Duplicate management**: Automatic detection of identical cards and convenient management of them +- **View and edit cards**: Full access to character card content from SillyTavern +- **Chat management**: View information about chats associated with cards, filter by user profiles +- **Data synchronization**: Automatic synchronization of metadata between SillyInnkeeper and SillyTavern +- **One-click launch**: The "Play" button instantly imports the card into SillyTavern +- **Automatic import**: The SillyTavern extension automatically receives cards from SillyInnkeeper ### Powerful Search and Filtering - **Search by name**: Quick search for cards by character name -- **Filter by creator**: Find all cards from a specific author -- **Filter by tags**: Select multiple tags for precise search -- **Filter by date**: Find cards created in a specific period -- **Filter by content**: Find cards with specific fields (e.g., only with system prompt or with alternate greetings) -- **Filter by size**: Search by approximate number of prompt tokens -- **Flexible sorting**: Sort by creation date or by name +- **Text search**: Full-text search through card content with two modes: + - "LIKE" mode — simple substring search + - "FTS" mode — full-text search with relevance ranking + - Search fields: description, personality, scenario, first message, message examples, creator notes, system prompts, post history instructions, alternate greetings, group-only greetings +- **Metadata filtering**: + - By creator (single or multiple) + - By specification version (V1/V2/V3) + - By tags (multiple selection) + - By patterns (customizable rules) +- **Date filtering**: Date range for card creation +- **Size filtering**: Search by approximate number of prompt tokens (min/max) +- **Field presence filtering**: Find cards with or without specific fields: + - Creator notes + - System prompts + - Post history instructions + - Personality + - Scenario + - Message examples + - Character Book (lorebook) +- **Alternate greetings filtering**: Presence and minimum count +- **Source filtering**: Cards from SillyTavern, from folder, or all +- **Status filtering**: Hidden, favorite cards +- **SillyTavern chats filtering**: + - Chat count (equal/greater than/less than) + - User profiles + - Hide cards without chats +- **Flexible sorting**: + - By creation date (newest/oldest first) + - By name (A-Z / Z-A) + - By prompt tokens count (descending/ascending) + - By SillyTavern chats count (descending/ascending) + - By last chat date (newest/oldest first) + - By first chat date (newest/oldest first) + - By relevance (when using full-text search) + +### Card Library Management + +- **Automatic scanning**: Just specify the folder with cards — the application will find and index all PNG files automatically +- **Automatic updates**: When new cards are added or existing ones are changed, the application will automatically update the information +- **Duplicate management**: Automatic detection of identical cards and convenient management of them ### Convenient Card Viewing @@ -50,11 +88,6 @@ SillyInnkeeper solves all these problems by providing a fast and convenient way - Raw JSON (for advanced users) - **Image viewing**: Zoom card images with optional blur (censorship) - **Metadata**: View ID, specification version, creation date, and other useful information - -### SillyTavern Integration - -- **One-click launch**: The "Play" button instantly imports the card into SillyTavern -- **Automatic import**: The SillyTavern extension automatically receives cards from SillyInnkeeper - **Card export**: Download PNG files with correct metadata for use in other applications ### User Experience @@ -73,8 +106,6 @@ SillyInnkeeper solves all these problems by providing a fast and convenient way - **Node.js**: version 18.x or higher (recommended 20.x or 24.x) - **Yarn**: version 4.12.0 (or npm 9.x+) - **Operating system**: Windows 10/11, Linux, macOS -- **RAM**: minimum 2 GB (recommended 4 GB+ for large collections) -- **Free disk space**: minimum 500 MB for installation + space for database and cache ## 📦 Installation @@ -175,8 +206,6 @@ You can configure **host/port** and SillyTavern integration via a root `.env` fi - `ST_PORT` — SillyTavern port for default CORS allowlist (default `8000`) - `CORS_ALLOW_ORIGINS` — extra allowed origins (comma-separated), e.g. `http://192.168.1.50:8001` -**Note about LAN**: if you set `INNKEEPER_HOST=0.0.0.0`, open the app from another device using `http://:` (you may also need to allow inbound traffic in your firewall). - ## 🚀 Quick Start ### First Launch @@ -204,15 +233,9 @@ You can configure **host/port** and SillyTavern integration via a root `.env` fi 1. Install the [ST-Extension-SillyInnkeeper](https://github.com/dmitryplyaskin/ST-Extension-SillyInnkeeper) extension in SillyTavern -2. In the extension settings, specify the SillyInnkeeper URL: - - ``` - http://127.0.0.1:48912 - ``` +2. In the extension settings, specify the SillyInnkeeper URL: `http://127.0.0.1:48912` - If SillyTavern is opened on a different machine, **do not use `localhost`** — use the Innkeeper machine IP/hostname instead (e.g. `http://192.168.1.10:48912`). - - If SillyTavern is not on port 8000, set `ST_PORT` (and/or add its full origin to `CORS_ALLOW_ORIGINS`). + If SillyTavern is opened on a different machine, use the Innkeeper machine IP/hostname instead (e.g. `http://192.168.1.10:48912`). 3. Enable "Auto-connect" for automatic connection @@ -232,22 +255,27 @@ The main screen of the application consists of: 1. **Open the filters panel** (the "Filters" button in the top panel) -2. **Use available filters**: +2. **Use search**: + - **Search by name**: Enter the character name for quick search + - **Text search**: Enter text to search through card content + - Choose search mode: "LIKE" (simple search) or "FTS" (full-text search with ranking) + - Select fields to search: description, personality, scenario, first message, message examples, creator notes, system prompts, post history instructions, alternate greetings, group-only greetings - - **Search by name**: Enter the character name - - **Creator**: Select one or more creators - - **Specification version**: Filter by Character Card version (V1/V2/V3) - - **Tags**: Select tags for filtering +3. **Apply filters**: + - **Metadata**: Creator, specification version (V1/V2/V3), tags, patterns - **Creation date**: Specify a date range - - **Tokens**: Minimum and maximum number of tokens + - **Prompt tokens**: Minimum and maximum number of tokens - **Alternate greetings**: Presence and minimum count - - **Field presence**: Select fields that should be present/absent + - **Field presence**: Select fields that should be present/absent (creator notes, system prompts, post history instructions, personality, scenario, message examples, Character Book) + - **Source**: All cards, only from SillyTavern, only from folder + - **Status**: Hidden, favorite cards + - **SillyTavern chats**: Chat count (equal/≥/≤), user profiles, hide cards without chats -3. **Choose sorting**: By creation date or by name +4. **Choose sorting**: By creation date, name, token count, chat count, last/first chat date, or by relevance (when using full-text search) -4. **Apply filters**: Results will update automatically +5. **Results update automatically** when filters change -5. **Reset filters**: Use the "Reset" button to clear all filters +6. **Reset filters**: Use the "Reset" button to clear all filters ### Viewing a Card @@ -266,12 +294,6 @@ The main screen of the application consists of: - **Rename**: Change the name of the main file - **Delete**: Delete the card or duplicate -### Library Management - -- **Automatic updates**: When files in the cards folder are changed, the application will automatically update the index -- **Manual scanning**: You can start a rescan through settings -- **Duplicate management**: In the detailed card view, you can select the main file or delete duplicates - ## 🔗 SillyTavern Integration SillyInnkeeper integrates with SillyTavern through the [ST-Extension-SillyInnkeeper](https://github.com/dmitryplyaskin/ST-Extension-SillyInnkeeper) extension. @@ -305,31 +327,16 @@ SillyInnkeeper integrates with SillyTavern through the [ST-Extension-SillyInnkee ### Planned Features -1. **Full SillyTavern Integration and Scanning** - - - Scanning cards from SillyTavern folder - - Managing and editing cards, chats, lorebooks, etc. - - Two-way synchronization between SillyInnkeeper and SillyTavern - -2. **Lorebook Support** +1. **Lorebook Support (Character Book)** - Viewing and managing lorebooks from cards - Editing lorebooks - Export/import lorebooks + - This functionality will be redesigned to improve UX/UI -3. **Multiple Directories Support** - - - Support for multiple card libraries - - Switching between libraries - - Unified search across all libraries - - Library management through UI +2. **Native Desktop Application** -4. **Auto-Download and Auto-Import** - - - Monitoring downloads folder - - Automatic scanning of new files - - Automatic import into SillyTavern (optional) - - Configuration rules for automatic card organization + - Possible implementation of a native desktop application for improved performance and usability ## 📄 License @@ -340,9 +347,6 @@ This project is licensed under [AGPL-3.0](https://opensource.org/licenses/AGPL-3 **Dmitry Plyaskin** - GitHub: [@dmitryplyaskin](https://github.com/dmitryplyaskin) +- Telegram Channel: [@SillyInnkeeper](https://t.me/SillyInnkeeper) - Project: [SillyInnkeeper](https://github.com/dmitryplyaskin/SillyInnkeeper) - SillyTavern Extension: [ST-Extension-SillyInnkeeper](https://github.com/dmitryplyaskin/ST-Extension-SillyInnkeeper) - ---- - -**Note**: SillyInnkeeper is an independent project, not officially affiliated with SillyTavern. It is a community tool for improving work with character cards. diff --git a/client/src/entities/cards/index.ts b/client/src/entities/cards/index.ts index 302dbac..1acc018 100644 --- a/client/src/entities/cards/index.ts +++ b/client/src/entities/cards/index.ts @@ -7,3 +7,28 @@ export { refreshCardsSilent, loadCardsFx, } from "./model"; + +export { + cardDeleted, + cardUpdated, + closeDeleteCardModal, + closeRenameMainFileModal, + deleteCardConfirmed, + openDeleteCardModal, + openInExplorerRequested, + openRenameMainFileModal, + playInSillyTavernRequested, + renameMainFileConfirmed, + renameMainFileValueChanged, + toggleHiddenRequested, + $deleteCardModal, + $isDeletingCard, + $isOpeningInExplorer, + $isPlayingInSillyTavern, + $isRenamingMainFile, + $isTogglingHidden, + $renameMainFileModal, +} from "./model.actions"; + +export { CardActionsMenu } from "./ui/card-actions-menu"; +export { CardActionsModals } from "./ui/card-actions-modals"; \ No newline at end of file diff --git a/client/src/entities/cards/lib/path.ts b/client/src/entities/cards/lib/path.ts new file mode 100644 index 0000000..a404e3d --- /dev/null +++ b/client/src/entities/cards/lib/path.ts @@ -0,0 +1,12 @@ +export function getFilenameFromPath(filePath: string | null | undefined): string { + const p = (filePath ?? "").trim(); + if (!p) return ""; + const parts = p.split(/[/\\]+/); + return parts[parts.length - 1] || ""; +} + +export function stripPngExt(name: string): string { + return name.replace(/\.png$/i, ""); +} + + diff --git a/client/src/entities/cards/model.actions.ts b/client/src/entities/cards/model.actions.ts new file mode 100644 index 0000000..e1ae2ea --- /dev/null +++ b/client/src/entities/cards/model.actions.ts @@ -0,0 +1,330 @@ +import { combine, createEffect, createEvent, createStore, sample } from "effector"; +import { notifications } from "@mantine/notifications"; +import i18n from "@/shared/i18n/i18n"; +import { deleteCard, renameCardMainFile, setCardHidden } from "@/shared/api/cards"; +import { showFile } from "@/shared/api/explorer"; +import { refreshCardsSilent } from "./model"; +import { getFilenameFromPath, stripPngExt } from "./lib/path"; + +async function readErrorText(response: Response): Promise { + const txt = (await response.text().catch(() => "")).trim(); + return txt; +} + +export const cardUpdated = createEvent(); +export const cardDeleted = createEvent(); + +export const playInSillyTavernRequested = createEvent<{ cardId: string }>(); +export const toggleHiddenRequested = createEvent<{ + cardId: string; + isHidden: boolean; +}>(); +export const openInExplorerRequested = createEvent<{ filePath: string }>(); + +export const openRenameMainFileModal = createEvent<{ + cardId: string; + filePath: string; +}>(); +export const closeRenameMainFileModal = createEvent(); +export const renameMainFileValueChanged = createEvent(); +export const renameMainFileConfirmed = createEvent(); + +export const openDeleteCardModal = createEvent<{ + cardId: string; + isSillyTavern: boolean; +}>(); +export const closeDeleteCardModal = createEvent(); +export const deleteCardConfirmed = createEvent(); +export const deleteChatsToggled = createEvent(); + +const playInSillyTavernFx = createEffect<{ cardId: string }, void, Error>( + async ({ cardId }) => { + const res = await fetch("/api/st/play", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ cardId }), + }); + if (!res.ok) { + const errText = await readErrorText(res); + throw new Error(errText || res.statusText); + } + } +); + +const toggleHiddenFx = createEffect< + { cardId: string; isHidden: boolean }, + { cardId: string; nextHidden: boolean }, + Error +>(async ({ cardId, isHidden }) => { + await setCardHidden(cardId, !isHidden); + return { cardId, nextHidden: !isHidden }; +}); + +const openInExplorerFx = createEffect<{ filePath: string }, void, Error>( + async ({ filePath }) => { + await showFile(filePath); + } +); + +const renameMainFileFx = createEffect< + { cardId: string; filename: string }, + { cardId: string }, + Error +>(async ({ cardId, filename }) => { + await renameCardMainFile(cardId, filename); + return { cardId }; +}); + +const deleteCardFx = createEffect< + { cardId: string; deleteChats: boolean }, + { cardId: string; chats_deleted?: boolean; chats_delete_error?: string }, + Error +>(async ({ cardId, deleteChats }) => { + const res = await deleteCard(cardId, { deleteChats }); + return { cardId, ...res }; +}); + +export const $isPlayingInSillyTavern = combine( + playInSillyTavernFx.pending, + (p) => p +); +export const $isTogglingHidden = combine(toggleHiddenFx.pending, (p) => p); +export const $isOpeningInExplorer = combine(openInExplorerFx.pending, (p) => p); +export const $isRenamingMainFile = combine(renameMainFileFx.pending, (p) => p); +export const $isDeletingCard = combine(deleteCardFx.pending, (p) => p); + +type RenameModalState = { + opened: boolean; + cardId: string | null; + filePath: string | null; + value: string; +}; + +type DeleteModalState = { + opened: boolean; + cardId: string | null; + isSillyTavern: boolean; + deleteChats: boolean; +}; + +export const $renameMainFileModal = createStore({ + opened: false, + cardId: null, + filePath: null, + value: "", +}) + .on(openRenameMainFileModal, (_, p) => { + const base = stripPngExt(getFilenameFromPath(p.filePath)); + return { + opened: true, + cardId: p.cardId, + filePath: p.filePath, + value: base, + }; + }) + .on(closeRenameMainFileModal, () => ({ + opened: false, + cardId: null, + filePath: null, + value: "", + })) + .on(renameMainFileValueChanged, (s, v) => ({ ...s, value: v })); + +export const $deleteCardModal = createStore({ + opened: false, + cardId: null, + isSillyTavern: false, + deleteChats: false, +}) + .on(openDeleteCardModal, (_, p) => ({ + opened: true, + cardId: p.cardId, + isSillyTavern: p.isSillyTavern, + deleteChats: false, + })) + .on(closeDeleteCardModal, () => ({ + opened: false, + cardId: null, + isSillyTavern: false, + deleteChats: false, + })) + .on(deleteChatsToggled, (s, v) => ({ + ...s, + deleteChats: Boolean(v), + })); + +sample({ + clock: playInSillyTavernRequested, + target: playInSillyTavernFx, +}); + +playInSillyTavernFx.done.watch(() => { + notifications.show({ + title: i18n.t("cardDetails.playInSillyTavern"), + message: i18n.t("cardDetails.playSent"), + color: "green", + }); +}); +playInSillyTavernFx.fail.watch(() => { + notifications.show({ + title: i18n.t("cardDetails.playInSillyTavern"), + message: i18n.t("cardDetails.playFailed"), + color: "red", + }); +}); + +sample({ + clock: toggleHiddenRequested, + target: toggleHiddenFx, +}); + +toggleHiddenFx.doneData.watch(({ nextHidden }) => { + notifications.show({ + title: i18n.t("cardDetails.actions"), + message: nextHidden + ? i18n.t("cardDetails.hideOk") + : i18n.t("cardDetails.showOk"), + color: "green", + }); +}); +toggleHiddenFx.fail.watch(() => { + notifications.show({ + title: i18n.t("cardDetails.actions"), + message: i18n.t("cardDetails.hideFailed"), + color: "red", + }); +}); + +sample({ + clock: toggleHiddenFx.doneData, + fn: ({ cardId }) => cardId, + target: cardUpdated, +}); + +sample({ + clock: toggleHiddenFx.doneData, + fn: () => undefined, + target: refreshCardsSilent, +}); + +sample({ + clock: openInExplorerRequested, + target: openInExplorerFx, +}); + +openInExplorerFx.done.watch(() => { + notifications.show({ + title: i18n.t("cardDetails.openInExplorer"), + message: i18n.t("cardDetails.openInExplorerHint"), + color: "blue", + autoClose: 3500, + }); +}); +openInExplorerFx.failData.watch((e: Error) => { + notifications.show({ + title: i18n.t("cardDetails.openInExplorer"), + message: e.message.trim() ? e.message : i18n.t("cardDetails.openInExplorerFailed"), + color: "red", + }); +}); + +sample({ + clock: renameMainFileConfirmed, + source: $renameMainFileModal, + filter: (m) => + Boolean(m.opened && m.cardId && m.filePath && m.value.trim().length > 0), + fn: (m) => ({ + cardId: m.cardId as string, + filename: m.value.trim(), + }), + target: renameMainFileFx, +}); + +renameMainFileFx.doneData.watch(() => { + notifications.show({ + title: i18n.t("cardDetails.rename"), + message: i18n.t("cardDetails.renameOk"), + color: "green", + }); +}); +renameMainFileFx.fail.watch(() => { + notifications.show({ + title: i18n.t("cardDetails.rename"), + message: i18n.t("cardDetails.renameFailed"), + color: "red", + }); +}); + +sample({ + clock: renameMainFileFx.doneData, + fn: () => undefined, + target: closeRenameMainFileModal, +}); + +sample({ + clock: renameMainFileFx.doneData, + fn: ({ cardId }) => cardId, + target: cardUpdated, +}); + +sample({ + clock: renameMainFileFx.doneData, + fn: () => undefined, + target: refreshCardsSilent, +}); + +sample({ + clock: deleteCardConfirmed, + source: $deleteCardModal, + filter: (m) => Boolean(m.opened && m.cardId), + fn: (m) => ({ + cardId: m.cardId as string, + deleteChats: Boolean(m.isSillyTavern && m.deleteChats), + }), + target: deleteCardFx, +}); + +deleteCardFx.doneData.watch(({ chats_delete_error }) => { + notifications.show({ + title: i18n.t("cardDetails.delete"), + message: i18n.t("cardDetails.cardDeleted"), + color: "green", + }); + + const err = (chats_delete_error ?? "").trim(); + if (err) { + notifications.show({ + title: i18n.t("cardDetails.delete"), + message: i18n.t("cardDetails.chatsDeleteFailed"), + color: "yellow", + autoClose: 6500, + }); + } +}); +deleteCardFx.fail.watch(() => { + notifications.show({ + title: i18n.t("cardDetails.delete"), + message: i18n.t("cardDetails.cardDeleteFailed"), + color: "red", + }); +}); + +sample({ + clock: deleteCardFx.doneData, + fn: () => undefined, + target: closeDeleteCardModal, +}); + +sample({ + clock: deleteCardFx.doneData, + fn: ({ cardId }) => cardId, + target: cardDeleted, +}); + +sample({ + clock: deleteCardFx.doneData, + fn: () => undefined, + target: refreshCardsSilent, +}); + + diff --git a/client/src/entities/cards/ui/Card.tsx b/client/src/entities/cards/ui/Card.tsx index 558bcf9..53ce673 100644 --- a/client/src/entities/cards/ui/Card.tsx +++ b/client/src/entities/cards/ui/Card.tsx @@ -12,8 +12,10 @@ import { Box, useMantineTheme, } from "@mantine/core"; +import { IconStarFilled } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; import type { CardListItem } from "@/shared/types/cards"; +import { CardActionsMenu } from "./card-actions-menu"; interface CardProps { card: CardListItem; @@ -53,7 +55,9 @@ export function Card({ const [opened, setOpened] = useState(false); const theme = useMantineTheme(); - const checkboxBg = isSelected ? theme.colors.blue[6] : "rgba(255,255,255,0.75)"; + const checkboxBg = isSelected + ? theme.colors.blue[6] + : "rgba(255,255,255,0.75)"; const checkboxColor = isSelected ? theme.white : theme.colors.gray[7]; const tags = card.tags ?? []; @@ -71,6 +75,8 @@ export function Card({ const greetingsCount = Number((card as any).alternate_greetings_count) || 0; const hasBook = Boolean((card as any).has_character_book); const tokensEstimate = formatTokensEstimate((card as any).prompt_tokens_est); + const isSillyTavern = Boolean((card as any).is_sillytavern); + const isFav = Boolean(card.fav); return ( <> @@ -132,41 +138,98 @@ export function Card({ /> - {isSelectionMode && ( + {(isSelectionMode || isFav || isSillyTavern) && ( - {isSelected ? ( - - - - ) : null} + {isSelected ? ( + + + + ) : null} + + )} + + {isFav && isSillyTavern && ( + + + + + + )} + + {isSillyTavern && ( + + + ST + + + )} )} @@ -205,9 +268,17 @@ export function Card({ - - {card.name || t("card.untitled")} - + + + {card.name || t("card.untitled")} + + + {card.creator && ( diff --git a/client/src/entities/cards/ui/card-actions-menu.tsx b/client/src/entities/cards/ui/card-actions-menu.tsx new file mode 100644 index 0000000..0d862ff --- /dev/null +++ b/client/src/entities/cards/ui/card-actions-menu.tsx @@ -0,0 +1,166 @@ +import { ActionIcon, Loader, Menu } from "@mantine/core"; +import { useUnit } from "effector-react"; +import { useTranslation } from "react-i18next"; +import { + IconDotsVertical, + IconDownload, + IconEye, + IconEyeOff, + IconFolder, + IconPencil, + IconPlayerPlay, + IconTrash, +} from "@tabler/icons-react"; +import { + $isOpeningInExplorer, + $isPlayingInSillyTavern, + $isTogglingHidden, + openDeleteCardModal, + openInExplorerRequested, + openRenameMainFileModal, + playInSillyTavernRequested, + toggleHiddenRequested, +} from "../model.actions"; + +export type CardActionsMenuProps = { + cardId: string; + filePath: string | null; + isHidden: boolean; + isSillyTavern: boolean; +}; + +export function CardActionsMenu({ + cardId, + filePath, + isHidden, + isSillyTavern, +}: CardActionsMenuProps) { + const { t } = useTranslation(); + + const [isPlaying, isTogglingHidden, isOpeningInExplorer] = useUnit([ + $isPlayingInSillyTavern, + $isTogglingHidden, + $isOpeningInExplorer, + ]); + + const [ + onPlay, + onToggleHidden, + onOpenInExplorer, + onOpenRename, + onOpenDelete, + ] = useUnit([ + playInSillyTavernRequested, + toggleHiddenRequested, + openInExplorerRequested, + openRenameMainFileModal, + openDeleteCardModal, + ]); + + const exportPngUrl = `/api/cards/${encodeURIComponent(cardId)}/export.png?download=1`; + const canUseFile = Boolean(filePath?.trim()); + + return ( + + + { + e.stopPropagation(); + }} + > + + + + + + : + } + disabled={isPlaying} + onClick={(e) => { + e.stopPropagation(); + onPlay({ cardId }); + }} + > + {t("cardDetails.playInSillyTavern")} + + + + ) : isHidden ? ( + + ) : ( + + ) + } + disabled={isTogglingHidden} + onClick={(e) => { + e.stopPropagation(); + onToggleHidden({ cardId, isHidden }); + }} + > + {isHidden ? t("cardDetails.show") : t("cardDetails.hide")} + + + } + onClick={(e) => { + e.stopPropagation(); + window.location.href = exportPngUrl; + }} + > + {t("cardDetails.download")} + + + + + : + } + disabled={!canUseFile || isOpeningInExplorer} + onClick={(e) => { + e.stopPropagation(); + if (!filePath) return; + onOpenInExplorer({ filePath }); + }} + > + {t("cardDetails.openInExplorer")} + + + } + disabled={!canUseFile} + onClick={(e) => { + e.stopPropagation(); + if (!filePath) return; + onOpenRename({ cardId, filePath }); + }} + > + {t("cardDetails.rename")} + + + } + onClick={(e) => { + e.stopPropagation(); + onOpenDelete({ cardId, isSillyTavern }); + }} + > + {t("cardDetails.delete")} + + + + ); +} + + diff --git a/client/src/entities/cards/ui/card-actions-modals.tsx b/client/src/entities/cards/ui/card-actions-modals.tsx new file mode 100644 index 0000000..2786377 --- /dev/null +++ b/client/src/entities/cards/ui/card-actions-modals.tsx @@ -0,0 +1,123 @@ +import { Button, Checkbox, Group, Modal, Stack, Text, TextInput } from "@mantine/core"; +import { useUnit } from "effector-react"; +import i18n from "@/shared/i18n/i18n"; +import { + $deleteCardModal, + $isDeletingCard, + $isRenamingMainFile, + $renameMainFileModal, + closeDeleteCardModal, + closeRenameMainFileModal, + deleteCardConfirmed, + deleteChatsToggled, + renameMainFileConfirmed, + renameMainFileValueChanged, +} from "../model.actions"; + +export function CardActionsModals() { + const [renameModal, deleteModal, isRenaming, isDeleting] = useUnit([ + $renameMainFileModal, + $deleteCardModal, + $isRenamingMainFile, + $isDeletingCard, + ]); + + const [ + onCloseRename, + onRenameValueChanged, + onRenameConfirm, + onCloseDelete, + onDeleteConfirm, + onDeleteChatsToggled, + ] = useUnit([ + closeRenameMainFileModal, + renameMainFileValueChanged, + renameMainFileConfirmed, + closeDeleteCardModal, + deleteCardConfirmed, + deleteChatsToggled, + ]); + + return ( + <> + onCloseRename()} + title={i18n.t("cardDetails.renameMainFileTitle")} + zIndex={500} + overlayProps={{ zIndex: 499 }} + > + + + {i18n.t("cardDetails.renameMainFileHint")} + + onRenameValueChanged(e.currentTarget.value)} + placeholder={i18n.t("cardDetails.renameMainFilePlaceholder")} + rightSection={.png} + /> + + + + + + + + onCloseDelete()} + title={i18n.t("cardDetails.confirmDeleteCardTitle")} + zIndex={500} + overlayProps={{ zIndex: 499 }} + > + + {i18n.t("cardDetails.confirmDeleteCardMessage")} + {deleteModal.isSillyTavern ? ( + + {i18n.t("cardDetails.confirmDeleteCardStWarning")} + + ) : null} + {deleteModal.isSillyTavern ? ( + onDeleteChatsToggled(e.currentTarget.checked)} + /> + ) : null} + + + + + + + + ); +} + + diff --git a/client/src/features/card-details/index.ts b/client/src/features/card-details/index.ts index bac0526..4941b16 100644 --- a/client/src/features/card-details/index.ts +++ b/client/src/features/card-details/index.ts @@ -1,4 +1,5 @@ export * from "./model"; +export * from "./model.chats"; export * from "./model.form"; export * from "./model.lorebook-editor"; export { CardDetailsDrawer } from "./ui/CardDetailsDrawer"; diff --git a/client/src/features/card-details/model.chats.ts b/client/src/features/card-details/model.chats.ts new file mode 100644 index 0000000..497560a --- /dev/null +++ b/client/src/features/card-details/model.chats.ts @@ -0,0 +1,167 @@ +import { combine, createEffect, createEvent, createStore, sample } from "effector"; +import { getCardChat, getCardChats } from "@/shared/api/cards"; +import type { CardChatDetails, CardChatSummary } from "@/shared/types/card-chats"; +import { $details, $openedId, closeCard, openCard } from "./model"; + +type LoadChatsParams = { requestId: number; cardId: string }; +type LoadChatsResult = { requestId: number; chats: CardChatSummary[] }; + +type LoadChatParams = { requestId: number; cardId: string; chatId: string }; +type LoadChatResult = { requestId: number; chat: CardChatDetails }; + +const loadChatsInternalFx = createEffect( + async ({ requestId, cardId }) => { + const chats = await getCardChats(cardId); + return { requestId, chats }; + } +); + +const loadChatInternalFx = createEffect( + async ({ requestId, cardId, chatId }) => { + const chat = await getCardChat(cardId, chatId); + return { requestId, chat }; + } +); + +export const chatSelected = createEvent(); +const resetSelection = createEvent(); + +export const $chats = createStore([]) + .reset(closeCard) + .reset(openCard); +export const $chatsError = createStore(null) + .reset(closeCard) + .reset(openCard); +const $chatsRequestId = createStore(0).reset(closeCard).reset(openCard); + +export const $selectedChatId = createStore(null) + .on(chatSelected, (_, id) => id) + .on(resetSelection, () => null) + .reset(closeCard) + .reset(openCard); + +export const $chat = createStore(null) + .reset(closeCard) + .reset(openCard); +export const $chatError = createStore(null) + .reset(closeCard) + .reset(openCard); +const $chatRequestId = createStore(0).reset(closeCard).reset(openCard); + +export const $isChatsLoading = combine(loadChatsInternalFx.pending, (p) => p); +export const $isChatLoading = combine(loadChatInternalFx.pending, (p) => p); + +const setChats = createEvent(); +const setChatsError = createEvent(); +const setChat = createEvent(); +const setChatError = createEvent(); + +$chats.on(setChats, (_, v) => v); +$chatsError.on(setChatsError, (_, v) => v); +$chat.on(setChat, (_, v) => v); +$chatError.on(setChatError, (_, v) => v); + +// Load chats list when card details are loaded (ST cards only) +sample({ + clock: $details.updates, + source: { openedId: $openedId, lastRequestId: $chatsRequestId }, + filter: ({ openedId }, details) => + Boolean( + openedId && + details && + details.id === openedId && + details.is_sillytavern === true + ), + fn: ({ openedId, lastRequestId }): LoadChatsParams => ({ + requestId: lastRequestId + 1, + cardId: openedId as string, + }), + target: loadChatsInternalFx, +}); + +$chatsRequestId.on(loadChatsInternalFx, (_, p) => p.requestId); + +sample({ + clock: loadChatsInternalFx.doneData, + source: $chatsRequestId, + filter: (lastId, done) => done.requestId === lastId, + fn: (_, done) => done.chats, + target: setChats, +}); + +sample({ + clock: loadChatsInternalFx.doneData, + fn: () => null, + target: setChatsError, +}); + +sample({ + clock: loadChatsInternalFx.failData, + fn: (e: Error) => e.message, + target: setChatsError, +}); + +// When non-ST details arrive, clear chats UI state +sample({ + clock: $details.updates, + filter: (details) => Boolean(details && !details.is_sillytavern), + fn: () => [], + target: setChats, +}); + +sample({ + clock: $details.updates, + filter: (details) => Boolean(details && !details.is_sillytavern), + fn: () => undefined, + target: resetSelection, +}); + +sample({ + clock: resetSelection, + fn: () => null, + target: [setChatsError, setChat, setChatError], +}); + +// Load selected chat +sample({ + clock: chatSelected, + source: { openedId: $openedId, lastRequestId: $chatRequestId }, + filter: ({ openedId }, chatId) => + Boolean(openedId && typeof chatId === "string" && chatId.trim().length > 0), + fn: ({ openedId, lastRequestId }, chatId): LoadChatParams => ({ + requestId: lastRequestId + 1, + cardId: openedId as string, + chatId: chatId.trim(), + }), + target: loadChatInternalFx, +}); + +$chatRequestId.on(loadChatInternalFx, (_, p) => p.requestId); + +sample({ + clock: loadChatInternalFx, + fn: () => null, + target: [setChat, setChatError], +}); + +sample({ + clock: loadChatInternalFx.doneData, + source: $chatRequestId, + filter: (lastId, done) => done.requestId === lastId, + fn: (_, done) => done.chat, + target: setChat, +}); + +sample({ + clock: loadChatInternalFx.doneData, + fn: () => null, + target: setChatError, +}); + +sample({ + clock: loadChatInternalFx.failData, + fn: (e: Error) => e.message, + target: setChatError, +}); + + diff --git a/client/src/features/card-details/model.ts b/client/src/features/card-details/model.ts index 7e2507e..65cf5ac 100644 --- a/client/src/features/card-details/model.ts +++ b/client/src/features/card-details/model.ts @@ -3,6 +3,7 @@ import { getCardDetails } from "@/shared/api/cards"; import { getLorebooks, getLorebook } from "@/shared/api/lorebooks"; import type { CardDetails } from "@/shared/types/cards"; import type { LorebookDetails } from "@/shared/types/lorebooks"; +import { cardDeleted, cardUpdated } from "@/entities/cards"; type LoadParams = { requestId: number; id: string }; type LoadResult = { requestId: number; details: CardDetails }; @@ -43,6 +44,24 @@ sample({ target: loadDetailsInternalFx, }); +// Refresh currently opened details if the card was updated elsewhere (grid menu, etc.) +sample({ + clock: cardUpdated, + source: $openedId, + filter: (openedId, id) => Boolean(openedId && openedId === id), + fn: (_, id) => id, + target: openCard, +}); + +// Close drawer if the currently opened card was deleted. +sample({ + clock: cardDeleted, + source: $openedId, + filter: (openedId, id) => Boolean(openedId && openedId === id), + fn: () => undefined, + target: closeCard, +}); + sample({ clock: loadDetailsInternalFx.doneData, source: $lastRequestId, diff --git a/client/src/features/card-details/ui/components/CardDetailsActionsPanel.tsx b/client/src/features/card-details/ui/components/CardDetailsActionsPanel.tsx index bccea69..cf4f3d4 100644 --- a/client/src/features/card-details/ui/components/CardDetailsActionsPanel.tsx +++ b/client/src/features/card-details/ui/components/CardDetailsActionsPanel.tsx @@ -1,30 +1,25 @@ import { useState } from "react"; -import { - ActionIcon, - Button, - Code, - Divider, - Group, - Modal, - Paper, - Stack, - Text, - TextInput, - Tooltip, -} from "@mantine/core"; +import { Button, Divider, Group, Paper, Stack, Text } from "@mantine/core"; import { notifications } from "@mantine/notifications"; import { useUnit } from "effector-react"; import type { CardDetails } from "@/shared/types/cards"; import i18n from "@/shared/i18n/i18n"; -import { deleteCard, deleteCardFileDuplicate } from "@/shared/api/cards"; -import { - renameCardMainFile, - saveCard, - setCardMainFile, -} from "@/shared/api/cards"; +import { deleteCardFileDuplicate } from "@/shared/api/cards"; +import { saveCard, setCardMainFile } from "@/shared/api/cards"; import { showFile } from "@/shared/api/explorer"; import { CopyableTruncatedText } from "@/shared/ui/CopyableTruncatedText"; -import { closeCard, openCard } from "../../model"; +import { $settings } from "@/entities/settings"; +import { + $isOpeningInExplorer, + $isPlayingInSillyTavern, + $isTogglingHidden, + openDeleteCardModal, + openInExplorerRequested, + openRenameMainFileModal, + playInSillyTavernRequested, + toggleHiddenRequested, +} from "@/entities/cards"; +import { openCard } from "../../model"; import { $altGreetingIds, $altGreetingValues, @@ -34,6 +29,10 @@ import { $lorebook, draftSaved, } from "../../model.form"; +import { SillyTavernChatsInfo } from "./sillytavern-chats-info"; +import { CardDuplicatesSection } from "./card-duplicates-section"; +import { ConfirmDeleteDuplicateModal } from "./confirm-delete-duplicate-modal"; +import { SaveCardModal, type SaveCardMode } from "./save-card-modal"; function getFilenameFromPath(filePath: string | null | undefined): string { const p = (filePath ?? "").trim(); @@ -42,29 +41,18 @@ function getFilenameFromPath(filePath: string | null | undefined): string { return parts[parts.length - 1] || i18n.t("empty.dash"); } -function stripPngExt(name: string): string { - return name.replace(/\.png$/i, ""); -} - export function CardDetailsActionsPanel({ details, }: { details: CardDetails | null; }) { - const [isSendingPlay, setIsSendingPlay] = useState(false); const [confirmDeleteDuplicateOpened, setConfirmDeleteDuplicateOpened] = useState(false); - const [confirmDeleteCardOpened, setConfirmDeleteCardOpened] = useState(false); - const [renameOpened, setRenameOpened] = useState(false); - const [renameValue, setRenameValue] = useState(""); const [selectedDuplicatePath, setSelectedDuplicatePath] = useState< string | null >(null); const [isDeletingDuplicate, setIsDeletingDuplicate] = useState(false); - const [isDeletingCard, setIsDeletingCard] = useState(false); const [isSettingMainFile, setIsSettingMainFile] = useState(false); - const [isRenaming, setIsRenaming] = useState(false); - const [isOpeningInExplorer, setIsOpeningInExplorer] = useState(false); const [openingDuplicatePath, setOpeningDuplicatePath] = useState< string | null >(null); @@ -72,7 +60,35 @@ export function CardDetailsActionsPanel({ const [isSaving, setIsSaving] = useState(false); const [pendingCardJson, setPendingCardJson] = useState(null); - const [draft, altIds, altValues, groupIds, groupValues, lorebook, markSaved] = useUnit([ + const [isSendingPlay, isTogglingHidden, isOpeningInExplorer] = useUnit([ + $isPlayingInSillyTavern, + $isTogglingHidden, + $isOpeningInExplorer, + ]); + const [ + onPlay, + onToggleHidden, + onOpenInExplorer, + onOpenRenameModal, + onOpenDeleteModal, + ] = useUnit([ + playInSillyTavernRequested, + toggleHiddenRequested, + openInExplorerRequested, + openRenameMainFileModal, + openDeleteCardModal, + ]); + + const [ + draft, + altIds, + altValues, + groupIds, + groupValues, + lorebook, + markSaved, + settings, + ] = useUnit([ $draft, $altGreetingIds, $altGreetingValues, @@ -80,6 +96,7 @@ export function CardDetailsActionsPanel({ $groupGreetingValues, $lorebook, draftSaved, + $settings, ]); function canonicalizeForCompare(value: unknown): unknown { @@ -189,35 +206,8 @@ export function CardDetailsActionsPanel({ ? `/api/cards/${encodeURIComponent(details.id)}/export.png?download=1` : undefined; - async function playInSillyTavern(): Promise { - if (!details?.id) return; - if (isSendingPlay) return; - setIsSendingPlay(true); - try { - const res = await fetch("/api/st/play", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ cardId: details.id }), - }); - if (!res.ok) { - const errText = (await res.text().catch(() => "")).trim(); - throw new Error(errText || res.statusText); - } - notifications.show({ - title: i18n.t("cardDetails.playInSillyTavern"), - message: i18n.t("cardDetails.playSent"), - color: "green", - }); - } catch (e) { - notifications.show({ - title: i18n.t("cardDetails.playInSillyTavern"), - message: i18n.t("cardDetails.playFailed"), - color: "red", - }); - } finally { - setIsSendingPlay(false); - } - } + const isHidden = Boolean(details?.innkeeperMeta?.isHidden); + const isSillyTavern = Boolean(details?.is_sillytavern); const duplicates = details?.duplicates ?? []; const hasDuplicates = duplicates.length > 0; @@ -243,7 +233,7 @@ export function CardDetailsActionsPanel({ setSaveOpened(true); } - async function doSave(mode: Parameters[0]["mode"]) { + async function doSave(mode: SaveCardMode) { if (!details?.id) return; if (!pendingCardJson) return; if (isSaving) return; @@ -287,34 +277,6 @@ export function CardDetailsActionsPanel({ } } - async function openMainInExplorer(): Promise { - const p = (details?.file_path ?? "").trim(); - if (!p) return; - if (isOpeningInExplorer) return; - setIsOpeningInExplorer(true); - try { - await showFile(p); - notifications.show({ - title: i18n.t("cardDetails.openInExplorer"), - message: i18n.t("cardDetails.openInExplorerHint"), - color: "blue", - autoClose: 3500, - }); - } catch (e) { - const msg = - e instanceof Error && e.message.trim() - ? e.message - : i18n.t("cardDetails.openInExplorerFailed"); - notifications.show({ - title: i18n.t("cardDetails.openInExplorer"), - message: msg, - color: "red", - }); - } finally { - setIsOpeningInExplorer(false); - } - } - async function openDuplicateInExplorer(p: string): Promise { const fp = (p ?? "").trim(); if (!fp) return; @@ -370,59 +332,6 @@ export function CardDetailsActionsPanel({ } } - async function deleteCardConfirmed(): Promise { - if (!details?.id) return; - if (isDeletingCard) return; - setIsDeletingCard(true); - try { - await deleteCard(details.id); - notifications.show({ - title: i18n.t("cardDetails.delete"), - message: i18n.t("cardDetails.cardDeleted"), - color: "green", - }); - setConfirmDeleteCardOpened(false); - closeCard(); - } catch (e) { - notifications.show({ - title: i18n.t("cardDetails.delete"), - message: i18n.t("cardDetails.cardDeleteFailed"), - color: "red", - }); - } finally { - setIsDeletingCard(false); - } - } - - async function renameMainFileConfirmed(): Promise { - if (!details?.id) return; - if (!details?.file_path) return; - if (isRenaming) return; - - const next = renameValue.trim(); - if (next.length === 0) return; - - setIsRenaming(true); - try { - await renameCardMainFile(details.id, next); - notifications.show({ - title: i18n.t("cardDetails.rename"), - message: i18n.t("cardDetails.renameOk"), - color: "green", - }); - setRenameOpened(false); - openCard(details.id); - } catch (e) { - notifications.show({ - title: i18n.t("cardDetails.rename"), - message: i18n.t("cardDetails.renameFailed"), - color: "red", - }); - } finally { - setIsRenaming(false); - } - } - async function makeDuplicateMain(filePath: string): Promise { if (!details?.id) return; if (isSettingMainFile) return; @@ -463,7 +372,10 @@ export function CardDetailsActionsPanel({ fullWidth variant="filled" color="green" - onClick={() => void playInSillyTavern()} + onClick={() => { + if (!details?.id) return; + onPlay({ cardId: details.id }); + }} loading={isSendingPlay} disabled={!details?.id} > @@ -479,6 +391,20 @@ export function CardDetailsActionsPanel({ {i18n.t("cardDetails.save")} + + @@ -531,6 +468,10 @@ export function CardDetailsActionsPanel({ {i18n.t("cardDetails.metadata")} + {details?.is_sillytavern === true && ( + + )} + {i18n.t("cardDetails.mainFile")} @@ -598,272 +539,39 @@ export function CardDetailsActionsPanel({ {hasDuplicates && ( <> - - {i18n.t("cardDetails.duplicatesTitle")} - - {duplicates.map((p) => ( - - - - - - void makeDuplicateMain(p)} - loading={isSettingMainFile} - aria-label={i18n.t("cardDetails.makeMainFile")} - > - - - - - - - - void openDuplicateInExplorer(p)} - loading={openingDuplicatePath === p} - aria-label={i18n.t("cardDetails.showInExplorer")} - > - - - - - - - - - { - setSelectedDuplicatePath(p); - setConfirmDeleteDuplicateOpened(true); - }} - aria-label={i18n.t("cardDetails.deleteDuplicate")} - > - - - - - - - - - - - - - ))} - + void makeDuplicateMain(p)} + onOpenInExplorer={(p) => void openDuplicateInExplorer(p)} + onRequestDelete={(p) => { + setSelectedDuplicatePath(p); + setConfirmDeleteDuplicateOpened(true); + }} + /> )} - setConfirmDeleteDuplicateOpened(false)} - title={i18n.t("cardDetails.confirmDeleteDuplicateTitle")} - > - - - {i18n.t("cardDetails.confirmDeleteDuplicateMessage")} - - {selectedDuplicatePath && {selectedDuplicatePath}} - - - - - - - - setConfirmDeleteCardOpened(false)} - title={i18n.t("cardDetails.confirmDeleteCardTitle")} - > - - - {i18n.t("cardDetails.confirmDeleteCardMessage")} - - - - - - - + onConfirm={() => void deleteDuplicateConfirmed()} + /> - setRenameOpened(false)} - title={i18n.t("cardDetails.renameMainFileTitle")} - > - - - {i18n.t("cardDetails.renameMainFileHint")} - - setRenameValue(e.currentTarget.value)} - placeholder={i18n.t("cardDetails.renameMainFilePlaceholder")} - rightSection={.png} - /> - - - - - - - - setSaveOpened(false)} - title={i18n.t("cardDetails.saveModalTitle")} - > - - {!hasDuplicates ? ( - <> - - - - ) : ( - <> - - - - - )} - - - + onSave={(mode) => void doSave(mode)} + /> ); } diff --git a/client/src/features/card-details/ui/components/CardDetailsTabs.tsx b/client/src/features/card-details/ui/components/CardDetailsTabs.tsx index fc6714d..dc43f6f 100644 --- a/client/src/features/card-details/ui/components/CardDetailsTabs.tsx +++ b/client/src/features/card-details/ui/components/CardDetailsTabs.tsx @@ -36,6 +36,7 @@ import { DraftNameField } from "./fields/DraftNameField"; import { DraftTagsField } from "./fields/DraftTagsField"; import { EditableGreetingsList } from "./EditableGreetingsList"; import { LorebookEditor } from "./LorebookEditor"; +import { CardChatsTab } from "./chats/card-chats-tab"; function JsonBlock({ value }: { value: unknown | null }) { const pretty = useMemo(() => { @@ -147,6 +148,9 @@ export function CardDetailsTabs({ {t("cardDetails.tabsAlt")} {t("cardDetails.tabsSystem")} {t("cardDetails.tabsLorebook", "Lorebook")} + {details?.is_sillytavern === true && ( + {t("cardDetails.tabsChats")} + )} {t("cardDetails.tabsRaw")} @@ -319,6 +323,12 @@ export function CardDetailsTabs({ + {details?.is_sillytavern === true && ( + + + + )} + diff --git a/client/src/features/card-details/ui/components/MdTextareaField.tsx b/client/src/features/card-details/ui/components/MdTextareaField.tsx index 1c2efa0..22c390b 100644 --- a/client/src/features/card-details/ui/components/MdTextareaField.tsx +++ b/client/src/features/card-details/ui/components/MdTextareaField.tsx @@ -371,6 +371,8 @@ export function MdTextareaField({ onClose={() => setModalOpened(false)} centered size={1024} + zIndex={500} + overlayProps={{ zIndex: 499 }} styles={{ content: { width: "70vw", diff --git a/client/src/features/card-details/ui/components/card-duplicates-section.tsx b/client/src/features/card-details/ui/components/card-duplicates-section.tsx new file mode 100644 index 0000000..2a182bb --- /dev/null +++ b/client/src/features/card-details/ui/components/card-duplicates-section.tsx @@ -0,0 +1,83 @@ +import { ActionIcon, Group, Paper, Stack, Text, Tooltip } from "@mantine/core"; +import { IconFolder, IconStar, IconTrash } from "@tabler/icons-react"; +import i18n from "@/shared/i18n/i18n"; +import { CopyableTruncatedText } from "@/shared/ui/CopyableTruncatedText"; + +export function CardDuplicatesSection({ + duplicates, + isSettingMainFile, + openingDuplicatePath, + onMakeMain, + onOpenInExplorer, + onRequestDelete, +}: { + duplicates: string[]; + isSettingMainFile: boolean; + openingDuplicatePath: string | null; + onMakeMain: (filePath: string) => void; + onOpenInExplorer: (filePath: string) => void; + onRequestDelete: (filePath: string) => void; +}) { + if (!duplicates || duplicates.length === 0) return null; + + return ( + <> + {i18n.t("cardDetails.duplicatesTitle")} + + {duplicates.map((p) => ( + + + + + + onMakeMain(p)} + loading={isSettingMainFile} + aria-label={i18n.t("cardDetails.makeMainFile")} + > + + + + + + onOpenInExplorer(p)} + loading={openingDuplicatePath === p} + aria-label={i18n.t("cardDetails.showInExplorer")} + > + + + + + + onRequestDelete(p)} + aria-label={i18n.t("cardDetails.deleteDuplicate")} + > + + + + + + + ))} + + + ); +} + + diff --git a/client/src/features/card-details/ui/components/chats/card-chats-tab.tsx b/client/src/features/card-details/ui/components/chats/card-chats-tab.tsx new file mode 100644 index 0000000..3fd1409 --- /dev/null +++ b/client/src/features/card-details/ui/components/chats/card-chats-tab.tsx @@ -0,0 +1,253 @@ +import { useEffect, useMemo, useRef } from "react"; +import { useUnit } from "effector-react"; +import { useTranslation } from "react-i18next"; +import { + Alert, + Grid, + Group, + Loader, + Paper, + ScrollArea, + Stack, + Text, + UnstyledButton, +} from "@mantine/core"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import i18n from "@/shared/i18n/i18n"; +import type { + CardChatMessage, + CardChatSummary, +} from "@/shared/types/card-chats"; +import { + $chat, + $chatError, + $chats, + $chatsError, + $isChatLoading, + $isChatsLoading, + $selectedChatId, + chatSelected, +} from "../../../model.chats"; + +function formatDateOrDash(ms: number): string { + if (!(ms > 0)) return i18n.t("empty.dash"); + const locale = i18n.language === "ru" ? "ru-RU" : "en-US"; + // Keep date format consistent (numeric) and avoid locale-specific month names / AM-PM. + return new Intl.DateTimeFormat(locale, { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }).format(new Date(ms)); +} + +function ChatListItem({ + chat, + selected, + onClick, +}: { + chat: CardChatSummary; + selected: boolean; + onClick: () => void; +}) { + return ( + + + + + {chat.title || chat.id} + + + + {i18n.t("cardDetails.chats.messagesCount", { + count: chat.messages_count, + })} + + + {formatDateOrDash(chat.last_message_at)} + + + + + + ); +} + +function ChatMessage({ msg }: { msg: CardChatMessage }) { + const header = useMemo(() => { + const who = msg.name?.trim() ? msg.name.trim() : msg.is_user ? "You" : ""; + const date = formatDateOrDash(msg.send_date_ms); + return { who, date }; + }, [msg.is_user, msg.name, msg.send_date_ms]); + + const bg = msg.is_system + ? "rgba(0, 0, 0, 0.04)" + : msg.is_user + ? "rgba(34, 139, 230, 0.06)" + : "rgba(99, 230, 190, 0.06)"; + + return ( + + + + {header.who || i18n.t("empty.dash")} + + + {header.date} + + + ( + + {children} + + ), + pre: ({ children, ...props }) => ( +
+              {children}
+            
+ ), + }} + > + {msg.mes ?? ""} +
+
+ ); +} + +export function CardChatsTab() { + const { t } = useTranslation(); + const [ + chats, + chatsError, + isChatsLoading, + selectedChatId, + selectChat, + chat, + isChatLoading, + chatError, + ] = useUnit([ + $chats, + $chatsError, + $isChatsLoading, + $selectedChatId, + chatSelected, + $chat, + $isChatLoading, + $chatError, + ]); + + const bottomRef = useRef(null); + + useEffect(() => { + if (!chat || isChatLoading) return; + bottomRef.current?.scrollIntoView({ block: "end" }); + }, [chat?.id, chat?.messages.length, isChatLoading]); + + const hasChats = chats.length > 0; + // Fill modal height better: modal is 100% height; subtract title + tabs + paddings. + const paneHeight = "calc(100vh - 220px)"; + + return ( + + + + + {t("cardDetails.chats.title")} + {isChatsLoading ? : null} + + + {chatsError ? ( + + {chatsError} + + ) : null} + + {!isChatsLoading && !hasChats ? ( + {t("cardDetails.chats.empty")} + ) : ( + + + {chats.map((c) => ( + selectChat(c.id)} + /> + ))} + + + )} + + + + + + + + {chat?.title + ? t("cardDetails.chats.chatTitle", { title: chat.title }) + : t("cardDetails.chats.chatTitleEmpty")} + + {isChatLoading ? : null} + + + {chatError ? ( + + {chatError} + + ) : null} + + {!selectedChatId ? ( + {t("cardDetails.chats.selectChatHint")} + ) : !chat && isChatLoading ? ( + + + + ) : chat ? ( + + + {chat.messages.map((m, idx) => ( + + ))} +
+ + + ) : ( + {t("empty.dash")} + )} + + + + ); +} diff --git a/client/src/features/card-details/ui/components/confirm-delete-duplicate-modal.tsx b/client/src/features/card-details/ui/components/confirm-delete-duplicate-modal.tsx new file mode 100644 index 0000000..9dca18f --- /dev/null +++ b/client/src/features/card-details/ui/components/confirm-delete-duplicate-modal.tsx @@ -0,0 +1,41 @@ +import { Button, Code, Group, Modal, Stack, Text } from "@mantine/core"; +import i18n from "@/shared/i18n/i18n"; + +export function ConfirmDeleteDuplicateModal({ + opened, + selectedDuplicatePath, + isDeleting, + onClose, + onConfirm, +}: { + opened: boolean; + selectedDuplicatePath: string | null; + isDeleting: boolean; + onClose: () => void; + onConfirm: () => void; +}) { + return ( + + + {i18n.t("cardDetails.confirmDeleteDuplicateMessage")} + {selectedDuplicatePath && {selectedDuplicatePath}} + + + + + + + ); +} + + diff --git a/client/src/features/card-details/ui/components/save-card-modal.tsx b/client/src/features/card-details/ui/components/save-card-modal.tsx new file mode 100644 index 0000000..ec1c835 --- /dev/null +++ b/client/src/features/card-details/ui/components/save-card-modal.tsx @@ -0,0 +1,111 @@ +import { Button, Modal, Stack, Tooltip } from "@mantine/core"; +import i18n from "@/shared/i18n/i18n"; + +export type SaveCardMode = + | "overwrite_main" + | "overwrite_all_files" + | "save_new" + | "save_new_delete_old_main" + | "save_new_to_library"; + +export function SaveCardModal({ + opened, + hasDuplicates, + isSillyTavern, + cardsFolderPath, + isSaving, + onClose, + onSave, +}: { + opened: boolean; + hasDuplicates: boolean; + isSillyTavern: boolean; + cardsFolderPath: string; + isSaving: boolean; + onClose: () => void; + onSave: (mode: SaveCardMode) => void; +}) { + return ( + + + {!hasDuplicates ? ( + <> + + + {isSillyTavern && ( + + + + )} + + ) : ( + <> + + + + {isSillyTavern && ( + + + + )} + + )} + + + + ); +} + + diff --git a/client/src/features/card-details/ui/components/sillytavern-chats-info.tsx b/client/src/features/card-details/ui/components/sillytavern-chats-info.tsx new file mode 100644 index 0000000..5d5b08d --- /dev/null +++ b/client/src/features/card-details/ui/components/sillytavern-chats-info.tsx @@ -0,0 +1,73 @@ +import { Group, Text } from "@mantine/core"; +import i18n from "@/shared/i18n/i18n"; + +export interface SillyTavernFileMetaLike { + st_chats_count?: number | null; + st_last_chat_at?: number | null; + st_first_chat_at?: number | null; +} + +function formatDateOrNone(ms: number): string { + if (!(ms > 0)) return i18n.t("empty.none"); + const locale = i18n.language === "ru" ? "ru-RU" : "en-US"; + return new Date(ms).toLocaleString(locale); +} + +export function SillyTavernChatsInfo({ + filesMeta, +}: { + filesMeta: SillyTavernFileMetaLike[] | null | undefined; +}) { + const meta = Array.isArray(filesMeta) ? filesMeta : []; + + const totalChats = meta.reduce( + (acc, m) => + acc + + (Number.isFinite(m.st_chats_count) ? (m.st_chats_count as number) : 0), + 0 + ); + + const lastChatAt = meta.reduce( + (acc, m) => + Math.max( + acc, + Number.isFinite(m.st_last_chat_at) ? (m.st_last_chat_at as number) : 0 + ), + 0 + ); + + const firstChatAt = meta.reduce((acc, m) => { + const v = Number.isFinite(m.st_first_chat_at) + ? (m.st_first_chat_at as number) + : 0; + if (v <= 0) return acc; + return acc === 0 ? v : Math.min(acc, v); + }, 0); + + return ( + <> + + + {i18n.t("cardDetails.stChatsCount")} + + + {totalChats > 0 ? String(totalChats) : i18n.t("empty.none")} + + + + + + {i18n.t("cardDetails.stLastChat")} + + {formatDateOrNone(lastChatAt)} + + + + + {i18n.t("cardDetails.stFirstChat")} + + {formatDateOrNone(firstChatAt)} + + + ); +} diff --git a/client/src/features/cards-filters/model.ts b/client/src/features/cards-filters/model.ts index f093578..da60060 100644 --- a/client/src/features/cards-filters/model.ts +++ b/client/src/features/cards-filters/model.ts @@ -4,10 +4,16 @@ import { createStore, sample, combine, + merge, } from "effector"; import { debounce } from "patronum/debounce"; import { getCardsFilters } from "@/shared/api/cards"; +import { + getCardsFiltersState, + updateCardsFiltersState, +} from "@/shared/api/cards-filters-state"; import type { CardsFiltersResponse } from "@/shared/types/cards-filters"; +import type { CardsFiltersState } from "@/shared/types/cards-filters-state"; import type { CardsFtsField, CardsQuery, @@ -19,67 +25,51 @@ import type { PatternRulesStatus } from "@/shared/types/pattern-rules-status"; import { getPatternRulesStatus } from "@/shared/api/pattern-rules"; import { loadCards, loadCardsSilent } from "@/entities/cards"; -export interface CardsFiltersState { - sort: CardsSort; - name: string; - q: string; - q_mode: CardsTextSearchMode; - q_fields: CardsFtsField[]; - creator: string[]; - spec_version: string[]; - tags: string[]; - created_from?: string; // YYYY-MM-DD - created_to?: string; // YYYY-MM-DD - prompt_tokens_min: number; - prompt_tokens_max: number; - has_creator_notes: TriState; - has_system_prompt: TriState; - has_post_history_instructions: TriState; - has_personality: TriState; - has_scenario: TriState; - has_mes_example: TriState; - has_character_book: TriState; - has_alternate_greetings: TriState; - alternate_greetings_min: number; - patterns: TriState; +function makeDefaultFilters(): CardsFiltersState { + return { + sort: "created_at_desc", + name: "", + q: "", + q_mode: "like", + q_fields: [ + "description", + "personality", + "scenario", + "first_mes", + "mes_example", + "creator_notes", + "system_prompt", + "post_history_instructions", + "alternate_greetings", + "group_only_greetings", + ], + creator: [], + spec_version: [], + tags: [], + created_from: undefined, + created_to: undefined, + prompt_tokens_min: 0, + prompt_tokens_max: 0, + is_sillytavern: "any", + is_hidden: "0", + fav: "any", + has_creator_notes: "any", + has_system_prompt: "any", + has_post_history_instructions: "any", + has_personality: "any", + has_scenario: "any", + has_mes_example: "any", + has_character_book: "any", + has_alternate_greetings: "any", + alternate_greetings_min: 0, + patterns: "any", + st_chats_count: undefined, + st_chats_count_op: "gte", + st_profile_handle: [], + st_hide_no_chats: false, + }; } -const DEFAULT_FILTERS: CardsFiltersState = { - sort: "created_at_desc", - name: "", - q: "", - q_mode: "like", - q_fields: [ - "description", - "personality", - "scenario", - "first_mes", - "mes_example", - "creator_notes", - "system_prompt", - "post_history_instructions", - "alternate_greetings", - "group_only_greetings", - ], - creator: [], - spec_version: [], - tags: [], - created_from: undefined, - created_to: undefined, - prompt_tokens_min: 0, - prompt_tokens_max: 0, - has_creator_notes: "any", - has_system_prompt: "any", - has_post_history_instructions: "any", - has_personality: "any", - has_scenario: "any", - has_mes_example: "any", - has_character_book: "any", - has_alternate_greetings: "any", - alternate_greetings_min: 0, - patterns: "any", -}; - function toLocalDayStartMs(dateStr: string): number | undefined { const d = new Date(`${dateStr}T00:00:00`); const t = d.getTime(); @@ -130,6 +120,9 @@ function toQuery(state: CardsFiltersState): CardsQuery { tags: state.tags, created_from_ms, created_to_ms, + is_sillytavern: state.is_sillytavern, + is_hidden: state.is_hidden, + fav: state.fav, prompt_tokens_min: state.prompt_tokens_min > 0 ? state.prompt_tokens_min : undefined, prompt_tokens_max: @@ -145,6 +138,21 @@ function toQuery(state: CardsFiltersState): CardsQuery { alternate_greetings_min: hasAlt === "0" ? undefined : effectiveMin > 0 ? effectiveMin : undefined, patterns: state.patterns, + st_chats_count: + typeof state.st_chats_count === "number" && + Number.isFinite(state.st_chats_count) && + state.st_chats_count >= 0 + ? state.st_chats_count + : undefined, + st_chats_count_op: state.st_chats_count_op ?? undefined, + st_profile_handle: + Array.isArray(state.st_profile_handle) && + state.st_profile_handle.length > 0 + ? state.st_profile_handle + .map((s) => String(s).trim()) + .filter((s) => s.length > 0) + : undefined, + st_has_chats: state.st_hide_no_chats ? "1" : undefined, }; return query; @@ -167,12 +175,45 @@ export const loadPatternRulesStatusFx = createEffect< return await getPatternRulesStatus(); }); +export const loadCardsFiltersStateFx = createEffect< + void, + CardsFiltersState, + Error +>(async () => { + return await getCardsFiltersState(); +}); + +export const saveCardsFiltersStateFx = createEffect< + CardsFiltersState, + CardsFiltersState, + Error +>(async (state) => { + return await updateCardsFiltersState(state); +}); + +// Kick-off init sequence (hydrate + then allow auto-apply) +export const initCardsFiltersFx = createEffect(async () => { + // fire-and-forget; store graph handles success/fail + try { + void loadCardsFiltersFx(); + } catch { + // ignore + } + try { + void loadCardsFiltersStateFx(); + } catch { + // ignore + } +}); + // Stores -export const $filters = createStore(DEFAULT_FILTERS); +export const $filters = createStore(makeDefaultFilters()); +export const $filtersReady = createStore(false); export const $filtersData = createStore({ creators: [], spec_versions: [], tags: [], + st_profiles: [], }); export const $filtersError = createStore(null); export const $filtersLoading = combine(loadCardsFiltersFx.pending, (p) => p); @@ -192,10 +233,16 @@ export const setQFields = createEvent(); export const setCreators = createEvent(); export const setSpecVersions = createEvent(); export const setTags = createEvent(); +// Internal: used when backend lists are refreshed (e.g. live sync) to sanitize +// selected tags without triggering "loud" cards reload + loader. +const syncTagsFromOptions = createEvent(); export const setCreatedFrom = createEvent(); export const setCreatedTo = createEvent(); export const setPromptTokensMin = createEvent(); export const setPromptTokensMax = createEvent(); +export const setIsSillyTavern = createEvent(); +export const setIsHidden = createEvent(); +export const setFav = createEvent(); export const setHasCreatorNotes = createEvent(); export const setHasSystemPrompt = createEvent(); export const setHasPostHistoryInstructions = createEvent(); @@ -206,9 +253,16 @@ export const setHasCharacterBook = createEvent(); export const setHasAlternateGreetings = createEvent(); export const setAlternateGreetingsMin = createEvent(); export const setPatterns = createEvent(); +export const setStChatsCount = createEvent(); +export const setStChatsCountOp = createEvent<"eq" | "gte" | "lte">(); +export const setStProfileHandle = createEvent(); +export const setStHideNoChats = createEvent(); export const resetFilters = createEvent(); export const applyFilters = createEvent(); export const applyFiltersSilent = createEvent(); +const hydrateFilters = createEvent(); +const hydrationFinished = createEvent(); +const setFiltersReady = createEvent(); export const applyTagsBulkEditToSelectedTags = createEvent<{ action: "replace" | "delete"; from_raw: string[]; // rawName (normalized) @@ -224,8 +278,12 @@ $filters .on(setCreators, (s, creator) => ({ ...s, creator })) .on(setSpecVersions, (s, spec_version) => ({ ...s, spec_version })) .on(setTags, (s, tags) => ({ ...s, tags })) + .on(syncTagsFromOptions, (s, tags) => ({ ...s, tags })) .on(setCreatedFrom, (s, created_from) => ({ ...s, created_from })) .on(setCreatedTo, (s, created_to) => ({ ...s, created_to })) + .on(setIsSillyTavern, (s, is_sillytavern) => ({ ...s, is_sillytavern })) + .on(setIsHidden, (s, is_hidden) => ({ ...s, is_hidden })) + .on(setFav, (s, fav) => ({ ...s, fav })) .on(setPromptTokensMin, (s, prompt_tokens_min) => { const min = Number.isFinite(prompt_tokens_min) ? Math.max(0, Math.floor(prompt_tokens_min)) @@ -276,6 +334,29 @@ $filters : 0, })) .on(setPatterns, (s, patterns) => ({ ...s, patterns })) + .on(setStChatsCount, (s, st_chats_count) => ({ + ...s, + st_chats_count: + typeof st_chats_count === "number" && Number.isFinite(st_chats_count) + ? Math.max(0, Math.floor(st_chats_count)) + : undefined, + })) + .on(setStChatsCountOp, (s, st_chats_count_op) => ({ + ...s, + st_chats_count_op, + })) + .on(setStProfileHandle, (s, st_profile_handle) => ({ + ...s, + st_profile_handle: Array.isArray(st_profile_handle) + ? st_profile_handle + .map((x) => String(x).trim()) + .filter((x) => x.length > 0) + : [], + })) + .on(setStHideNoChats, (s, st_hide_no_chats) => ({ + ...s, + st_hide_no_chats: Boolean(st_hide_no_chats), + })) .on(applyTagsBulkEditToSelectedTags, (s, payload) => { const normalize = (x: string) => x.trim().toLowerCase(); const fromSet = new Set(payload.from_raw.map((x) => normalize(String(x)))); @@ -311,7 +392,10 @@ $filters return { ...s, tags: out }; }) - .on(resetFilters, () => DEFAULT_FILTERS); + .on(resetFilters, () => makeDefaultFilters()); + +$filters.on(hydrateFilters, (_, next) => next); +$filtersReady.on(setFiltersReady, (_, v) => v); // sync filters response sample({ @@ -323,6 +407,18 @@ sample({ sample({ clock: loadCardsFiltersFx.doneData, source: $filters, + filter: (filters, data) => { + const normalize = (x: string) => x.trim().toLowerCase(); + const existing = new Set(data.tags.map((t) => normalize(t.value))); + const current = (filters.tags ?? []).map((t) => String(t)); + const next = current.filter((t) => existing.has(normalize(t))); + + if (current.length !== next.length) return true; + for (let i = 0; i < current.length; i++) { + if (normalize(current[i]) !== normalize(next[i])) return true; + } + return false; + }, fn: (filters, data) => { const normalize = (x: string) => x.trim().toLowerCase(); const existing = new Set(data.tags.map((t) => normalize(t.value))); @@ -331,7 +427,14 @@ sample({ ); return nextTags; }, - target: setTags, + target: syncTagsFromOptions, +}); + +// If backend lists refresh caused sanitization of selected tags, refresh cards silently +// to avoid loader "jitter" during live sync. +sample({ + clock: syncTagsFromOptions, + target: applyFiltersSilent, }); sample({ @@ -349,12 +452,33 @@ sample({ // Keep patterns status fresh when filters lists are refreshed sample({ clock: loadCardsFiltersFx, target: loadPatternRulesStatusFx }); +// Hydrate from persisted state on startup. +sample({ clock: loadCardsFiltersStateFx.doneData, target: hydrateFilters }); +sample({ + clock: loadCardsFiltersStateFx.failData, + fn: () => makeDefaultFilters(), + target: hydrateFilters, +}); + +sample({ clock: hydrateFilters, target: hydrationFinished }); + +// Allow auto-apply only after hydration is finished (even if we fell back to defaults) +sample({ clock: hydrationFinished, fn: () => true, target: setFiltersReady }); + +// First load: only after filters are hydrated +sample({ + clock: hydrationFinished, + source: $filters, + fn: (state) => toQuery(state), + target: loadCards, +}); + // Auto-apply: // - name changes are debounced // - q changes are debounced // - other changes apply immediately -const nameDebounced = debounce({ source: setName, timeout: 450 }); -const qDebounced = debounce({ source: setQ, timeout: 450 }); +const nameDebounced = debounce({ source: setName, timeout: 500 }); +const qDebounced = debounce({ source: setQ, timeout: 500 }); const immediateApplyClock = [ setSort, @@ -367,6 +491,9 @@ const immediateApplyClock = [ setCreatedTo, setPromptTokensMin, setPromptTokensMax, + setIsSillyTavern, + setIsHidden, + setFav, setHasCreatorNotes, setHasSystemPrompt, setHasPostHistoryInstructions, @@ -377,46 +504,112 @@ const immediateApplyClock = [ setHasAlternateGreetings, setAlternateGreetingsMin, setPatterns, + setStChatsCount, + setStChatsCountOp, + setStProfileHandle, + setStHideNoChats, ]; sample({ clock: immediateApplyClock, - source: $filters, - fn: (state) => toQuery(state), + source: { state: $filters, ready: $filtersReady }, + filter: ({ ready }) => ready, + fn: ({ state }) => toQuery(state), target: loadCards, }); sample({ clock: nameDebounced, - source: $filters, - fn: (state) => toQuery(state), + source: { state: $filters, ready: $filtersReady }, + filter: ({ ready }) => ready, + fn: ({ state }) => toQuery(state), target: loadCards, }); sample({ clock: qDebounced, - source: $filters, - fn: (state) => toQuery(state), + source: { state: $filters, ready: $filtersReady }, + filter: ({ ready }) => ready, + fn: ({ state }) => toQuery(state), target: loadCards, }); sample({ clock: applyFilters, - source: $filters, - fn: (state) => toQuery(state), + source: { state: $filters, ready: $filtersReady }, + filter: ({ ready }) => ready, + fn: ({ state }) => toQuery(state), target: loadCards, }); sample({ clock: applyFiltersSilent, - source: $filters, - fn: (state) => toQuery(state), + source: { state: $filters, ready: $filtersReady }, + filter: ({ ready }) => ready, + fn: ({ state }) => toQuery(state), target: loadCardsSilent, }); // Reset should deterministically apply defaults sample({ clock: resetFilters, - fn: () => toQuery(DEFAULT_FILTERS), + source: $filtersReady, + filter: (ready) => ready, + fn: () => toQuery(makeDefaultFilters()), target: loadCards, }); + +// Persist filters state to backend with debounce (500ms). +// Important: for text inputs (name/q) we persist only after their own debounce +// to avoid save request per keystroke. +const persistNonTextChanged = merge([ + setSort, + setQMode, + setQFields, + setCreators, + setSpecVersions, + setTags, + syncTagsFromOptions, + setCreatedFrom, + setCreatedTo, + setPromptTokensMin, + setPromptTokensMax, + setIsSillyTavern, + setIsHidden, + setFav, + setHasCreatorNotes, + setHasSystemPrompt, + setHasPostHistoryInstructions, + setHasPersonality, + setHasScenario, + setHasMesExample, + setHasCharacterBook, + setHasAlternateGreetings, + setAlternateGreetingsMin, + setPatterns, + setStChatsCount, + setStChatsCountOp, + setStProfileHandle, + setStHideNoChats, + applyTagsBulkEditToSelectedTags, + resetFilters, +]); + +const persistNonTextDebounced = debounce({ + source: persistNonTextChanged, + timeout: 500, +}); + +const persistClock = merge([ + persistNonTextDebounced, + nameDebounced, + qDebounced, +]); + +sample({ + clock: persistClock, + source: { state: $filters, ready: $filtersReady }, + filter: ({ ready }) => ready, + fn: ({ state }) => state, + target: saveCardsFiltersStateFx, +}); diff --git a/client/src/features/cards-filters/ui/CardsFiltersPanel.tsx b/client/src/features/cards-filters/ui/CardsFiltersPanel.tsx index 1e9fdd4..50ba975 100644 --- a/client/src/features/cards-filters/ui/CardsFiltersPanel.tsx +++ b/client/src/features/cards-filters/ui/CardsFiltersPanel.tsx @@ -1,239 +1,27 @@ import { useUnit } from "effector-react"; -import { - Alert, - Button, - Checkbox, - Divider, - Group, - NumberInput, - Select, - SimpleGrid, - Stack, - Text, - TextInput, - MultiSelect, - Tooltip, - ActionIcon, - SegmentedControl, -} from "@mantine/core"; +import { Alert, Stack } from "@mantine/core"; import { useTranslation } from "react-i18next"; -import { - $filters, - $filtersData, - $filtersError, - $filtersLoading, - $patternRulesStatus, - loadCardsFiltersFx, - resetFilters, - setAlternateGreetingsMin, - setCreatedFrom, - setCreatedTo, - setCreators, - setHasCharacterBook, - setHasCreatorNotes, - setHasMesExample, - setHasPersonality, - setHasPostHistoryInstructions, - setHasScenario, - setHasSystemPrompt, - setHasAlternateGreetings, - setName, - setQ, - setQMode, - setQFields, - setPromptTokensMax, - setPromptTokensMin, - setSort, - setSpecVersions, - setTags, - setPatterns, -} from "../model"; -import type { CardsFtsField, TriState } from "@/shared/types/cards-query"; -import { openPatternRulesModal } from "@/features/pattern-rules"; - -function InfoTip({ text }: { text: string }) { - const { t } = useTranslation(); - return ( - - - i - - - ); -} +import { $filtersError } from "../model"; +import { ActionsSection } from "./sections/ActionsSection"; +import { SearchAndSortSection } from "./sections/SearchAndSortSection"; +import { SourceSection } from "./sections/SourceSection"; +import { HiddenSection } from "./sections/HiddenSection"; +import { FavoritesSection } from "./sections/FavoritesSection"; +import { TextSearchSection } from "./sections/TextSearchSection"; +import { MetaSection } from "./sections/MetaSection"; +import { CreatedAtSection } from "./sections/CreatedAtSection"; +import { TokensSection } from "./sections/TokensSection"; +import { AltGreetingsSection } from "./sections/AltGreetingsSection"; +import { FieldsPresenceSection } from "./sections/FieldsPresenceSection"; +import { ChatsSection } from "./sections/ChatsSection"; export function CardsFiltersPanel() { const { t } = useTranslation(); - const [ - filters, - filtersData, - filtersError, - filtersLoading, - patternRulesStatus, - loadFilters, - onOpenPatternRules, - onSetSort, - onSetName, - onSetQ, - onSetQMode, - onSetQFields, - onSetCreators, - onSetSpecVersions, - onSetTags, - onSetCreatedFrom, - onSetCreatedTo, - onSetPromptTokensMin, - onSetPromptTokensMax, - onSetHasCreatorNotes, - onSetHasSystemPrompt, - onSetHasPostHistoryInstructions, - onSetHasPersonality, - onSetHasScenario, - onSetHasMesExample, - onSetHasCharacterBook, - onSetHasAlternateGreetings, - onSetAlternateGreetingsMin, - onSetPatterns, - onReset, - ] = useUnit([ - $filters, - $filtersData, - $filtersError, - $filtersLoading, - $patternRulesStatus, - loadCardsFiltersFx, - openPatternRulesModal, - setSort, - setName, - setQ, - setQMode, - setQFields, - setCreators, - setSpecVersions, - setTags, - setCreatedFrom, - setCreatedTo, - setPromptTokensMin, - setPromptTokensMax, - setHasCreatorNotes, - setHasSystemPrompt, - setHasPostHistoryInstructions, - setHasPersonality, - setHasScenario, - setHasMesExample, - setHasCharacterBook, - setHasAlternateGreetings, - setAlternateGreetingsMin, - setPatterns, - resetFilters, - ]); - - const TRI_STATE_DATA: Array<{ value: TriState; label: string }> = [ - { value: "any", label: t("filters.triAny") }, - { value: "1", label: t("filters.triHas") }, - { value: "0", label: t("filters.triHasNot") }, - ]; - - const SORT_DATA = [ - { value: "created_at_desc", label: t("filters.sortNewFirst") }, - { value: "created_at_asc", label: t("filters.sortOldFirst") }, - { value: "name_asc", label: t("filters.sortNameAsc") }, - { value: "name_desc", label: t("filters.sortNameDesc") }, - { value: "prompt_tokens_desc", label: t("filters.sortTokensDesc") }, - { value: "prompt_tokens_asc", label: t("filters.sortTokensAsc") }, - { value: "relevance", label: t("filters.sortRelevance") }, - ] as const; - - const isRelevanceWithoutQuery = - filters.sort === "relevance" && filters.q.trim().length === 0; - - const isRelevanceInLikeMode = - filters.sort === "relevance" && - filters.q.trim().length > 0 && - filters.q_mode !== "fts"; - - const FTS_FIELDS: Array<{ value: CardsFtsField; label: string }> = [ - { value: "description", label: t("filters.qFieldDescription") }, - { value: "personality", label: t("filters.qFieldPersonality") }, - { value: "scenario", label: t("filters.qFieldScenario") }, - { value: "first_mes", label: t("filters.qFieldFirstMes") }, - { value: "mes_example", label: t("filters.qFieldMesExample") }, - { value: "creator_notes", label: t("filters.qFieldCreatorNotes") }, - { value: "system_prompt", label: t("filters.qFieldSystemPrompt") }, - { - value: "post_history_instructions", - label: t("filters.qFieldPostHistoryInstructions"), - }, - { - value: "alternate_greetings", - label: t("filters.qFieldAlternateGreetings"), - }, - { - value: "group_only_greetings", - label: t("filters.qFieldGroupOnlyGreetings"), - }, - ]; - - function mergeOptions( - selected: string[], - serverOptions: Array<{ value: string; count: number }> - ): Array<{ value: string; label: string }> { - const map = new Map(); - for (const opt of serverOptions) map.set(opt.value, opt.count); - for (const sel of selected) { - if (!map.has(sel)) map.set(sel, 0); - } - return Array.from(map.entries()).map(([value, count]) => ({ - value, - label: `${value} (${count})`, - })); - } - - const creatorOptions = mergeOptions(filters.creator, filtersData.creators); - const specVersionOptions = mergeOptions( - filters.spec_version, - filtersData.spec_versions - ); - const tagOptions = mergeOptions(filters.tags, filtersData.tags); - - const patternsEnabled = filters.patterns === "1"; - const patternsAvailable = - patternRulesStatus == null ? true : patternRulesStatus.hasEnabledRules; - const patternsDisabled = !patternsAvailable; - const patternsNeedsRun = - patternsEnabled && - patternRulesStatus != null && - patternRulesStatus.lastReady == null; + const [filtersError] = useUnit([$filtersError]); return ( - - - - - - + {filtersError && ( @@ -241,317 +29,17 @@ export function CardsFiltersPanel() { )} - - - - onSetName(e.currentTarget.value)} - /> - - - {t("filters.presence")} - - - } - data={TRI_STATE_DATA as any} - value={filters.has_alternate_greetings} - onChange={(v) => onSetHasAlternateGreetings((v as TriState) || "any")} - /> - - onSetAlternateGreetingsMin(Number(v) || 0)} - /> - - - - - - onSetHasSystemPrompt((v as TriState) || "any")} - /> - onSetHasPersonality((v as TriState) || "any")} - /> - onSetHasMesExample((v as TriState) || "any")} - /> - + {t("filters.presence")} + + + } + data={TRI_STATE_DATA as any} + value={hasAlt} + onChange={(v) => onSetHasAlt((v as TriState) || "any")} + /> + + onSetMin(Number(v) || 0)} + /> + + + ); +} diff --git a/client/src/features/cards-filters/ui/sections/ChatsSection.tsx b/client/src/features/cards-filters/ui/sections/ChatsSection.tsx new file mode 100644 index 0000000..bec6bce --- /dev/null +++ b/client/src/features/cards-filters/ui/sections/ChatsSection.tsx @@ -0,0 +1,110 @@ +import { + Checkbox, + Divider, + MultiSelect, + NumberInput, + Select, + SimpleGrid, +} from "@mantine/core"; +import { useUnit } from "effector-react"; +import { useTranslation } from "react-i18next"; +import { + $filters, + $filtersData, + setStChatsCount, + setStChatsCountOp, + setStHideNoChats, + setStProfileHandle, +} from "../../model"; +import { mergeOptions } from "../shared/mergeOptions"; + +const $count = $filters.map((s) => s.st_chats_count, { skipVoid: false }); +const $op = $filters.map((s) => s.st_chats_count_op ?? "gte"); +const $profile = $filters.map((s) => s.st_profile_handle); +const $hideNoChats = $filters.map((s) => s.st_hide_no_chats); +const $profilesOptions = $filtersData.map((s) => s.st_profiles ?? []); + +export function ChatsSection() { + const { t } = useTranslation(); + const [ + count, + op, + profile, + hideNoChats, + profiles, + onSetCount, + onSetOp, + onSetProfile, + onSetHideNoChats, + ] = useUnit([ + $count, + $op, + $profile, + $hideNoChats, + $profilesOptions, + setStChatsCount, + setStChatsCountOp, + setStProfileHandle, + setStHideNoChats, + ]); + + const opData = [ + { value: "eq", label: t("filters.opEq") }, + { value: "gte", label: t("filters.opGte") }, + { value: "lte", label: t("filters.opLte") }, + ] as const; + + const profileData = mergeOptions(profile, profiles); + + return ( + <> + + + + { + if ( + v === "" || + v === null || + typeof v !== "number" || + !Number.isFinite(v) + ) { + onSetCount(undefined); + return; + } + onSetCount(Math.max(0, Math.floor(v))); + }} + /> + + onSetHasCreatorNotes((v as TriState) || "any")} + /> + onSetHasPostHistoryInstructions((v as TriState) || "any")} + /> + onSetHasScenario((v as TriState) || "any")} + /> + onSetHasCharacterBook((v as TriState) || "any")} + /> + + + ); +} + + diff --git a/client/src/features/cards-filters/ui/sections/HiddenSection.tsx b/client/src/features/cards-filters/ui/sections/HiddenSection.tsx new file mode 100644 index 0000000..8f63630 --- /dev/null +++ b/client/src/features/cards-filters/ui/sections/HiddenSection.tsx @@ -0,0 +1,32 @@ +import { useUnit } from "effector-react"; +import { SegmentedControl, Stack, Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import type { TriState } from "@/shared/types/cards-query"; +import { $filters, setIsHidden } from "../../model"; + +const $isHidden = $filters.map((s) => s.is_hidden); + +export function HiddenSection() { + const { t } = useTranslation(); + const [isHidden, onSetIsHidden] = useUnit([$isHidden, setIsHidden]); + + return ( + + + {t("filters.hidden")} + + onSetIsHidden(value as TriState)} + data={[ + { value: "0", label: t("filters.hiddenExclude") }, + { value: "any", label: t("filters.hiddenInclude") }, + { value: "1", label: t("filters.hiddenOnly") }, + ]} + /> + + ); +} + + diff --git a/client/src/features/cards-filters/ui/sections/MetaSection.tsx b/client/src/features/cards-filters/ui/sections/MetaSection.tsx new file mode 100644 index 0000000..f6f1eeb --- /dev/null +++ b/client/src/features/cards-filters/ui/sections/MetaSection.tsx @@ -0,0 +1,145 @@ +import { + Button, + Checkbox, + Divider, + Group, + MultiSelect, + SimpleGrid, + Text, +} from "@mantine/core"; +import { useUnit } from "effector-react"; +import { useTranslation } from "react-i18next"; +import { InfoTip } from "../shared/InfoTip"; +import { + $filters, + $filtersData, + $patternRulesStatus, + setCreators, + setPatterns, + setSpecVersions, + setTags, +} from "../../model"; +import { openPatternRulesModal } from "@/features/pattern-rules"; +import { mergeOptions } from "../shared/mergeOptions"; + +const $creator = $filters.map((s) => s.creator); +const $specVersion = $filters.map((s) => s.spec_version); +const $tags = $filters.map((s) => s.tags); +const $patterns = $filters.map((s) => s.patterns); + +export function MetaSection() { + const { t } = useTranslation(); + const [ + creator, + specVersion, + tags, + patterns, + filtersData, + patternRulesStatus, + onOpenPatternRules, + onSetCreators, + onSetSpecVersions, + onSetTags, + onSetPatterns, + ] = useUnit([ + $creator, + $specVersion, + $tags, + $patterns, + $filtersData, + $patternRulesStatus, + openPatternRulesModal, + setCreators, + setSpecVersions, + setTags, + setPatterns, + ]); + + const creatorOptions = mergeOptions(creator, filtersData.creators); + const specVersionOptions = mergeOptions( + specVersion, + filtersData.spec_versions + ); + const tagOptions = mergeOptions(tags, filtersData.tags); + + const patternsEnabled = patterns === "1"; + const patternsAvailable = + patternRulesStatus == null ? true : patternRulesStatus.hasEnabledRules; + const patternsDisabled = !patternsAvailable; + const patternsNeedsRun = + patternsEnabled && + patternRulesStatus != null && + patternRulesStatus.lastReady == null; + + return ( + <> + + {t("filters.meta")} + + } + /> + + + onSetPatterns(e.currentTarget.checked ? "1" : "any")} + /> + + + + + + {patternsDisabled + ? t("filters.patternsDisabled") + : patternsNeedsRun + ? t("filters.patternsNeedsRun") + : t("filters.patternsHint")} + + + + + + + + + {t("filters.tags")} + + + } + data={tagOptions} + value={tags} + onChange={onSetTags} + searchable + clearable + /> + + + ); +} diff --git a/client/src/features/cards-filters/ui/sections/SearchAndSortSection.tsx b/client/src/features/cards-filters/ui/sections/SearchAndSortSection.tsx new file mode 100644 index 0000000..5746b45 --- /dev/null +++ b/client/src/features/cards-filters/ui/sections/SearchAndSortSection.tsx @@ -0,0 +1,80 @@ +import { useUnit } from "effector-react"; +import { Divider, Select, SimpleGrid, Text, TextInput } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import type { CardsSort } from "@/shared/types/cards-query"; +import { $filters, setName, setSort } from "../../model"; + +const SORT_DATA = [ + { value: "created_at_desc", label: "filters.sortNewFirst" }, + { value: "created_at_asc", label: "filters.sortOldFirst" }, + { value: "name_asc", label: "filters.sortNameAsc" }, + { value: "name_desc", label: "filters.sortNameDesc" }, + { value: "prompt_tokens_desc", label: "filters.sortTokensDesc" }, + { value: "prompt_tokens_asc", label: "filters.sortTokensAsc" }, + { value: "st_chats_count_desc", label: "filters.sortChatsCountDesc" }, + { value: "st_chats_count_asc", label: "filters.sortChatsCountAsc" }, + { value: "st_last_chat_at_desc", label: "filters.sortLastChatDesc" }, + { value: "st_last_chat_at_asc", label: "filters.sortLastChatAsc" }, + { value: "st_first_chat_at_desc", label: "filters.sortFirstChatDesc" }, + { value: "st_first_chat_at_asc", label: "filters.sortFirstChatAsc" }, + { value: "relevance", label: "filters.sortRelevance" }, +] as const; + +const $name = $filters.map((s) => s.name); +const $sort = $filters.map((s) => s.sort); +const $q = $filters.map((s) => s.q); +const $qMode = $filters.map((s) => s.q_mode); + +export function SearchAndSortSection() { + const { t } = useTranslation(); + const [name, sort, q, qMode, onSetName, onSetSort] = useUnit([ + $name, + $sort, + $q, + $qMode, + setName, + setSort, + ]); + + const isRelevanceWithoutQuery = sort === "relevance" && q.trim().length === 0; + const isRelevanceInLikeMode = + sort === "relevance" && q.trim().length > 0 && qMode !== "fts"; + + return ( + <> + + + + onSetName(e.currentTarget.value)} + /> + +