From eab0966ed0825dc4b9e37682ee3f35f4c844c706 Mon Sep 17 00:00:00 2001 From: "DESKTOP-80A4L2N\\dima2" Date: Mon, 29 Dec 2025 17:47:43 +0300 Subject: [PATCH 01/28] feat: add folder picker for SillyTavern path in settings form --- .../settings-form/ui/SettingsForm.tsx | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/client/src/features/settings-form/ui/SettingsForm.tsx b/client/src/features/settings-form/ui/SettingsForm.tsx index aaa8f41..c3c65d0 100644 --- a/client/src/features/settings-form/ui/SettingsForm.tsx +++ b/client/src/features/settings-form/ui/SettingsForm.tsx @@ -63,6 +63,8 @@ export function SettingsForm({ saveSettingsFx, ]); const [isPickingFolder, setIsPickingFolder] = useState(false); + const [isPickingSillyTavernFolder, setIsPickingSillyTavernFolder] = + useState(false); const FolderIcon = ({ size = 16 }: { size?: number }) => ( + { + if (isLoading || isPickingSillyTavernFolder) return; + setIsPickingSillyTavernFolder(true); + void pickFolder(t("settingsForm.pickFolderDialogTitle")) + .then((res) => { + if (res.cancelled || !res.path) return; + form.setFieldValue("sillytavenrPath", res.path); + }) + .catch((e) => { + const msg = + e instanceof Error && e.message.trim() + ? e.message + : t("settingsForm.pickFolderFailed"); + notifications.show({ + title: t("settingsForm.sillyTavernPathLabel"), + message: msg, + color: "red", + }); + }) + .finally(() => setIsPickingSillyTavernFolder(false)); + }} + disabled={isLoading || isPickingSillyTavernFolder} + aria-label={t("settingsForm.pickFolderAria")} + > + {isPickingSillyTavernFolder ? ( + + ) : ( + + )} + + + } {...form.getInputProps("sillytavenrPath")} /> From da28f03900040e21af06c303b6b24691bd221d51 Mon Sep 17 00:00:00 2001 From: "DESKTOP-80A4L2N\\dima2" Date: Mon, 29 Dec 2025 18:16:40 +0300 Subject: [PATCH 02/28] feat: add scan cards from sillytavern folder --- client/src/entities/cards/ui/Card.tsx | 30 +++++ client/src/features/cards-filters/model.ts | 6 + .../cards-filters/ui/CardsFiltersPanel.tsx | 19 +++ client/src/shared/api/cards/index.ts | 2 + client/src/shared/i18n/locales/en.ts | 5 + client/src/shared/i18n/locales/ru.ts | 5 + client/src/shared/types/cards-query/index.ts | 1 + client/src/shared/types/cards/index.ts | 2 + server/src/plugins/database.ts | 6 + server/src/plugins/scanner.ts | 13 ++ server/src/routes/api/cards.ts | 31 +++-- server/src/routes/api/settings.ts | 17 +++ server/src/services/cards-filters.ts | 40 +++++-- .../src/services/cards-sync-orchestrator.ts | 33 ++++-- server/src/services/cards.ts | 18 ++- server/src/services/scan.ts | 112 ++++++++++++++++-- server/src/services/sillytavern.ts | 85 +++++++++++++ 17 files changed, 388 insertions(+), 37 deletions(-) create mode 100644 server/src/services/sillytavern.ts diff --git a/client/src/entities/cards/ui/Card.tsx b/client/src/entities/cards/ui/Card.tsx index 558bcf9..6e3de4d 100644 --- a/client/src/entities/cards/ui/Card.tsx +++ b/client/src/entities/cards/ui/Card.tsx @@ -71,6 +71,7 @@ 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); return ( <> @@ -202,6 +203,35 @@ export function Card({ + + {isSillyTavern && ( + + + ST + + + )} diff --git a/client/src/features/cards-filters/model.ts b/client/src/features/cards-filters/model.ts index f093578..802c2c2 100644 --- a/client/src/features/cards-filters/model.ts +++ b/client/src/features/cards-filters/model.ts @@ -32,6 +32,7 @@ export interface CardsFiltersState { created_to?: string; // YYYY-MM-DD prompt_tokens_min: number; prompt_tokens_max: number; + is_sillytavern: TriState; has_creator_notes: TriState; has_system_prompt: TriState; has_post_history_instructions: TriState; @@ -68,6 +69,7 @@ const DEFAULT_FILTERS: CardsFiltersState = { created_to: undefined, prompt_tokens_min: 0, prompt_tokens_max: 0, + is_sillytavern: "any", has_creator_notes: "any", has_system_prompt: "any", has_post_history_instructions: "any", @@ -130,6 +132,7 @@ function toQuery(state: CardsFiltersState): CardsQuery { tags: state.tags, created_from_ms, created_to_ms, + is_sillytavern: state.is_sillytavern, prompt_tokens_min: state.prompt_tokens_min > 0 ? state.prompt_tokens_min : undefined, prompt_tokens_max: @@ -196,6 +199,7 @@ export const setCreatedFrom = createEvent(); export const setCreatedTo = createEvent(); export const setPromptTokensMin = createEvent(); export const setPromptTokensMax = createEvent(); +export const setIsSillyTavern = createEvent(); export const setHasCreatorNotes = createEvent(); export const setHasSystemPrompt = createEvent(); export const setHasPostHistoryInstructions = createEvent(); @@ -226,6 +230,7 @@ $filters .on(setTags, (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(setPromptTokensMin, (s, prompt_tokens_min) => { const min = Number.isFinite(prompt_tokens_min) ? Math.max(0, Math.floor(prompt_tokens_min)) @@ -367,6 +372,7 @@ const immediateApplyClock = [ setCreatedTo, setPromptTokensMin, setPromptTokensMax, + setIsSillyTavern, setHasCreatorNotes, setHasSystemPrompt, setHasPostHistoryInstructions, diff --git a/client/src/features/cards-filters/ui/CardsFiltersPanel.tsx b/client/src/features/cards-filters/ui/CardsFiltersPanel.tsx index 1e9fdd4..888c5fc 100644 --- a/client/src/features/cards-filters/ui/CardsFiltersPanel.tsx +++ b/client/src/features/cards-filters/ui/CardsFiltersPanel.tsx @@ -37,6 +37,7 @@ import { setHasScenario, setHasSystemPrompt, setHasAlternateGreetings, + setIsSillyTavern, setName, setQ, setQMode, @@ -90,6 +91,7 @@ export function CardsFiltersPanel() { onSetCreatedTo, onSetPromptTokensMin, onSetPromptTokensMax, + onSetIsSillyTavern, onSetHasCreatorNotes, onSetHasSystemPrompt, onSetHasPostHistoryInstructions, @@ -121,6 +123,7 @@ export function CardsFiltersPanel() { setCreatedTo, setPromptTokensMin, setPromptTokensMax, + setIsSillyTavern, setHasCreatorNotes, setHasSystemPrompt, setHasPostHistoryInstructions, @@ -261,6 +264,22 @@ export function CardsFiltersPanel() { /> + + + {t("filters.source")} + + onSetIsSillyTavern(value as TriState)} + data={[ + { value: "any", label: t("filters.sourceAll") }, + { value: "1", label: t("filters.sourceOnlySt") }, + { value: "0", label: t("filters.sourceOnlyFolder") }, + ]} + /> + + {isRelevanceWithoutQuery && ( {t("filters.relevanceNeedsQuery")} diff --git a/client/src/shared/api/cards/index.ts b/client/src/shared/api/cards/index.ts index 1c9e42a..9b7abac 100644 --- a/client/src/shared/api/cards/index.ts +++ b/client/src/shared/api/cards/index.ts @@ -53,6 +53,8 @@ export async function getCards(query?: CardsQuery): Promise { if (query?.has_alternate_greetings) params.set("has_alternate_greetings", query.has_alternate_greetings); + if (query?.is_sillytavern) params.set("is_sillytavern", query.is_sillytavern); + if (query?.patterns) params.set("patterns", query.patterns); if (typeof query?.alternate_greetings_min === "number") diff --git a/client/src/shared/i18n/locales/en.ts b/client/src/shared/i18n/locales/en.ts index beb8a18..50f10f2 100644 --- a/client/src/shared/i18n/locales/en.ts +++ b/client/src/shared/i18n/locales/en.ts @@ -151,6 +151,10 @@ const en = { tags: "Tags", tagsTip: "Works as AND: a card must contain all selected tags.", localDayTip: "Filtering by days in local time.", + source: "Source", + sourceAll: "All", + sourceOnlySt: "Only ST", + sourceOnlyFolder: "Only from folder", triAny: "Any", triHas: "Has", triHasNot: "Has not", @@ -185,6 +189,7 @@ const en = { altGreetingsCount: "Alternate greetings (count)", tokensEstimateTip: "Tokens estimate (approx.)", createdAtTip: "Created date (from file)", + stBadgeTip: "Card from SillyTavern", imageTitleFallback: "Card image", }, cardDetails: { diff --git a/client/src/shared/i18n/locales/ru.ts b/client/src/shared/i18n/locales/ru.ts index 40fea18..474cb84 100644 --- a/client/src/shared/i18n/locales/ru.ts +++ b/client/src/shared/i18n/locales/ru.ts @@ -153,6 +153,10 @@ const ru = { tags: "Теги", tagsTip: "Работает по AND: карточка должна содержать все выбранные теги.", localDayTip: "Фильтрация по суткам в локальном времени.", + source: "Источник", + sourceAll: "Все", + sourceOnlySt: "Только ST", + sourceOnlyFolder: "Только из папки", triAny: "Не важно", triHas: "Только с", triHasNot: "Только без", @@ -187,6 +191,7 @@ const ru = { altGreetingsCount: "Альтернативные приветствия (кол-во)", tokensEstimateTip: "Оценка токенов (примерно)", createdAtTip: "Дата создания (по файлу)", + stBadgeTip: "Карточка из SillyTavern", imageTitleFallback: "Изображение карточки", }, cardDetails: { diff --git a/client/src/shared/types/cards-query/index.ts b/client/src/shared/types/cards-query/index.ts index be355e2..5e3fc55 100644 --- a/client/src/shared/types/cards-query/index.ts +++ b/client/src/shared/types/cards-query/index.ts @@ -34,6 +34,7 @@ export interface CardsQuery { tags?: string[]; created_from_ms?: number; created_to_ms?: number; + is_sillytavern?: TriState; has_creator_notes?: TriState; has_system_prompt?: TriState; has_post_history_instructions?: TriState; diff --git a/client/src/shared/types/cards/index.ts b/client/src/shared/types/cards/index.ts index 42f6279..c84ad9e 100644 --- a/client/src/shared/types/cards/index.ts +++ b/client/src/shared/types/cards/index.ts @@ -7,6 +7,7 @@ export interface CardListItem { file_path: string | null; spec_version: string | null; created_at: number; + is_sillytavern: boolean; alternate_greetings_count: number; has_character_book: boolean; prompt_tokens_est: number; @@ -19,6 +20,7 @@ export interface CardDetails { tags: string[] | null; spec_version: string | null; created_at: number; + is_sillytavern: boolean; file_path: string | null; file_paths?: string[]; duplicates?: string[]; diff --git a/server/src/plugins/database.ts b/server/src/plugins/database.ts index b928d86..fc0529e 100644 --- a/server/src/plugins/database.ts +++ b/server/src/plugins/database.ts @@ -183,6 +183,11 @@ function initializeSchema(db: Database.Database): void { "library_id", "library_id TEXT NOT NULL DEFAULT 'cards'" ); + addColumnIfMissing( + "cards", + "is_sillytavern", + "is_sillytavern INTEGER NOT NULL DEFAULT 0" + ); addColumnIfMissing("cards", "content_hash", "content_hash TEXT"); addColumnIfMissing("cards", "personality", "personality TEXT"); addColumnIfMissing("cards", "scenario", "scenario TEXT"); @@ -259,6 +264,7 @@ function initializeSchema(db: Database.Database): void { CREATE INDEX IF NOT EXISTS idx_cards_creator ON cards(creator); CREATE INDEX IF NOT EXISTS idx_cards_spec_version ON cards(spec_version); CREATE INDEX IF NOT EXISTS idx_cards_library_id ON cards(library_id); + CREATE INDEX IF NOT EXISTS idx_cards_is_sillytavern ON cards(is_sillytavern); CREATE INDEX IF NOT EXISTS idx_cards_content_hash ON cards(content_hash); CREATE INDEX IF NOT EXISTS idx_cards_has_creator_notes ON cards(has_creator_notes); CREATE INDEX IF NOT EXISTS idx_cards_has_system_prompt ON cards(has_system_prompt); diff --git a/server/src/plugins/scanner.ts b/server/src/plugins/scanner.ts index 0cbe49b..d8fb4e2 100644 --- a/server/src/plugins/scanner.ts +++ b/server/src/plugins/scanner.ts @@ -51,6 +51,19 @@ export async function initializeScannerWithOrchestrator( const libraryId = getOrCreateLibraryId(db, settings.cardsFolderPath); orchestrator.requestScan("app", settings.cardsFolderPath, libraryId); } + + if (settings.sillytavenrPath !== null && existsSync(settings.sillytavenrPath)) { + logger.infoKey("log.scanner.autoStart", { + folderPath: settings.sillytavenrPath, + }); + const libraryId = getOrCreateLibraryId(db, settings.sillytavenrPath); + orchestrator.requestScan( + "app", + settings.sillytavenrPath, + libraryId, + "sillytavern" + ); + } } catch (error) { logger.errorKey(error, "error.scanner.readSettingsFailed"); } diff --git a/server/src/routes/api/cards.ts b/server/src/routes/api/cards.ts index 720c59b..dd71f94 100644 --- a/server/src/routes/api/cards.ts +++ b/server/src/routes/api/cards.ts @@ -101,13 +101,19 @@ router.get("/cards", async (req: Request, res: Response) => { const cardsService = createCardsService(db); const settings = await getSettings(); - if (!settings.cardsFolderPath) { + const libraryIds: string[] = []; + if (settings.cardsFolderPath) { + libraryIds.push(getOrCreateLibraryId(db, settings.cardsFolderPath)); + } + if (settings.sillytavenrPath) { + libraryIds.push(getOrCreateLibraryId(db, settings.sillytavenrPath)); + } + + if (libraryIds.length === 0) { res.json([]); return; } - const libraryId = getOrCreateLibraryId(db, settings.cardsFolderPath); - const sortRaw = parseString(req.query.sort); const sort: CardsSort | undefined = sortRaw === "created_at_desc" || @@ -250,7 +256,7 @@ router.get("/cards", async (req: Request, res: Response) => { const promptTokensMax = parseNumber((req.query as any).prompt_tokens_max); const params: SearchCardsParams = { - library_id: libraryId, + library_ids: libraryIds, sort, name, q, @@ -261,6 +267,7 @@ router.get("/cards", async (req: Request, res: Response) => { tags: tags.length > 0 ? tags : undefined, created_from_ms, created_to_ms, + is_sillytavern: parseTriState((req.query as any).is_sillytavern), has_creator_notes: parseTriState((req.query as any).has_creator_notes), has_system_prompt: parseTriState((req.query as any).has_system_prompt), has_post_history_instructions: parseTriState( @@ -393,13 +400,20 @@ router.get("/cards/filters", async (req: Request, res: Response) => { const filtersService = createCardsFiltersService(db); const settings = await getSettings(); - if (!settings.cardsFolderPath) { + const libraryIds: string[] = []; + if (settings.cardsFolderPath) { + libraryIds.push(getOrCreateLibraryId(db, settings.cardsFolderPath)); + } + if (settings.sillytavenrPath) { + libraryIds.push(getOrCreateLibraryId(db, settings.sillytavenrPath)); + } + + if (libraryIds.length === 0) { res.json({ creators: [], spec_versions: [], tags: [] }); return; } - const libraryId = getOrCreateLibraryId(db, settings.cardsFolderPath); - res.json(filtersService.getFilters(libraryId)); + res.json(filtersService.getFilters(libraryIds)); } catch (error) { logger.errorKey(error, "api.cards.filters_failed"); return sendError(res, error, { @@ -426,6 +440,7 @@ router.get("/cards/:id", async (req: Request, res: Response) => { c.creator, c.spec_version, c.created_at, + c.is_sillytavern, c.avatar_path, c.data_json, c.primary_file_path, @@ -466,6 +481,7 @@ router.get("/cards/:id", async (req: Request, res: Response) => { creator: string | null; spec_version: string | null; created_at: number; + is_sillytavern: number; avatar_path: string | null; data_json: string; primary_file_path: string | null; @@ -553,6 +569,7 @@ router.get("/cards/:id", async (req: Request, res: Response) => { tags: tags ?? null, spec_version: row.spec_version, created_at: row.created_at, + is_sillytavern: row.is_sillytavern === 1, file_path: main_file_path, file_paths, duplicates, diff --git a/server/src/routes/api/settings.ts b/server/src/routes/api/settings.ts index 7c6341c..5c9c3db 100644 --- a/server/src/routes/api/settings.ts +++ b/server/src/routes/api/settings.ts @@ -81,6 +81,8 @@ router.put("/settings", async (req: Request, res: Response) => { const prevPath = prevSettings.cardsFolderPath; const nextPath = savedSettings.cardsFolderPath; + const prevStPath = prevSettings.sillytavenrPath; + const nextStPath = savedSettings.sillytavenrPath; const db = getDb(req); // Перезапускаем watcher, если путь изменился @@ -107,6 +109,21 @@ router.put("/settings", async (req: Request, res: Response) => { } } + // Запускаем SillyTavern scan, если путь изменился (или был установлен) + if (prevStPath !== nextStPath && nextStPath !== null) { + try { + const libraryId = getOrCreateLibraryId(db, nextStPath); + getOrchestrator(req).requestScan( + "app", + nextStPath, + libraryId, + "sillytavern" + ); + } catch (error) { + logger.errorKey(error, "error.settings.postSettingsSyncFailed"); + } + } + res.json(savedSettings); } catch (error) { logger.errorKey(error, "api.settings.update_failed"); diff --git a/server/src/services/cards-filters.ts b/server/src/services/cards-filters.ts index 2de7324..12dd7ac 100644 --- a/server/src/services/cards-filters.ts +++ b/server/src/services/cards-filters.ts @@ -15,35 +15,49 @@ export interface CardsFiltersResponse { export class CardsFiltersService { constructor(private dbService: DatabaseService) {} - getCreators(libraryId: string): FilterOption[] { + private buildLibraryWhere(libraryIds: string | string[]): { + whereSql: string; + params: string[]; + } { + const ids = Array.isArray(libraryIds) ? libraryIds : [libraryIds]; + const clean = ids.map((s) => String(s).trim()).filter((s) => s.length > 0); + if (clean.length === 0) return { whereSql: "1=0", params: [] }; + const placeholders = clean.map(() => "?").join(", "); + return { whereSql: `c.library_id IN (${placeholders})`, params: clean }; + } + + getCreators(libraryIds: string | string[]): FilterOption[] { + const lib = this.buildLibraryWhere(libraryIds); const sql = ` SELECT c.creator as value, COUNT(*) as count FROM cards c - WHERE c.library_id = ? AND c.creator IS NOT NULL AND TRIM(c.creator) != '' + WHERE ${lib.whereSql} AND c.creator IS NOT NULL AND TRIM(c.creator) != '' GROUP BY c.creator ORDER BY count DESC, value COLLATE NOCASE ASC `; - return this.dbService.query(sql, [libraryId]); + return this.dbService.query(sql, lib.params); } - getSpecVersions(libraryId: string): FilterOption[] { + getSpecVersions(libraryIds: string | string[]): FilterOption[] { + const lib = this.buildLibraryWhere(libraryIds); const sql = ` SELECT c.spec_version as value, COUNT(*) as count FROM cards c - WHERE c.library_id = ? AND c.spec_version IS NOT NULL AND TRIM(c.spec_version) != '' + WHERE ${lib.whereSql} AND c.spec_version IS NOT NULL AND TRIM(c.spec_version) != '' GROUP BY c.spec_version ORDER BY count DESC, value COLLATE NOCASE ASC `; - return this.dbService.query(sql, [libraryId]); + return this.dbService.query(sql, lib.params); } - getTags(libraryId: string): FilterOption[] { + getTags(libraryIds: string | string[]): FilterOption[] { + const lib = this.buildLibraryWhere(libraryIds); const sql = ` SELECT t.name as value, @@ -51,19 +65,19 @@ export class CardsFiltersService { FROM tags t JOIN card_tags ct ON ct.tag_rawName = t.rawName JOIN cards c ON c.id = ct.card_id - WHERE c.library_id = ? + WHERE ${lib.whereSql} GROUP BY t.rawName, t.name ORDER BY count DESC, value COLLATE NOCASE ASC `; - return this.dbService.query(sql, [libraryId]); + return this.dbService.query(sql, lib.params); } - getFilters(libraryId: string): CardsFiltersResponse { + getFilters(libraryIds: string | string[]): CardsFiltersResponse { return { - creators: this.getCreators(libraryId), - spec_versions: this.getSpecVersions(libraryId), - tags: this.getTags(libraryId), + creators: this.getCreators(libraryIds), + spec_versions: this.getSpecVersions(libraryIds), + tags: this.getTags(libraryIds), }; } } diff --git a/server/src/services/cards-sync-orchestrator.ts b/server/src/services/cards-sync-orchestrator.ts index 1b7284e..80f07a7 100644 --- a/server/src/services/cards-sync-orchestrator.ts +++ b/server/src/services/cards-sync-orchestrator.ts @@ -5,6 +5,7 @@ import { createDatabaseService } from "./database"; import type { SseHub } from "./sse-hub"; export type SyncOrigin = "fs" | "app"; +export type ScanMode = "folder" | "sillytavern"; export type CardsResyncedPayload = { revision: number; @@ -55,33 +56,38 @@ export class CardsSyncOrchestrator { private revision = 0; private lastFolderPath: string | null = null; private lastLibraryId: string | null = null; + private lastScanMode: ScanMode | null = null; constructor(private db: Database.Database, private hub: SseHub) {} requestScan( origin: SyncOrigin, folderPath: string, - libraryId: string = "cards" + libraryId: string = "cards", + scanMode: ScanMode = "folder" ): void { this.lastFolderPath = folderPath; this.lastLibraryId = libraryId; + this.lastScanMode = scanMode; if (this.running) { this.requestedAgain = true; return; } - void this.runLoop(origin, folderPath, libraryId); + void this.runLoop(origin, folderPath, libraryId, scanMode); } private async runLoop( origin: SyncOrigin, folderPath: string, - libraryId: string + libraryId: string, + scanMode: ScanMode ): Promise { this.running = true; try { let currentOrigin: SyncOrigin = origin; let currentPath = folderPath; let currentLibraryId = libraryId; + let currentScanMode: ScanMode = scanMode; // loop if events arrived during scan // eslint-disable-next-line no-constant-condition @@ -102,13 +108,12 @@ export class CardsSyncOrchestrator { ); const before = beforeRow?.count ?? 0; - const scanService = createScanService(this.db, currentLibraryId); let totalFiles = 0; let processedFiles = 0; let startedSent = false; - const scanResult = await scanService.scanFolder(currentPath, { - onStart: (total) => { + const scanOpts = { + onStart: (total: number) => { totalFiles = total; startedSent = true; const payload: CardsScanStartedPayload = { @@ -123,7 +128,7 @@ export class CardsSyncOrchestrator { id: `${scanRevision}:scan_started`, }); }, - onProgress: (processed, total) => { + onProgress: (processed: number, total: number) => { processedFiles = processed; totalFiles = total; const payload: CardsScanProgressPayload = { @@ -139,7 +144,18 @@ export class CardsSyncOrchestrator { id: `${scanRevision}:scan_progress:${processed}`, }); }, - }); + }; + + const scanService = createScanService( + this.db, + currentLibraryId, + currentScanMode === "sillytavern" + ); + + const scanResult = + currentScanMode === "sillytavern" + ? await scanService.scanSillyTavern(currentPath, scanOpts) + : await scanService.scanFolder(currentPath, scanOpts); // If folder had no PNG files, onStart might never fire (edge cases). Ensure we emit started at least once. if (!startedSent) { @@ -221,6 +237,7 @@ export class CardsSyncOrchestrator { currentOrigin = "fs"; currentPath = this.lastFolderPath ?? currentPath; currentLibraryId = this.lastLibraryId ?? currentLibraryId; + currentScanMode = this.lastScanMode ?? currentScanMode; } } catch (error) { logger.errorKey(error, "error.cardsSync.failed"); diff --git a/server/src/services/cards.ts b/server/src/services/cards.ts index 6c4bb6b..b36e6cc 100644 --- a/server/src/services/cards.ts +++ b/server/src/services/cards.ts @@ -11,6 +11,7 @@ export interface CardListItem { file_path: string | null; spec_version: string | null; created_at: number; + is_sillytavern: boolean; alternate_greetings_count: number; has_character_book: boolean; prompt_tokens_est: number; @@ -43,6 +44,8 @@ export type CardsTextSearchMode = "like" | "fts"; export interface SearchCardsParams { library_id?: string; + library_ids?: string[]; + is_sillytavern?: TriState; sort?: CardsSort; name?: string; q?: string; @@ -97,7 +100,16 @@ export class CardsService { const effectiveSort: Exclude | "relevance" = sort === "relevance" && (!hasQ || qMode !== "fts") ? "created_at_desc" : sort; - if (params.library_id && params.library_id.trim().length > 0) { + const libraryIds = + Array.isArray(params.library_ids) && params.library_ids.length > 0 + ? params.library_ids.map((s) => String(s).trim()).filter((s) => s.length > 0) + : []; + + if (libraryIds.length > 0) { + const placeholders = libraryIds.map(() => "?").join(", "); + where.push(`c.library_id IN (${placeholders})`); + sqlParams.push(...libraryIds); + } else if (params.library_id && params.library_id.trim().length > 0) { where.push(`c.library_id = ?`); sqlParams.push(params.library_id.trim()); } @@ -150,6 +162,7 @@ export class CardsService { sqlParams.push(value === "1" ? 1 : 0); }; + addTriState("c.is_sillytavern", params.is_sillytavern); addTriState("c.has_creator_notes", params.has_creator_notes); addTriState("c.has_system_prompt", params.has_system_prompt); addTriState( @@ -390,6 +403,7 @@ export class CardsService { c.creator, c.spec_version, c.created_at, + c.is_sillytavern, c.alternate_greetings_count, c.has_character_book, c.prompt_tokens_est, @@ -419,6 +433,7 @@ export class CardsService { creator: string | null; spec_version: string | null; created_at: number; + is_sillytavern: number; alternate_greetings_count: number; has_character_book: number; prompt_tokens_est: number; @@ -449,6 +464,7 @@ export class CardsService { file_path: row.file_path, spec_version: row.spec_version, created_at: row.created_at, + is_sillytavern: row.is_sillytavern === 1, alternate_greetings_count: Number.isFinite( row.alternate_greetings_count ) diff --git a/server/src/services/scan.ts b/server/src/services/scan.ts index 3c2c812..748530f 100644 --- a/server/src/services/scan.ts +++ b/server/src/services/scan.ts @@ -11,6 +11,7 @@ import { createTagService } from "./tags"; import { computeContentHash } from "./card-hash"; import { createLorebooksService, LorebooksService } from "./lorebooks"; import { logger } from "../utils/logger"; +import { listSillyTavernCharacterPngs } from "./sillytavern"; const CONCURRENT_LIMIT = 5; @@ -25,7 +26,8 @@ export class ScanService { constructor( private dbService: DatabaseService, - private libraryId: string = "cards" + private libraryId: string = "cards", + private isSillyTavern: boolean = false ) { this.cardParser = new CardParser(); this.lorebooksService = new LorebooksService(this.dbService); @@ -61,10 +63,11 @@ export class ScanService { try { // Рекурсивно получаем все файлы const files = await this.getAllPngFiles(folderPath); - logger.infoKey("log.scan.foundPngFiles", { count: files.length }); - opts?.onStart?.(files.length); + const sortedFiles = this.sortFilesOldToNew(files); + logger.infoKey("log.scan.foundPngFiles", { count: sortedFiles.length }); + opts?.onStart?.(sortedFiles.length); - const totalFiles = files.length; + const totalFiles = sortedFiles.length; let processedFiles = 0; let lastProgressAt = 0; const emitProgress = () => { @@ -78,7 +81,7 @@ export class ScanService { }; // Обрабатываем файлы с ограничением конкурентности - const promises = files.map((file) => + const promises = sortedFiles.map((file) => this.limit(async () => { try { await this.processFile(file); @@ -107,6 +110,72 @@ export class ScanService { } } + /** + * Сканирует SillyTavern-карточки по структуре: + * /data//characters/*.png + * + * Подпапки внутри characters игнорируются (считаем их доп. изображениями). + */ + async scanSillyTavern( + sillytavenrPath: string, + opts?: { + onStart?: (totalFiles: number) => void; + onProgress?: (processedFiles: number, totalFiles: number) => void; + } + ): Promise<{ totalFiles: number; processedFiles: number }> { + const root = String(sillytavenrPath ?? "").trim(); + if (!root || !existsSync(root)) { + logger.errorMessageKey("error.scan.folderNotExists", { folderPath: root }); + return { totalFiles: 0, processedFiles: 0 }; + } + + logger.infoKey("log.scan.start", { folderPath: root }); + this.scannedFiles.clear(); + + try { + const files = await listSillyTavernCharacterPngs(root); + // listSillyTavernCharacterPngs already sorts old->new, but keep it explicit. + const sortedFiles = this.sortFilesOldToNew(files); + logger.infoKey("log.scan.foundPngFiles", { count: sortedFiles.length }); + opts?.onStart?.(sortedFiles.length); + + const totalFiles = sortedFiles.length; + let processedFiles = 0; + let lastProgressAt = 0; + const emitProgress = () => { + if (!opts?.onProgress) return; + const now = Date.now(); + if (processedFiles >= totalFiles || now - lastProgressAt >= 300) { + lastProgressAt = now; + opts.onProgress(processedFiles, totalFiles); + } + }; + + const promises = sortedFiles.map((file) => + this.limit(async () => { + try { + await this.processFile(file); + } finally { + processedFiles += 1; + emitProgress(); + } + }) + ); + await Promise.all(promises); + + await this.cleanupDeletedFiles(); + + logger.infoKey("log.scan.done", { count: sortedFiles.length }); + if (processedFiles !== totalFiles) processedFiles = totalFiles; + opts?.onProgress?.(processedFiles, totalFiles); + + return { totalFiles, processedFiles }; + } catch (error) { + logger.errorKey(error, "error.scan.scanFolderFailed", { folderPath: root }); + throw error; + } + } + /** * Рекурсивно получает все PNG файлы из папки */ @@ -132,6 +201,27 @@ export class ScanService { return files; } + private sortFilesOldToNew(files: string[]): string[] { + const entries: Array<{ filePath: string; createdAtMs: number }> = []; + for (const p of files) { + try { + const st = statSync(p); + const birth = st.birthtimeMs; + const mtime = st.mtimeMs; + const createdAtMs = + Number.isFinite(birth) && birth > 0 ? birth : mtime; + entries.push({ filePath: p, createdAtMs }); + } catch { + // If file disappears during listing, ignore. + } + } + entries.sort((a, b) => { + if (a.createdAtMs !== b.createdAtMs) return a.createdAtMs - b.createdAtMs; + return a.filePath.localeCompare(b.filePath); + }); + return entries.map((e) => e.filePath); + } + /** * Обрабатывает один PNG файл * @param filePath Путь к файлу @@ -327,6 +417,7 @@ export class ScanService { // Сохраняем оригинальные данные для экспорта const dataJson = JSON.stringify(extractedData.original_data); const createdAt = fileCreatedAt; + const isSillyTavern = this.isSillyTavern ? 1 : 0; // Записываем в БД в транзакции this.dbService.transaction((db) => { @@ -339,6 +430,7 @@ export class ScanService { dbService.execute( `UPDATE cards SET library_id = ?, + is_sillytavern = ?, content_hash = ?, name = ?, description = ?, @@ -369,6 +461,7 @@ export class ScanService { WHERE id = ?`, [ this.libraryId, + isSillyTavern, contentHash, name, description, @@ -429,6 +522,7 @@ export class ScanService { `INSERT INTO cards ( id, library_id, + is_sillytavern, content_hash, name, description, @@ -456,10 +550,11 @@ export class ScanService { has_mes_example, has_character_book, prompt_tokens_est - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ cardId, this.libraryId, + isSillyTavern, contentHash, name, description, @@ -764,8 +859,9 @@ export class ScanService { */ export function createScanService( db: Database.Database, - libraryId: string = "cards" + libraryId: string = "cards", + isSillyTavern: boolean = false ): ScanService { const dbService = createDatabaseService(db); - return new ScanService(dbService, libraryId); + return new ScanService(dbService, libraryId, isSillyTavern); } diff --git a/server/src/services/sillytavern.ts b/server/src/services/sillytavern.ts new file mode 100644 index 0000000..4facc46 --- /dev/null +++ b/server/src/services/sillytavern.ts @@ -0,0 +1,85 @@ +import { existsSync, statSync } from "node:fs"; +import { readdir } from "node:fs/promises"; +import { join } from "node:path"; + +type PngFileEntry = { + filePath: string; + createdAtMs: number; +}; + +function isFinitePositive(n: unknown): n is number { + return typeof n === "number" && Number.isFinite(n) && n > 0; +} + +function getCreatedAtMs(filePath: string): number { + const st = statSync(filePath); + const birth = st.birthtimeMs; + const mtime = st.mtimeMs; + return isFinitePositive(birth) ? birth : mtime; +} + +/** + * Возвращает список путей до PNG карточек в SillyTavern. + * + * Ожидаемая структура: + * /data//characters/*.png + * + * Особенности: + * - profile директории: все папки внутри `data`, которые НЕ начинаются с `_` + * - внутри `characters` берём только *.png файлы на первом уровне (без рекурсии), + * т.к. подпапки сейчас трактуем как доп. изображения и игнорируем + * - итог сортируем от старых к новым (birthtime fallback mtime, затем filePath) + */ +export async function listSillyTavernCharacterPngs( + sillytavenrPath: string +): Promise { + const root = String(sillytavenrPath ?? "").trim(); + if (!root) return []; + + const dataDir = join(root, "data"); + if (!existsSync(dataDir)) return []; + + const entries = await readdir(dataDir, { withFileTypes: true }); + const profileDirs = entries + .filter((e) => e.isDirectory()) + .map((e) => e.name) + .filter((name) => typeof name === "string" && name.length > 0) + .filter((name) => !name.startsWith("_")); + + const pngEntries: PngFileEntry[] = []; + + for (const profileName of profileDirs) { + const charactersDir = join(dataDir, profileName, "characters"); + if (!existsSync(charactersDir)) continue; + + try { + const files = await readdir(charactersDir, { withFileTypes: true }); + for (const f of files) { + if (!f.isFile()) continue; + const name = f.name ?? ""; + if (typeof name !== "string") continue; + if (!name.toLowerCase().endsWith(".png")) continue; + const fullPath = join(charactersDir, name); + try { + pngEntries.push({ + filePath: fullPath, + createdAtMs: getCreatedAtMs(fullPath), + }); + } catch { + // If file disappears during listing, ignore. + } + } + } catch { + continue; + } + } + + pngEntries.sort((a, b) => { + if (a.createdAtMs !== b.createdAtMs) return a.createdAtMs - b.createdAtMs; + return a.filePath.localeCompare(b.filePath); + }); + + return pngEntries.map((e) => e.filePath); +} + + From 2ec8ef24adc8ac0a4e2d0457ebb6d09306b98a3e Mon Sep 17 00:00:00 2001 From: "DESKTOP-80A4L2N\\dima2" Date: Mon, 29 Dec 2025 18:46:34 +0300 Subject: [PATCH 03/28] feat: add save/load filters --- client/src/features/cards-filters/model.ts | 170 +++-- .../cards-filters/ui/CardsFiltersPanel.tsx | 579 +----------------- .../ui/sections/ActionsSection.tsx | 29 + .../ui/sections/AltGreetingsSection.tsx | 63 ++ .../ui/sections/CreatedAtSection.tsx | 93 +++ .../ui/sections/FieldsPresenceSection.tsx | 118 ++++ .../cards-filters/ui/sections/MetaSection.tsx | 145 +++++ .../ui/sections/SearchAndSortSection.tsx | 74 +++ .../ui/sections/SourceSection.tsx | 34 + .../ui/sections/TextSearchSection.tsx | 107 ++++ .../ui/sections/TokensSection.tsx | 52 ++ .../cards-filters/ui/shared/InfoTip.tsx | 21 + .../cards-filters/ui/shared/mergeOptions.ts | 16 + client/src/pages/home/ui/HomePage.tsx | 13 +- .../shared/api/cards-filters-state/index.ts | 49 ++ client/src/shared/i18n/locales/en.ts | 2 + client/src/shared/i18n/locales/ru.ts | 2 + .../src/shared/types/cards-filters-state.ts | 34 + server/src/routes/api/cards-filters-state.ts | 49 ++ server/src/routes/api/index.ts | 2 + server/src/services/cards-filters-state.ts | 285 +++++++++ 21 files changed, 1333 insertions(+), 604 deletions(-) create mode 100644 client/src/features/cards-filters/ui/sections/ActionsSection.tsx create mode 100644 client/src/features/cards-filters/ui/sections/AltGreetingsSection.tsx create mode 100644 client/src/features/cards-filters/ui/sections/CreatedAtSection.tsx create mode 100644 client/src/features/cards-filters/ui/sections/FieldsPresenceSection.tsx create mode 100644 client/src/features/cards-filters/ui/sections/MetaSection.tsx create mode 100644 client/src/features/cards-filters/ui/sections/SearchAndSortSection.tsx create mode 100644 client/src/features/cards-filters/ui/sections/SourceSection.tsx create mode 100644 client/src/features/cards-filters/ui/sections/TextSearchSection.tsx create mode 100644 client/src/features/cards-filters/ui/sections/TokensSection.tsx create mode 100644 client/src/features/cards-filters/ui/shared/InfoTip.tsx create mode 100644 client/src/features/cards-filters/ui/shared/mergeOptions.ts create mode 100644 client/src/shared/api/cards-filters-state/index.ts create mode 100644 client/src/shared/types/cards-filters-state.ts create mode 100644 server/src/routes/api/cards-filters-state.ts create mode 100644 server/src/services/cards-filters-state.ts diff --git a/client/src/features/cards-filters/model.ts b/client/src/features/cards-filters/model.ts index 802c2c2..015d129 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,32 +25,6 @@ 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; - is_sillytavern: TriState; - 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; -} - const DEFAULT_FILTERS: CardsFiltersState = { sort: "created_at_desc", name: "", @@ -170,8 +150,40 @@ 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 $filtersReady = createStore(false); export const $filtersData = createStore({ creators: [], spec_versions: [], @@ -213,6 +225,9 @@ export const setPatterns = 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) @@ -318,6 +333,9 @@ $filters }) .on(resetFilters, () => DEFAULT_FILTERS); +$filters.on(hydrateFilters, (_, next) => next); +$filtersReady.on(setFiltersReady, (_, v) => v); + // sync filters response sample({ clock: loadCardsFiltersFx.doneData, @@ -354,12 +372,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: () => DEFAULT_FILTERS, + 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, @@ -387,42 +426,97 @@ const immediateApplyClock = [ 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, + source: $filtersReady, + filter: (ready) => ready, fn: () => toQuery(DEFAULT_FILTERS), 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, + setCreatedFrom, + setCreatedTo, + setPromptTokensMin, + setPromptTokensMax, + setIsSillyTavern, + setHasCreatorNotes, + setHasSystemPrompt, + setHasPostHistoryInstructions, + setHasPersonality, + setHasScenario, + setHasMesExample, + setHasCharacterBook, + setHasAlternateGreetings, + setAlternateGreetingsMin, + setPatterns, + 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 888c5fc..902d17c 100644 --- a/client/src/features/cards-filters/ui/CardsFiltersPanel.tsx +++ b/client/src/features/cards-filters/ui/CardsFiltersPanel.tsx @@ -1,242 +1,24 @@ 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, - setIsSillyTavern, - 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 { 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"; 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, - onSetIsSillyTavern, - 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, - setIsSillyTavern, - 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 && ( @@ -244,333 +26,14 @@ 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/CreatedAtSection.tsx b/client/src/features/cards-filters/ui/sections/CreatedAtSection.tsx new file mode 100644 index 0000000..1244a6a --- /dev/null +++ b/client/src/features/cards-filters/ui/sections/CreatedAtSection.tsx @@ -0,0 +1,93 @@ +import { + ActionIcon, + Divider, + Group, + SimpleGrid, + Text, + TextInput, +} from "@mantine/core"; +import { useUnit } from "effector-react"; +import { useTranslation } from "react-i18next"; +import { InfoTip } from "../shared/InfoTip"; +import { $filters, setCreatedFrom, setCreatedTo } from "../../model"; + +const $createdFrom = $filters.map((s) => s.created_from); +const $createdTo = $filters.map((s) => s.created_to); + +export function CreatedAtSection() { + const { t } = useTranslation(); + const [createdFrom, createdTo, onSetCreatedFrom, onSetCreatedTo] = useUnit([ + $createdFrom, + $createdTo, + setCreatedFrom, + setCreatedTo, + ]); + return ( + <> + + {t("filters.createdAt")} + + + } + /> + + + onSetCreatedFrom(undefined)} + > + × + + ) : null + } + rightSectionPointerEvents="all" + onChange={(e) => + onSetCreatedFrom( + e.currentTarget.value.trim().length > 0 + ? e.currentTarget.value + : undefined + ) + } + /> + + onSetCreatedTo(undefined)} + > + × + + ) : null + } + rightSectionPointerEvents="all" + onChange={(e) => + onSetCreatedTo( + e.currentTarget.value.trim().length > 0 + ? e.currentTarget.value + : undefined + ) + } + /> + + + ); +} diff --git a/client/src/features/cards-filters/ui/sections/FieldsPresenceSection.tsx b/client/src/features/cards-filters/ui/sections/FieldsPresenceSection.tsx new file mode 100644 index 0000000..8a3ff26 --- /dev/null +++ b/client/src/features/cards-filters/ui/sections/FieldsPresenceSection.tsx @@ -0,0 +1,118 @@ +import { Divider, Select, SimpleGrid } from "@mantine/core"; +import { useUnit } from "effector-react"; +import { useTranslation } from "react-i18next"; +import type { TriState } from "@/shared/types/cards-query"; +import { + $filters, + setHasCharacterBook, + setHasCreatorNotes, + setHasMesExample, + setHasPersonality, + setHasPostHistoryInstructions, + setHasScenario, + setHasSystemPrompt, +} from "../../model"; + +const $hasCreatorNotes = $filters.map((s) => s.has_creator_notes); +const $hasSystemPrompt = $filters.map((s) => s.has_system_prompt); +const $hasPostHistoryInstructions = $filters.map( + (s) => s.has_post_history_instructions +); +const $hasPersonality = $filters.map((s) => s.has_personality); +const $hasScenario = $filters.map((s) => s.has_scenario); +const $hasMesExample = $filters.map((s) => s.has_mes_example); +const $hasCharacterBook = $filters.map((s) => s.has_character_book); + +export function FieldsPresenceSection() { + const { t } = useTranslation(); + const [ + hasCreatorNotes, + hasSystemPrompt, + hasPostHistoryInstructions, + hasPersonality, + hasScenario, + hasMesExample, + hasCharacterBook, + onSetHasCreatorNotes, + onSetHasSystemPrompt, + onSetHasPostHistoryInstructions, + onSetHasPersonality, + onSetHasScenario, + onSetHasMesExample, + onSetHasCharacterBook, + ] = useUnit([ + $hasCreatorNotes, + $hasSystemPrompt, + $hasPostHistoryInstructions, + $hasPersonality, + $hasScenario, + $hasMesExample, + $hasCharacterBook, + setHasCreatorNotes, + setHasSystemPrompt, + setHasPostHistoryInstructions, + setHasPersonality, + setHasScenario, + setHasMesExample, + setHasCharacterBook, + ]); + + 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") }, + ]; + + return ( + <> + + + + onSetHasSystemPrompt((v as TriState) || "any")} + /> + onSetHasPersonality((v as TriState) || "any")} + /> + onSetHasMesExample((v as TriState) || "any")} + /> + ({ value: x.value, label: t(x.label) }))} + value={sort} + onChange={(v) => { + if (v) onSetSort(v as CardsSort); + }} + /> + + + {isRelevanceWithoutQuery && ( + + {t("filters.relevanceNeedsQuery")} + + )} + + {isRelevanceInLikeMode && ( + + {t("filters.relevanceNeedsFts")} + + )} + + ); +} + + diff --git a/client/src/features/cards-filters/ui/sections/SourceSection.tsx b/client/src/features/cards-filters/ui/sections/SourceSection.tsx new file mode 100644 index 0000000..ae623ad --- /dev/null +++ b/client/src/features/cards-filters/ui/sections/SourceSection.tsx @@ -0,0 +1,34 @@ +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, setIsSillyTavern } from "../../model"; + +const $isSillyTavern = $filters.map((s) => s.is_sillytavern); + +export function SourceSection() { + const { t } = useTranslation(); + const [isSillyTavern, onSetIsSillyTavern] = useUnit([ + $isSillyTavern, + setIsSillyTavern, + ]); + return ( + + + {t("filters.source")} + + onSetIsSillyTavern(value as TriState)} + data={[ + { value: "any", label: t("filters.sourceAll") }, + { value: "1", label: t("filters.sourceOnlySt") }, + { value: "0", label: t("filters.sourceOnlyFolder") }, + ]} + /> + + ); +} + + diff --git a/client/src/features/cards-filters/ui/sections/TextSearchSection.tsx b/client/src/features/cards-filters/ui/sections/TextSearchSection.tsx new file mode 100644 index 0000000..4b32f6c --- /dev/null +++ b/client/src/features/cards-filters/ui/sections/TextSearchSection.tsx @@ -0,0 +1,107 @@ +import { useUnit } from "effector-react"; +import { + Checkbox, + Group, + SegmentedControl, + SimpleGrid, + Text, + TextInput, +} from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import type { + CardsFtsField, + CardsTextSearchMode, +} from "@/shared/types/cards-query"; +import { InfoTip } from "../shared/InfoTip"; +import { $filters, setQ, setQFields, setQMode } from "../../model"; + +const FTS_FIELDS: Array<{ value: CardsFtsField; labelKey: string }> = [ + { value: "description", labelKey: "filters.qFieldDescription" }, + { value: "personality", labelKey: "filters.qFieldPersonality" }, + { value: "scenario", labelKey: "filters.qFieldScenario" }, + { value: "first_mes", labelKey: "filters.qFieldFirstMes" }, + { value: "mes_example", labelKey: "filters.qFieldMesExample" }, + { value: "creator_notes", labelKey: "filters.qFieldCreatorNotes" }, + { value: "system_prompt", labelKey: "filters.qFieldSystemPrompt" }, + { + value: "post_history_instructions", + labelKey: "filters.qFieldPostHistoryInstructions", + }, + { + value: "alternate_greetings", + labelKey: "filters.qFieldAlternateGreetings", + }, + { + value: "group_only_greetings", + labelKey: "filters.qFieldGroupOnlyGreetings", + }, +]; + +const $q = $filters.map((s) => s.q); +const $qMode = $filters.map((s) => s.q_mode); +const $qFields = $filters.map((s) => s.q_fields); + +export function TextSearchSection() { + const { t } = useTranslation(); + const [q, qMode, qFields, onSetQ, onSetQMode, onSetQFields] = useUnit([ + $q, + $qMode, + $qFields, + setQ, + setQMode, + setQFields, + ]); + + return ( + <> + + {t("filters.textSearch")} + onSetQMode((v as any) ?? "like")} + data={[ + { value: "like", label: t("filters.searchModeLike") }, + { value: "fts", label: t("filters.searchModeFts") }, + ]} + /> + + + } + placeholder={t("filters.textSearchPlaceholder")} + value={q} + onChange={(e) => onSetQ(e.currentTarget.value)} + /> + + onSetQFields(values as CardsFtsField[])} + > + + {FTS_FIELDS.map((f) => ( + + ))} + + + + + {qMode === "fts" ? t("filters.noteFts") : t("filters.noteLike")} + + + ); +} + + diff --git a/client/src/features/cards-filters/ui/sections/TokensSection.tsx b/client/src/features/cards-filters/ui/sections/TokensSection.tsx new file mode 100644 index 0000000..e96b028 --- /dev/null +++ b/client/src/features/cards-filters/ui/sections/TokensSection.tsx @@ -0,0 +1,52 @@ +import { Divider, Group, NumberInput, SimpleGrid, Text } from "@mantine/core"; +import { useUnit } from "effector-react"; +import { useTranslation } from "react-i18next"; +import { InfoTip } from "../shared/InfoTip"; +import { $filters, setPromptTokensMax, setPromptTokensMin } from "../../model"; + +const $min = $filters.map((s) => s.prompt_tokens_min); +const $max = $filters.map((s) => s.prompt_tokens_max); + +export function TokensSection() { + const { t } = useTranslation(); + const [min, max, onSetMin, onSetMax] = useUnit([ + $min, + $max, + setPromptTokensMin, + setPromptTokensMax, + ]); + return ( + <> + + + + + {t("filters.min")} + + + ) as any + } + min={0} + value={min} + onChange={(v) => onSetMin(Number(v) || 0)} + /> + + {t("filters.max")} + + + ) as any + } + min={0} + value={max} + onChange={(v) => onSetMax(Number(v) || 0)} + /> + + + ); +} diff --git a/client/src/features/cards-filters/ui/shared/InfoTip.tsx b/client/src/features/cards-filters/ui/shared/InfoTip.tsx new file mode 100644 index 0000000..a79fd91 --- /dev/null +++ b/client/src/features/cards-filters/ui/shared/InfoTip.tsx @@ -0,0 +1,21 @@ +import { ActionIcon, Tooltip } from "@mantine/core"; +import { useTranslation } from "react-i18next"; + +export function InfoTip({ text }: { text: string }) { + const { t } = useTranslation(); + return ( + + + i + + + ); +} + + diff --git a/client/src/features/cards-filters/ui/shared/mergeOptions.ts b/client/src/features/cards-filters/ui/shared/mergeOptions.ts new file mode 100644 index 0000000..f213f68 --- /dev/null +++ b/client/src/features/cards-filters/ui/shared/mergeOptions.ts @@ -0,0 +1,16 @@ +export 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})`, + })); +} + + diff --git a/client/src/pages/home/ui/HomePage.tsx b/client/src/pages/home/ui/HomePage.tsx index 65922b0..a8c8a0e 100644 --- a/client/src/pages/home/ui/HomePage.tsx +++ b/client/src/pages/home/ui/HomePage.tsx @@ -28,8 +28,7 @@ import { PatternRulesModal } from "@/features/pattern-rules"; import { TagsBulkEditModal } from "@/features/tags-bulk-edit"; import { CardsFiltersPanel, - applyFilters, - loadCardsFiltersFx, + initCardsFiltersFx, } from "@/features/cards-filters"; import { CardDetailsDrawer } from "@/features/card-details"; import { AppSidebar } from "@/widgets/app-sidebar"; @@ -80,9 +79,8 @@ function MoonIcon() { export function HomePage() { const { t } = useTranslation(); - const [loadFilters, onApply, colorScheme, cycleScheme] = useUnit([ - loadCardsFiltersFx, - applyFilters, + const [initFilters, colorScheme, cycleScheme] = useUnit([ + initCardsFiltersFx, $colorScheme, cycleColorScheme, ]); @@ -94,9 +92,8 @@ export function HomePage() { }); useEffect(() => { - loadFilters(); - onApply(); - }, [loadFilters, onApply]); + initFilters(); + }, [initFilters]); const schemeLabel = colorScheme === "light" diff --git a/client/src/shared/api/cards-filters-state/index.ts b/client/src/shared/api/cards-filters-state/index.ts new file mode 100644 index 0000000..14d4c65 --- /dev/null +++ b/client/src/shared/api/cards-filters-state/index.ts @@ -0,0 +1,49 @@ +import i18n from "@/shared/i18n/i18n"; +import type { CardsFiltersState } from "@/shared/types/cards-filters-state"; + +async function readErrorText(response: Response): Promise { + try { + const data = (await response.json().catch(() => null)) as any; + if (data && typeof data === "object") { + if (typeof data.error === "string" && data.error.trim()) + return data.error; + if (typeof data.message === "string" && data.message.trim()) + return data.message; + } + } catch { + // ignore + } + return (await response.text().catch(() => "")).trim(); +} + +export async function getCardsFiltersState(): Promise { + const response = await fetch("/api/cards-filters-state"); + if (!response.ok) { + const errorText = await readErrorText(response); + if (errorText) throw new Error(errorText); + throw new Error( + `${i18n.t("errors.loadFiltersStateTitle")}: ${response.statusText}` + ); + } + return response.json(); +} + +export async function updateCardsFiltersState( + state: CardsFiltersState +): Promise { + const response = await fetch("/api/cards-filters-state", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(state), + }); + if (!response.ok) { + const errorText = await readErrorText(response); + if (errorText) throw new Error(errorText); + throw new Error( + `${i18n.t("errors.saveFiltersStateTitle")}: ${response.statusText}` + ); + } + return response.json(); +} + + diff --git a/client/src/shared/i18n/locales/en.ts b/client/src/shared/i18n/locales/en.ts index 50f10f2..d29505c 100644 --- a/client/src/shared/i18n/locales/en.ts +++ b/client/src/shared/i18n/locales/en.ts @@ -35,6 +35,8 @@ const en = { loadCards: "Failed to load cards", loadCard: "Failed to load card", loadFiltersTitle: "Failed to load filters", + loadFiltersStateTitle: "Failed to load saved filters", + saveFiltersStateTitle: "Failed to save filters", loadViewSettings: "Failed to load view settings", saveViewSettings: "Failed to save view settings", loadTags: "Failed to load tags", diff --git a/client/src/shared/i18n/locales/ru.ts b/client/src/shared/i18n/locales/ru.ts index 474cb84..50d31d9 100644 --- a/client/src/shared/i18n/locales/ru.ts +++ b/client/src/shared/i18n/locales/ru.ts @@ -35,6 +35,8 @@ const ru = { loadCards: "Ошибка загрузки карточек", loadCard: "Ошибка загрузки карточки", loadFiltersTitle: "Ошибка загрузки фильтров", + loadFiltersStateTitle: "Ошибка загрузки сохранённых фильтров", + saveFiltersStateTitle: "Ошибка сохранения фильтров", loadViewSettings: "Ошибка загрузки настроек отображения", saveViewSettings: "Ошибка сохранения настроек отображения", loadTags: "Ошибка загрузки тегов", diff --git a/client/src/shared/types/cards-filters-state.ts b/client/src/shared/types/cards-filters-state.ts new file mode 100644 index 0000000..ab0a2cd --- /dev/null +++ b/client/src/shared/types/cards-filters-state.ts @@ -0,0 +1,34 @@ +import type { + CardsFtsField, + CardsSort, + CardsTextSearchMode, + TriState, +} from "@/shared/types/cards-query"; + +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; + is_sillytavern: TriState; + 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; +} + + diff --git a/server/src/routes/api/cards-filters-state.ts b/server/src/routes/api/cards-filters-state.ts new file mode 100644 index 0000000..0f9cb65 --- /dev/null +++ b/server/src/routes/api/cards-filters-state.ts @@ -0,0 +1,49 @@ +import { Router, type Request, type Response } from "express"; +import { + getCardsFiltersState, + updateCardsFiltersState, + type CardsFiltersState, +} from "../../services/cards-filters-state"; +import { logger } from "../../utils/logger"; +import { AppError } from "../../errors/app-error"; +import { sendError } from "../../errors/http"; + +const router = Router(); + +router.get("/cards-filters-state", async (_req: Request, res: Response) => { + try { + const state = await getCardsFiltersState(); + res.json(state); + } catch (error) { + logger.errorKey(error, "api.cardsFiltersState.get_failed"); + return sendError(res, error, { + status: 500, + code: "api.cardsFiltersState.get_failed", + }); + } +}); + +router.put("/cards-filters-state", async (req: Request, res: Response) => { + try { + const body = req.body as unknown; + if (typeof body !== "object" || body === null) { + throw new AppError({ + status: 400, + code: "api.cardsFiltersState.invalid_format", + }); + } + + const saved = await updateCardsFiltersState(body as CardsFiltersState); + res.json(saved); + } catch (error) { + logger.errorKey(error, "api.cardsFiltersState.update_failed"); + return sendError(res, error, { + status: 500, + code: "api.cardsFiltersState.update_failed", + }); + } +}); + +export default router; + + diff --git a/server/src/routes/api/index.ts b/server/src/routes/api/index.ts index 9e3c242..16eb13a 100644 --- a/server/src/routes/api/index.ts +++ b/server/src/routes/api/index.ts @@ -17,6 +17,7 @@ import explorer from "./explorer"; import lorebooks from "./lorebooks"; import importSettings from "./import-settings"; import patternRules from "./pattern-rules"; +import cardsFiltersState from "./cards-filters-state"; const router = Router(); @@ -89,5 +90,6 @@ router.use(explorer); router.use(lorebooks); router.use(importSettings); router.use(patternRules); +router.use(cardsFiltersState); export default router; diff --git a/server/src/services/cards-filters-state.ts b/server/src/services/cards-filters-state.ts new file mode 100644 index 0000000..1ae2b13 --- /dev/null +++ b/server/src/services/cards-filters-state.ts @@ -0,0 +1,285 @@ +import { readFile, writeFile, ensureDir } from "fs-extra"; +import { join } from "node:path"; +import { AppError } from "../errors/app-error"; + +export type TriState = "any" | "1" | "0"; +export type CardsTextSearchMode = "like" | "fts"; + +export type CardsSort = + | "created_at_desc" + | "created_at_asc" + | "name_asc" + | "name_desc" + | "prompt_tokens_desc" + | "prompt_tokens_asc" + | "relevance"; + +export type CardsFtsField = + | "description" + | "personality" + | "scenario" + | "first_mes" + | "mes_example" + | "creator_notes" + | "system_prompt" + | "post_history_instructions" + | "alternate_greetings" + | "group_only_greetings"; + +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; + is_sillytavern: TriState; + 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; +} + +const CARDS_FILTERS_STATE_FILE_PATH = join( + process.cwd(), + "data", + "cards-filters-state.json" +); + +const DEFAULT_STATE: 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, + is_sillytavern: "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", +}; + +const SORT_VALUES: CardsSort[] = [ + "created_at_desc", + "created_at_asc", + "name_asc", + "name_desc", + "prompt_tokens_desc", + "prompt_tokens_asc", + "relevance", +]; + +const Q_MODE_VALUES: CardsTextSearchMode[] = ["like", "fts"]; + +const FTS_FIELD_VALUES: CardsFtsField[] = [ + "description", + "personality", + "scenario", + "first_mes", + "mes_example", + "creator_notes", + "system_prompt", + "post_history_instructions", + "alternate_greetings", + "group_only_greetings", +]; + +function normalizeTriState(v: unknown, fallback: TriState = "any"): TriState { + return v === "any" || v === "1" || v === "0" ? v : fallback; +} + +function normalizeEnum( + v: unknown, + allowed: readonly T[], + fallback: T +): T { + return typeof v === "string" && (allowed as readonly string[]).includes(v) + ? (v as T) + : fallback; +} + +function normalizeInt(v: unknown, fallback: number): number { + const n = Number(v); + if (!Number.isFinite(n)) return fallback; + return Math.max(0, Math.floor(n)); +} + +function isIsoDate(value: unknown): value is string { + if (typeof value !== "string") return false; + const s = value.trim(); + return /^\d{4}-\d{2}-\d{2}$/.test(s); +} + +function normalizeStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + const out: string[] = []; + const seen = new Set(); + for (const raw of value) { + const s = typeof raw === "string" ? raw.trim() : ""; + if (!s) continue; + const key = s.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + out.push(s); + } + return out; +} + +function normalizeFtsFields(value: unknown): CardsFtsField[] | undefined { + if (!Array.isArray(value)) return undefined; + const allowed = new Set(FTS_FIELD_VALUES); + const out: CardsFtsField[] = []; + const seen = new Set(); + for (const raw of value) { + if (typeof raw !== "string") continue; + if (!allowed.has(raw as CardsFtsField)) continue; + if (seen.has(raw)) continue; + seen.add(raw); + out.push(raw as CardsFtsField); + } + return out; +} + +function normalizeState(raw: unknown): CardsFiltersState { + const src = (typeof raw === "object" && raw !== null ? raw : {}) as any; + + const promptMin = normalizeInt(src.prompt_tokens_min, DEFAULT_STATE.prompt_tokens_min); + const promptMaxRaw = normalizeInt( + src.prompt_tokens_max, + DEFAULT_STATE.prompt_tokens_max + ); + const promptMax = + promptMaxRaw > 0 && promptMaxRaw < promptMin ? promptMin : promptMaxRaw; + + const qFields = normalizeFtsFields(src.q_fields); + + return { + sort: normalizeEnum(src.sort, SORT_VALUES, DEFAULT_STATE.sort), + name: typeof src.name === "string" ? src.name : DEFAULT_STATE.name, + q: typeof src.q === "string" ? src.q : DEFAULT_STATE.q, + q_mode: normalizeEnum(src.q_mode, Q_MODE_VALUES, DEFAULT_STATE.q_mode), + q_fields: qFields ?? DEFAULT_STATE.q_fields, + creator: normalizeStringArray(src.creator), + spec_version: normalizeStringArray(src.spec_version), + tags: normalizeStringArray(src.tags), + created_from: isIsoDate(src.created_from) ? src.created_from.trim() : undefined, + created_to: isIsoDate(src.created_to) ? src.created_to.trim() : undefined, + prompt_tokens_min: promptMin, + prompt_tokens_max: promptMax, + is_sillytavern: normalizeTriState(src.is_sillytavern, DEFAULT_STATE.is_sillytavern), + has_creator_notes: normalizeTriState( + src.has_creator_notes, + DEFAULT_STATE.has_creator_notes + ), + has_system_prompt: normalizeTriState( + src.has_system_prompt, + DEFAULT_STATE.has_system_prompt + ), + has_post_history_instructions: normalizeTriState( + src.has_post_history_instructions, + DEFAULT_STATE.has_post_history_instructions + ), + has_personality: normalizeTriState( + src.has_personality, + DEFAULT_STATE.has_personality + ), + has_scenario: normalizeTriState(src.has_scenario, DEFAULT_STATE.has_scenario), + has_mes_example: normalizeTriState( + src.has_mes_example, + DEFAULT_STATE.has_mes_example + ), + has_character_book: normalizeTriState( + src.has_character_book, + DEFAULT_STATE.has_character_book + ), + has_alternate_greetings: normalizeTriState( + src.has_alternate_greetings, + DEFAULT_STATE.has_alternate_greetings + ), + alternate_greetings_min: normalizeInt( + src.alternate_greetings_min, + DEFAULT_STATE.alternate_greetings_min + ), + patterns: normalizeTriState(src.patterns, DEFAULT_STATE.patterns), + }; +} + +export async function getCardsFiltersState(): Promise { + try { + const data = await readFile(CARDS_FILTERS_STATE_FILE_PATH, "utf-8"); + const parsed = JSON.parse(data) as unknown; + return normalizeState(parsed); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + await ensureDir(join(process.cwd(), "data")); + await writeFile( + CARDS_FILTERS_STATE_FILE_PATH, + JSON.stringify(DEFAULT_STATE, null, 2), + "utf-8" + ); + return DEFAULT_STATE; + } + // Corrupted JSON or other error: fall back to defaults + return DEFAULT_STATE; + } +} + +export async function updateCardsFiltersState( + newState: unknown +): Promise { + if (typeof newState !== "object" || newState === null) { + throw new AppError({ + status: 400, + code: "api.cardsFiltersState.invalid_format", + }); + } + + const normalized = normalizeState({ ...DEFAULT_STATE, ...(newState as any) }); + + await ensureDir(join(process.cwd(), "data")); + await writeFile( + CARDS_FILTERS_STATE_FILE_PATH, + JSON.stringify(normalized, null, 2), + "utf-8" + ); + + return normalized; +} + + From 3507ab2357ed096eb1b9befa98f6968ec4a9b788 Mon Sep 17 00:00:00 2001 From: "DESKTOP-80A4L2N\\dima2" Date: Mon, 29 Dec 2025 22:35:36 +0300 Subject: [PATCH 04/28] feat: new watcher and sync st folder path --- server/src/routes/api/settings.ts | 18 +- server/src/server.ts | 11 +- server/src/services/fs-watcher.ts | 262 +++++++++++++++++++-------- server/src/services/scan.ts | 28 ++- server/src/services/watch-targets.ts | 75 ++++++++ 5 files changed, 299 insertions(+), 95 deletions(-) create mode 100644 server/src/services/watch-targets.ts diff --git a/server/src/routes/api/settings.ts b/server/src/routes/api/settings.ts index 5c9c3db..f1bf9fb 100644 --- a/server/src/routes/api/settings.ts +++ b/server/src/routes/api/settings.ts @@ -13,6 +13,7 @@ import type { FsWatcherService } from "../../services/fs-watcher"; import { setCurrentLanguage } from "../../i18n/language"; import { AppError } from "../../errors/app-error"; import { sendError } from "../../errors/http"; +import { buildWatchTargets } from "../../services/watch-targets"; const router = Router(); @@ -85,18 +86,11 @@ router.put("/settings", async (req: Request, res: Response) => { const nextStPath = savedSettings.sillytavenrPath; const db = getDb(req); - // Перезапускаем watcher, если путь изменился - if (prevPath !== nextPath) { - try { - if (nextPath) { - const libraryId = getOrCreateLibraryId(db, nextPath); - getFsWatcher(req).restart(nextPath, libraryId); - } else { - getFsWatcher(req).restart(null); - } - } catch (error) { - logger.errorKey(error, "error.settings.restartFsWatcherFailed"); - } + // Синхронизируем watcher'ы под актуальные настройки (cards + sillytavern) + try { + getFsWatcher(req).syncTargets(buildWatchTargets(savedSettings, db)); + } catch (error) { + logger.errorKey(error, "error.settings.restartFsWatcherFailed"); } // Запускаем scan через orchestrator (мгновенно, без debounce) diff --git a/server/src/server.ts b/server/src/server.ts index 6a39508..f6bc21e 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -4,12 +4,11 @@ import { initializeScannerWithOrchestrator } from "./plugins/scanner"; import { logger } from "./utils/logger"; import Database from "better-sqlite3"; import { getSettings } from "./services/settings"; -import { existsSync } from "node:fs"; -import { getOrCreateLibraryId } from "./services/libraries"; import type { SseHub } from "./services/sse-hub"; import type { FsWatcherService } from "./services/fs-watcher"; import type { CardsSyncOrchestrator } from "./services/cards-sync-orchestrator"; import { setCurrentLanguage } from "./i18n/language"; +import { buildWatchTargets } from "./services/watch-targets"; function readPort( ...candidates: Array @@ -59,11 +58,7 @@ async function startServer(): Promise { // Запускаем FS watcher (если cardsFolderPath указан) getSettings() .then((settings) => { - const p = settings.cardsFolderPath; - if (p && existsSync(p)) { - const libraryId = getOrCreateLibraryId(db, p); - fsWatcher.start(p, libraryId); - } + fsWatcher.syncTargets(buildWatchTargets(settings, db)); }) .catch((error) => { logger.errorKey(error, "log.server.startFsWatcherFailed"); @@ -79,7 +74,7 @@ async function startServer(): Promise { // Закрываем SSE и watcher try { - fsWatcher.stop(); + fsWatcher.stopAll(); sseHub.closeAll(); } catch (error) { logger.errorKey(error, "log.server.closeSseWatcherFailed"); diff --git a/server/src/services/fs-watcher.ts b/server/src/services/fs-watcher.ts index 760519f..8eab8aa 100644 --- a/server/src/services/fs-watcher.ts +++ b/server/src/services/fs-watcher.ts @@ -3,104 +3,220 @@ import { extname } from "node:path"; import { logger } from "../utils/logger"; import type { CardsSyncOrchestrator } from "./cards-sync-orchestrator"; +export type WatchTarget = { + /** Stable id for diffing/restarts (e.g. "cards", "sillytavern") */ + id: string; + /** Path that should be passed into orchestrator (root folder for scanning) */ + folderPath: string; + libraryId: string; + scanMode?: "folder" | "sillytavern"; + /** Globs passed into chokidar.watch(...) */ + watchGlobs: string[]; + /** + * Optional chokidar depth (useful to avoid recursion into subfolders). + * - 0: only direct children + * - undefined: default chokidar behavior (recursive) + */ + depth?: number; + /** Optional extra filter, applied to the chokidar event path */ + isRelevantPath?: (absPath: string) => boolean; + debounceMs?: number; + /** + * If true, chokidar will use polling (more reliable on some Windows drives/UNC, + * but higher CPU/disk usage). If not provided, we auto-detect a safe default. + */ + usePolling?: boolean; + /** Poll interval used when usePolling is enabled (ms). */ + pollingIntervalMs?: number; +}; + +type ActiveTarget = { + configKey: string; + watcher: FSWatcher; + debounceTimer: NodeJS.Timeout | null; + target: WatchTarget; +}; + +function toPosixPath(p: string): string { + return String(p ?? "").replace(/\\/g, "/"); +} + +function trimTrailingSlashes(p: string): string { + return p.replace(/\/+$/, ""); +} + +function shouldUsePollingByDefault(folderPath: string): boolean { + // We intentionally keep native fs events as the default behavior. + // The old implementation worked reliably on Windows for local drives. + // Polling can be enabled explicitly via WatchTarget.usePolling. + void folderPath; + return false; +} + +export function buildGlob(rootPath: string, suffix: string): string { + const root = trimTrailingSlashes(toPosixPath(rootPath)); + const s = String(suffix ?? "").replace(/^\/+/, ""); + return s ? `${root}/${s}` : root; +} + export class FsWatcherService { - private watcher: FSWatcher | null = null; - private debounceTimer: NodeJS.Timeout | null = null; - private currentPath: string | null = null; - private currentLibraryId: string | null = null; + private targets = new Map(); constructor( private orchestrator: CardsSyncOrchestrator, private debounceMs: number = 2000 ) {} - start(folderPath: string, libraryId: string): void { - if ( - this.currentPath === folderPath && - this.currentLibraryId === libraryId && - this.watcher - ) - return; - this.stop(); + /** + * Ensures chokidar watchers match the provided targets set. + * - starts missing targets + * - restarts changed targets + * - stops removed targets + */ + syncTargets(nextTargets: WatchTarget[]): void { + const byId = new Map(); + for (const t of nextTargets) { + if (!t?.id) continue; + byId.set(t.id, t); + } + + // stop removed + for (const [id] of this.targets) { + if (!byId.has(id)) this.stopTarget(id); + } + + // start/restart existing + for (const [id, t] of byId) { + this.startOrRestartTarget(id, t); + } + } + + stopAll(): void { + for (const [id] of this.targets) { + this.stopTarget(id); + } + this.targets.clear(); + } + + private stopTarget(id: string): void { + const active = this.targets.get(id); + if (!active) return; + + if (active.debounceTimer) { + clearTimeout(active.debounceTimer); + active.debounceTimer = null; + } + try { + void active.watcher.close(); + } finally { + this.targets.delete(id); + } + } + + private startOrRestartTarget(id: string, target: WatchTarget): void { + const debounceMs = target.debounceMs ?? this.debounceMs; + const scanMode = target.scanMode ?? "folder"; + const folderPath = target.folderPath; + const libraryId = target.libraryId; + const watchGlobs = target.watchGlobs ?? []; + const depth = target.depth; + const usePolling = + target.usePolling ?? shouldUsePollingByDefault(folderPath); + const pollingIntervalMs = target.pollingIntervalMs ?? 1000; + + const configKey = JSON.stringify({ + folderPath, + libraryId, + scanMode, + watchGlobs, + depth, + debounceMs, + hasRelevantFilter: Boolean(target.isRelevantPath), + usePolling, + pollingIntervalMs, + }); - this.currentPath = folderPath; - this.currentLibraryId = libraryId; + const existing = this.targets.get(id); + if (existing && existing.configKey === configKey) return; + if (existing) this.stopTarget(id); - this.watcher = chokidar.watch(folderPath, { + const watcher = chokidar.watch(watchGlobs, { ignoreInitial: true, persistent: true, + depth, + usePolling, + interval: pollingIntervalMs, awaitWriteFinish: { stabilityThreshold: 1500, pollInterval: 100, }, }); - const schedule = (reason: string) => { - if (!this.currentPath) return; - const libId = this.currentLibraryId; - if (!libId) return; - if (this.debounceTimer) clearTimeout(this.debounceTimer); - this.debounceTimer = setTimeout(() => { - this.debounceTimer = null; - if (!this.currentPath || !this.currentLibraryId) return; - logger.infoKey("log.fsWatcher.triggerScan", { reason }); - this.orchestrator.requestScan( - "fs", - this.currentPath, - this.currentLibraryId - ); - }, this.debounceMs); + const active: ActiveTarget = { + configKey, + watcher, + debounceTimer: null, + target: { ...target, scanMode }, + }; + this.targets.set(id, active); + + const isPng = (p: string) => extname(p).toLowerCase() === ".png"; + const isRelevant = (eventPath: string): boolean => { + if (!isPng(eventPath)) return false; + if (active.target.isRelevantPath) { + try { + return Boolean(active.target.isRelevantPath(eventPath)); + } catch { + // If filter throws, treat as irrelevant to avoid scan spam. + return false; + } + } + return true; }; - const isPng = (path: string) => extname(path).toLowerCase() === ".png"; + const schedule = (reason: string) => { + if (active.debounceTimer) clearTimeout(active.debounceTimer); + active.debounceTimer = setTimeout(() => { + active.debounceTimer = null; + logger.infoKey("log.fsWatcher.triggerScan", { + reason, + targetId: id, + scanMode, + }); + this.orchestrator.requestScan("fs", folderPath, libraryId, scanMode); + }, debounceMs); + }; - this.watcher - .on("add", (path) => { - if (!isPng(path)) return; + watcher + .on("add", (p) => { + if (!isRelevant(p)) return; schedule("add"); }) - .on("unlink", (path) => { - if (!isPng(path)) return; + .on("change", (p) => { + if (!isRelevant(p)) return; + schedule("change"); + }) + .on("unlink", (p) => { + if (!isRelevant(p)) return; schedule("unlink"); }) - .on("addDir", () => schedule("addDir")) - .on("unlinkDir", () => schedule("unlinkDir")) + // For folder watchers, directory events can hint about bulk changes. + // For SillyTavern mode we intentionally ignore directory events to avoid scan spam. + .on("addDir", () => { + if (scanMode !== "folder") return; + schedule("addDir"); + }) + .on("unlinkDir", () => { + if (scanMode !== "folder") return; + schedule("unlinkDir"); + }) .on("error", (err) => logger.errorKey(err, "error.fsWatcher.error")); - logger.infoKey("log.fsWatcher.started", { folderPath }); - } - - stop(): void { - if (this.debounceTimer) { - clearTimeout(this.debounceTimer); - this.debounceTimer = null; - } - if (this.watcher) { - void this.watcher.close(); - this.watcher = null; - } - this.currentPath = null; - this.currentLibraryId = null; - } - - restart(folderPath: string | null, libraryId?: string): void { - if (!folderPath) { - this.stop(); - return; - } - if (!libraryId) { - throw new Error( - "FsWatcherService.restart requires libraryId when folderPath is not null" - ); - } - this.start(folderPath, libraryId); - } - - getWatchedPath(): string | null { - return this.currentPath; - } - - getWatchedLibraryId(): string | null { - return this.currentLibraryId; + logger.infoKey("log.fsWatcher.started", { + folderPath, + targetId: id, + scanMode, + usePolling, + }); } } diff --git a/server/src/services/scan.ts b/server/src/services/scan.ts index 748530f..4f777c2 100644 --- a/server/src/services/scan.ts +++ b/server/src/services/scan.ts @@ -13,22 +13,39 @@ import { createLorebooksService, LorebooksService } from "./lorebooks"; import { logger } from "../utils/logger"; import { listSillyTavernCharacterPngs } from "./sillytavern"; -const CONCURRENT_LIMIT = 5; +const CONCURRENT_LIMIT_FOLDER = 5; +// SillyTavern scan tends to create more pressure on the event loop (lots of sync IO + parsing). +// Keep it lower to improve server responsiveness during startup/rescans. +const CONCURRENT_LIMIT_SILLYTAVERN = 2; +const YIELD_EVERY_FILES_FOLDER = 80; +const YIELD_EVERY_FILES_SILLYTAVERN = 40; + +function yieldToEventLoop(): Promise { + return new Promise((resolve) => setImmediate(resolve)); +} /** * Сервис для сканирования папки с карточками и синхронизации с базой данных */ export class ScanService { - private limit = pLimit(CONCURRENT_LIMIT); + private limit: ReturnType; private scannedFiles = new Set(); private cardParser: CardParser; private lorebooksService: LorebooksService; + private yieldCounter = 0; + private yieldEvery: number; constructor( private dbService: DatabaseService, private libraryId: string = "cards", private isSillyTavern: boolean = false ) { + this.limit = pLimit( + this.isSillyTavern ? CONCURRENT_LIMIT_SILLYTAVERN : CONCURRENT_LIMIT_FOLDER + ); + this.yieldEvery = this.isSillyTavern + ? YIELD_EVERY_FILES_SILLYTAVERN + : YIELD_EVERY_FILES_FOLDER; this.cardParser = new CardParser(); this.lorebooksService = new LorebooksService(this.dbService); } @@ -228,6 +245,13 @@ export class ScanService { */ private async processFile(filePath: string): Promise { try { + // Prevent long event-loop blocking during large scans. + // Most work below is sync-heavy (statSync, parsing), so we yield periodically. + this.yieldCounter += 1; + if (this.yieldEvery > 0 && this.yieldCounter % this.yieldEvery === 0) { + await yieldToEventLoop(); + } + // Отмечаем файл как обработанный this.scannedFiles.add(filePath); diff --git a/server/src/services/watch-targets.ts b/server/src/services/watch-targets.ts new file mode 100644 index 0000000..d9ab3b1 --- /dev/null +++ b/server/src/services/watch-targets.ts @@ -0,0 +1,75 @@ +import type Database from "better-sqlite3"; +import { existsSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import type { Settings } from "./settings"; +import { getOrCreateLibraryId } from "./libraries"; +import type { WatchTarget } from "./fs-watcher"; + +function listSillyTavernProfileNames(dataDir: string): string[] { + if (!existsSync(dataDir)) return []; + try { + const entries = readdirSync(dataDir, { withFileTypes: true }); + return entries + .filter((e) => e.isDirectory()) + .map((e) => e.name) + .filter((name) => typeof name === "string" && name.length > 0) + .filter((name) => !name.startsWith("_")); + } catch { + return []; + } +} + +function listExistingDirs(dirs: string[]): string[] { + const out: string[] = []; + for (const d of dirs) { + if (d && existsSync(d)) out.push(d); + } + return out; +} + +export function buildWatchTargets( + settings: Settings, + db: Database.Database +): WatchTarget[] { + const targets: WatchTarget[] = []; + + const cardsPath = settings.cardsFolderPath; + if (cardsPath && existsSync(cardsPath)) { + const libraryId = getOrCreateLibraryId(db, cardsPath); + targets.push({ + id: "cards", + folderPath: cardsPath, + libraryId, + scanMode: "folder", + // Old stable behavior: watch the folder itself (recursive). + watchGlobs: [cardsPath], + }); + } + + const stRoot = settings.sillytavenrPath; + if (stRoot && existsSync(stRoot)) { + const dataDir = join(stRoot, "data"); + const profiles = listSillyTavernProfileNames(dataDir); + const charactersDirs = listExistingDirs( + profiles.map((p) => join(dataDir, p, "characters")) + ); + + if (charactersDirs.length > 0) { + const libraryId = getOrCreateLibraryId(db, stRoot); + targets.push({ + id: "sillytavern", + folderPath: stRoot, + libraryId, + scanMode: "sillytavern", + // Watch only exact characters dirs; drastically reduces FS event noise. + watchGlobs: charactersDirs, + // Only direct *.png files in characters/ (no subdirs) + depth: 0, + }); + } + } + + return targets; +} + + From 36cfcbca12cc482e4f67cc3059cd822ea1391950 Mon Sep 17 00:00:00 2001 From: "DESKTOP-80A4L2N\\dima2" Date: Mon, 29 Dec 2025 23:36:31 +0300 Subject: [PATCH 05/28] feat: add play to st card native with ext --- client/src/shared/types/events/index.ts | 1 + server/src/locales/en.ts | 4 ++ server/src/locales/ru.ts | 4 ++ server/src/plugins/database.ts | 11 +++ server/src/routes/api/st.ts | 70 ++++++++++++++++++ server/src/services/scan.ts | 96 +++++++++++++++++++++---- server/src/services/sillytavern.ts | 15 ++-- 7 files changed, 181 insertions(+), 20 deletions(-) diff --git a/client/src/shared/types/events/index.ts b/client/src/shared/types/events/index.ts index 8b93a2e..bc11ad2 100644 --- a/client/src/shared/types/events/index.ts +++ b/client/src/shared/types/events/index.ts @@ -66,6 +66,7 @@ export type StImportResultEvent = { ts: number; cardId: string; ok: boolean; + action?: "import" | "open"; message?: string; stCharacterId?: string; }; diff --git a/server/src/locales/en.ts b/server/src/locales/en.ts index 3f41224..098dfeb 100644 --- a/server/src/locales/en.ts +++ b/server/src/locales/en.ts @@ -178,6 +178,10 @@ const en = { "api.st.invalid_cardId": "Invalid cardId", "api.st.invalid_ok": "Invalid ok field (expected boolean)", + "api.st.missing_st_profile": + "Missing SillyTavern profile data to open this card (re-scan SillyTavern library)", + "api.st.missing_st_avatar": + "Missing SillyTavern avatar filename to open this card (re-scan SillyTavern library)", "api.st.play_failed": "Could not send Play command to SillyTavern", "api.st.import_result_failed": "Could not accept import result from SillyTavern", diff --git a/server/src/locales/ru.ts b/server/src/locales/ru.ts index 3013f05..954377d 100644 --- a/server/src/locales/ru.ts +++ b/server/src/locales/ru.ts @@ -192,6 +192,10 @@ const ru = { "api.st.invalid_cardId": "Некорректный cardId", "api.st.invalid_ok": "Некорректное поле ok (ожидается boolean)", + "api.st.missing_st_profile": + "Не хватает данных SillyTavern-профиля для запуска карточки (пересканируй библиотеку SillyTavern)", + "api.st.missing_st_avatar": + "Не хватает имени avatar-файла SillyTavern для запуска карточки (пересканируй библиотеку SillyTavern)", "api.st.play_failed": "Не удалось отправить команду Play в SillyTavern", "api.st.import_result_failed": "Не удалось принять результат импорта из SillyTavern", diff --git a/server/src/plugins/database.ts b/server/src/plugins/database.ts index fc0529e..2f676cd 100644 --- a/server/src/plugins/database.ts +++ b/server/src/plugins/database.ts @@ -475,6 +475,15 @@ function initializeSchema(db: Database.Database): void { // card_files: folder_path для фильтрации/группировки по папкам addColumnIfMissing("card_files", "folder_path", "folder_path TEXT"); + // card_files: SillyTavern metadata (для запуска/открытия в ST без импорта) + // Храним на уровне file_path (а не cards), т.к. одна и та же карточка может существовать в разных профилях. + addColumnIfMissing( + "card_files", + "st_profile_handle", + "st_profile_handle TEXT" + ); + addColumnIfMissing("card_files", "st_avatar_file", "st_avatar_file TEXT"); + addColumnIfMissing("card_files", "st_avatar_base", "st_avatar_base TEXT"); // card_files: file_birthtime — время создания файла (нужно для корректного created_at карточки) // NOT NULL + DEFAULT нужен для старых БД. addColumnIfMissing( @@ -484,6 +493,8 @@ function initializeSchema(db: Database.Database): void { ); db.exec(` CREATE INDEX IF NOT EXISTS idx_card_files_folder_path ON card_files(folder_path); + CREATE INDEX IF NOT EXISTS idx_card_files_st_profile_handle ON card_files(st_profile_handle); + CREATE INDEX IF NOT EXISTS idx_card_files_st_avatar_file ON card_files(st_avatar_file); `); // Связь карточек и тегов для точной фильтрации diff --git a/server/src/routes/api/st.ts b/server/src/routes/api/st.ts index 6f8f132..894444d 100644 --- a/server/src/routes/api/st.ts +++ b/server/src/routes/api/st.ts @@ -1,6 +1,7 @@ import { Router, type Request, type Response } from "express"; import Database from "better-sqlite3"; import { existsSync } from "node:fs"; +import { basename } from "node:path"; import { AppError } from "../../errors/app-error"; import { sendError } from "../../errors/http"; import type { SseHub } from "../../services/sse-hub"; @@ -27,11 +28,21 @@ type StCardPlayPayload = { filename: string; }; +type StCardOpenPayload = { + type: "st:card_open"; + ts: number; + cardId: string; + stProfileHandle: string; + stAvatarFile: string; + stAvatarBase: string; +}; + type StImportResultPayload = { type: "st:import_result"; ts: number; cardId: string; ok: boolean; + action?: "import" | "open"; message?: string; stCharacterId?: string; }; @@ -51,6 +62,7 @@ router.post("/st/play", (req: Request, res: Response) => { SELECT c.id, c.name, + c.is_sillytavern, c.primary_file_path, ( SELECT cf.file_path @@ -68,6 +80,7 @@ router.post("/st/play", (req: Request, res: Response) => { | { id: string; name: string | null; + is_sillytavern: number; primary_file_path: string | null; file_path: string | null; } @@ -84,6 +97,56 @@ router.post("/st/play", (req: Request, res: Response) => { throw new AppError({ status: 404, code: "api.image.file_not_found" }); } + // If this is a SillyTavern-origin card, do NOT export+import. + // Instead, ask the ST extension to open an existing character by avatar file. + if (row.is_sillytavern === 1) { + const meta = db + .prepare( + ` + SELECT st_profile_handle, st_avatar_file, st_avatar_base + FROM card_files + WHERE file_path = ? + LIMIT 1 + ` + ) + .get(mainFilePath) as + | { + st_profile_handle: string | null; + st_avatar_file: string | null; + st_avatar_base: string | null; + } + | undefined; + + const stProfileHandle = meta?.st_profile_handle?.trim() ?? ""; + const stAvatarFile = meta?.st_avatar_file?.trim() ?? basename(mainFilePath); + const stAvatarBase = + meta?.st_avatar_base?.trim() ?? + stAvatarFile.replace(/\.png$/i, ""); + + if (!stProfileHandle) { + throw new AppError({ status: 409, code: "api.st.missing_st_profile" }); + } + if (!stAvatarFile) { + throw new AppError({ status: 409, code: "api.st.missing_st_avatar" }); + } + + const payload: StCardOpenPayload = { + type: "st:card_open", + ts: Date.now(), + cardId: row.id, + stProfileHandle, + stAvatarFile, + stAvatarBase, + }; + + logger.infoKey("log.st.playRequested", { cardId: row.id }); + getHub(req).broadcast("st:card_open", payload, { id: payload.ts }); + logger.infoKey("log.st.playBroadcasted", { cardId: row.id }); + + res.json({ ok: true }); + return; + } + const base = sanitizeWindowsFilenameBase(row.name, `card-${row.id}`); const filename = `${base}.png`; @@ -130,11 +193,18 @@ router.post("/st/import-result", (req: Request, res: Response) => { ? body.stCharacterId.trim() : undefined; + const actionRaw = typeof body.action === "string" ? body.action.trim() : ""; + const action = + actionRaw === "import" || actionRaw === "open" + ? (actionRaw as "import" | "open") + : undefined; + const payload: StImportResultPayload = { type: "st:import_result", ts: Date.now(), cardId: cardId.trim(), ok, + ...(action ? { action } : {}), ...(shortMessage ? { message: shortMessage } : {}), ...(stCharacterId ? { stCharacterId } : {}), }; diff --git a/server/src/services/scan.ts b/server/src/services/scan.ts index 4f777c2..a2f62f9 100644 --- a/server/src/services/scan.ts +++ b/server/src/services/scan.ts @@ -150,13 +150,11 @@ export class ScanService { this.scannedFiles.clear(); try { - const files = await listSillyTavernCharacterPngs(root); - // listSillyTavernCharacterPngs already sorts old->new, but keep it explicit. - const sortedFiles = this.sortFilesOldToNew(files); - logger.infoKey("log.scan.foundPngFiles", { count: sortedFiles.length }); - opts?.onStart?.(sortedFiles.length); + const entries = await listSillyTavernCharacterPngs(root); + logger.infoKey("log.scan.foundPngFiles", { count: entries.length }); + opts?.onStart?.(entries.length); - const totalFiles = sortedFiles.length; + const totalFiles = entries.length; let processedFiles = 0; let lastProgressAt = 0; const emitProgress = () => { @@ -168,10 +166,14 @@ export class ScanService { } }; - const promises = sortedFiles.map((file) => + const promises = entries.map((entry) => this.limit(async () => { try { - await this.processFile(file); + await this.processFile(entry.filePath, { + stProfileHandle: entry.profileHandle, + stAvatarFile: entry.avatarFile, + stAvatarBase: entry.avatarBase, + }); } finally { processedFiles += 1; emitProgress(); @@ -182,7 +184,7 @@ export class ScanService { await this.cleanupDeletedFiles(); - logger.infoKey("log.scan.done", { count: sortedFiles.length }); + logger.infoKey("log.scan.done", { count: entries.length }); if (processedFiles !== totalFiles) processedFiles = totalFiles; opts?.onProgress?.(processedFiles, totalFiles); @@ -243,7 +245,14 @@ export class ScanService { * Обрабатывает один PNG файл * @param filePath Путь к файлу */ - private async processFile(filePath: string): Promise { + private async processFile( + filePath: string, + stMeta?: { + stProfileHandle: string; + stAvatarFile: string; + stAvatarBase: string; + } + ): Promise { try { // Prevent long event-loop blocking during large scans. // Most work below is sync-heavy (statSync, parsing), so we yield periodically. @@ -274,12 +283,18 @@ export class ScanService { file_birthtime: number; file_size: number; prompt_tokens_est?: number; + st_profile_handle?: string | null; + st_avatar_file?: string | null; + st_avatar_base?: string | null; }>( `SELECT cf.card_id, cf.file_mtime, cf.file_birthtime, cf.file_size, + cf.st_profile_handle, + cf.st_avatar_file, + cf.st_avatar_base, c.prompt_tokens_est as prompt_tokens_est FROM card_files cf LEFT JOIN cards c ON c.id = cf.card_id @@ -287,7 +302,10 @@ export class ScanService { [filePath] ); - // Если файл не изменился, пропускаем + const shouldSetStMeta = Boolean(this.isSillyTavern && stMeta); + + // Если файл не изменился, обычно пропускаем. + // Но для SillyTavern после миграций нам важно проставить st_* поля даже без изменения PNG. if ( existingFile && existingFile.file_mtime === fileMtime && @@ -296,6 +314,29 @@ export class ScanService { // Если оценка токенов ещё не заполнена (0 по умолчанию после миграции), делаем перерасчёт. (existingFile.prompt_tokens_est ?? 0) > 0 ) { + if (shouldSetStMeta) { + const needUpdate = + (existingFile.st_profile_handle ?? null) !== + (stMeta!.stProfileHandle ?? null) || + (existingFile.st_avatar_file ?? null) !== + (stMeta!.stAvatarFile ?? null) || + (existingFile.st_avatar_base ?? null) !== + (stMeta!.stAvatarBase ?? null); + + if (needUpdate) { + this.dbService.execute( + `UPDATE card_files + SET st_profile_handle = ?, st_avatar_file = ?, st_avatar_base = ? + WHERE file_path = ?`, + [ + stMeta!.stProfileHandle, + stMeta!.stAvatarFile, + stMeta!.stAvatarBase, + filePath, + ] + ); + } + } return; } @@ -534,9 +575,21 @@ export class ScanService { file_mtime = ?, file_birthtime = ?, file_size = ?, - folder_path = ? + folder_path = ?, + st_profile_handle = ?, + st_avatar_file = ?, + st_avatar_base = ? WHERE file_path = ?`, - [fileMtime, createdAt, fileSize, dirname(filePath), filePath] + [ + fileMtime, + createdAt, + fileSize, + dirname(filePath), + shouldSetStMeta ? stMeta!.stProfileHandle : null, + shouldSetStMeta ? stMeta!.stAvatarFile : null, + shouldSetStMeta ? stMeta!.stAvatarBase : null, + filePath, + ] ); } else { // Для новых file_path: либо создаём карточку, либо привязываем к существующей по (library_id, content_hash). @@ -668,8 +721,18 @@ export class ScanService { // Привязываем файл к cardId (и для новой карточки, и для дубля по хэшу) dbService.execute( - `INSERT INTO card_files (file_path, card_id, file_mtime, file_birthtime, file_size, folder_path) - VALUES (?, ?, ?, ?, ?, ?)`, + `INSERT INTO card_files ( + file_path, + card_id, + file_mtime, + file_birthtime, + file_size, + folder_path, + st_profile_handle, + st_avatar_file, + st_avatar_base + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ filePath, cardId, @@ -677,6 +740,9 @@ export class ScanService { createdAt, fileSize, dirname(filePath), + shouldSetStMeta ? stMeta!.stProfileHandle : null, + shouldSetStMeta ? stMeta!.stAvatarFile : null, + shouldSetStMeta ? stMeta!.stAvatarBase : null, ] ); diff --git a/server/src/services/sillytavern.ts b/server/src/services/sillytavern.ts index 4facc46..7339c09 100644 --- a/server/src/services/sillytavern.ts +++ b/server/src/services/sillytavern.ts @@ -1,10 +1,13 @@ import { existsSync, statSync } from "node:fs"; import { readdir } from "node:fs/promises"; -import { join } from "node:path"; +import { join, parse } from "node:path"; type PngFileEntry = { filePath: string; createdAtMs: number; + profileHandle: string; + avatarFile: string; + avatarBase: string; }; function isFinitePositive(n: unknown): n is number { @@ -32,7 +35,7 @@ function getCreatedAtMs(filePath: string): number { */ export async function listSillyTavernCharacterPngs( sillytavenrPath: string -): Promise { +): Promise { const root = String(sillytavenrPath ?? "").trim(); if (!root) return []; @@ -61,9 +64,13 @@ export async function listSillyTavernCharacterPngs( if (!name.toLowerCase().endsWith(".png")) continue; const fullPath = join(charactersDir, name); try { + const p = parse(name); pngEntries.push({ filePath: fullPath, createdAtMs: getCreatedAtMs(fullPath), + profileHandle: profileName, + avatarFile: name, + avatarBase: p.name, }); } catch { // If file disappears during listing, ignore. @@ -79,7 +86,5 @@ export async function listSillyTavernCharacterPngs( return a.filePath.localeCompare(b.filePath); }); - return pngEntries.map((e) => e.filePath); + return pngEntries; } - - From 30fe13341b5b717aa5d3f1a47000efd752f4ba41 Mon Sep 17 00:00:00 2001 From: "DESKTOP-80A4L2N\\dima2" Date: Tue, 30 Dec 2025 00:35:02 +0300 Subject: [PATCH 06/28] feat: add hide card --- .../ui/components/CardDetailsActionsPanel.tsx | 41 +++++- client/src/features/cards-filters/model.ts | 6 + .../cards-filters/ui/CardsFiltersPanel.tsx | 2 + .../ui/sections/HiddenSection.tsx | 32 ++++ client/src/shared/api/cards/index.ts | 39 +++++ client/src/shared/i18n/locales/en.ts | 9 ++ client/src/shared/i18n/locales/ru.ts | 9 ++ .../src/shared/types/cards-filters-state.ts | 1 + client/src/shared/types/cards-query/index.ts | 1 + client/src/shared/types/cards/index.ts | 2 + server/src/plugins/database.ts | 13 ++ server/src/routes/api/cards.ts | 138 ++++++++++++++++++ server/src/services/cards-filters-state.ts | 3 + server/src/services/cards.ts | 33 ++++- 14 files changed, 320 insertions(+), 9 deletions(-) create mode 100644 client/src/features/cards-filters/ui/sections/HiddenSection.tsx diff --git a/client/src/features/card-details/ui/components/CardDetailsActionsPanel.tsx b/client/src/features/card-details/ui/components/CardDetailsActionsPanel.tsx index bccea69..7612336 100644 --- a/client/src/features/card-details/ui/components/CardDetailsActionsPanel.tsx +++ b/client/src/features/card-details/ui/components/CardDetailsActionsPanel.tsx @@ -16,7 +16,7 @@ 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 { deleteCard, deleteCardFileDuplicate, setCardHidden } from "@/shared/api/cards"; import { renameCardMainFile, saveCard, @@ -24,6 +24,7 @@ import { } from "@/shared/api/cards"; import { showFile } from "@/shared/api/explorer"; import { CopyableTruncatedText } from "@/shared/ui/CopyableTruncatedText"; +import { refreshCardsSilent } from "@/entities/cards"; import { closeCard, openCard } from "../../model"; import { $altGreetingIds, @@ -71,6 +72,7 @@ export function CardDetailsActionsPanel({ const [saveOpened, setSaveOpened] = useState(false); const [isSaving, setIsSaving] = useState(false); const [pendingCardJson, setPendingCardJson] = useState(null); + const [isTogglingHidden, setIsTogglingHidden] = useState(false); const [draft, altIds, altValues, groupIds, groupValues, lorebook, markSaved] = useUnit([ $draft, @@ -189,6 +191,32 @@ export function CardDetailsActionsPanel({ ? `/api/cards/${encodeURIComponent(details.id)}/export.png?download=1` : undefined; + const isHidden = Boolean(details?.innkeeperMeta?.isHidden); + + async function toggleHidden(): Promise { + if (!details?.id) return; + if (isTogglingHidden) return; + setIsTogglingHidden(true); + try { + await setCardHidden(details.id, !isHidden); + notifications.show({ + title: i18n.t("cardDetails.actions"), + message: !isHidden ? i18n.t("cardDetails.hideOk") : i18n.t("cardDetails.showOk"), + color: "green", + }); + openCard(details.id); + refreshCardsSilent(); + } catch { + notifications.show({ + title: i18n.t("cardDetails.actions"), + message: i18n.t("cardDetails.hideFailed"), + color: "red", + }); + } finally { + setIsTogglingHidden(false); + } + } + async function playInSillyTavern(): Promise { if (!details?.id) return; if (isSendingPlay) return; @@ -479,6 +507,17 @@ export function CardDetailsActionsPanel({ {i18n.t("cardDetails.save")} + + + + + + + + onCloseDelete()} + title={i18n.t("cardDetails.confirmDeleteCardTitle")} + > + + {i18n.t("cardDetails.confirmDeleteCardMessage")} + + + + + + + + ); +} + + 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 7612336..6205281 100644 --- a/client/src/features/card-details/ui/components/CardDetailsActionsPanel.tsx +++ b/client/src/features/card-details/ui/components/CardDetailsActionsPanel.tsx @@ -9,23 +9,28 @@ import { Paper, Stack, Text, - TextInput, Tooltip, } from "@mantine/core"; import { notifications } from "@mantine/notifications"; import { useUnit } from "effector-react"; +import { IconFolder, IconStar, IconTrash } from "@tabler/icons-react"; import type { CardDetails } from "@/shared/types/cards"; import i18n from "@/shared/i18n/i18n"; -import { deleteCard, deleteCardFileDuplicate, setCardHidden } 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 { refreshCardsSilent } from "@/entities/cards"; -import { closeCard, openCard } from "../../model"; +import { + $isOpeningInExplorer, + $isPlayingInSillyTavern, + $isTogglingHidden, + openDeleteCardModal, + openInExplorerRequested, + openRenameMainFileModal, + playInSillyTavernRequested, + toggleHiddenRequested, +} from "@/entities/cards"; +import { openCard } from "../../model"; import { $altGreetingIds, $altGreetingValues, @@ -43,47 +48,55 @@ 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); const [saveOpened, setSaveOpened] = useState(false); const [isSaving, setIsSaving] = useState(false); const [pendingCardJson, setPendingCardJson] = useState(null); - const [isTogglingHidden, setIsTogglingHidden] = useState(false); - - const [draft, altIds, altValues, groupIds, groupValues, lorebook, markSaved] = useUnit([ - $draft, - $altGreetingIds, - $altGreetingValues, - $groupGreetingIds, - $groupGreetingValues, - $lorebook, - draftSaved, + + 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] = + useUnit([ + $draft, + $altGreetingIds, + $altGreetingValues, + $groupGreetingIds, + $groupGreetingValues, + $lorebook, + draftSaved, + ]); + function canonicalizeForCompare(value: unknown): unknown { if (value === null || value === undefined) return null; if (Array.isArray(value)) return value.map(canonicalizeForCompare); @@ -193,60 +206,6 @@ export function CardDetailsActionsPanel({ const isHidden = Boolean(details?.innkeeperMeta?.isHidden); - async function toggleHidden(): Promise { - if (!details?.id) return; - if (isTogglingHidden) return; - setIsTogglingHidden(true); - try { - await setCardHidden(details.id, !isHidden); - notifications.show({ - title: i18n.t("cardDetails.actions"), - message: !isHidden ? i18n.t("cardDetails.hideOk") : i18n.t("cardDetails.showOk"), - color: "green", - }); - openCard(details.id); - refreshCardsSilent(); - } catch { - notifications.show({ - title: i18n.t("cardDetails.actions"), - message: i18n.t("cardDetails.hideFailed"), - color: "red", - }); - } finally { - setIsTogglingHidden(false); - } - } - - 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 duplicates = details?.duplicates ?? []; const hasDuplicates = duplicates.length > 0; @@ -315,34 +274,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; @@ -398,59 +329,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; @@ -491,7 +369,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} > @@ -511,7 +392,10 @@ export function CardDetailsActionsPanel({ fullWidth variant={isHidden ? "light" : "subtle"} color={isHidden ? "gray" : "orange"} - onClick={() => void toggleHidden()} + onClick={() => { + if (!details?.id) return; + onToggleHidden({ cardId: details.id, isHidden }); + }} disabled={!details?.id} loading={isTogglingHidden} > @@ -536,7 +420,11 @@ export function CardDetailsActionsPanel({ fullWidth variant="subtle" color="gray" - onClick={() => void openMainInExplorer()} + onClick={() => { + const p = (details?.file_path ?? "").trim(); + if (!p) return; + onOpenInExplorer({ filePath: p }); + }} disabled={!details?.file_path} loading={isOpeningInExplorer} > @@ -548,9 +436,10 @@ export function CardDetailsActionsPanel({ color="orange" disabled={!details?.file_path} onClick={() => { - const base = stripPngExt(getFilenameFromPath(details?.file_path)); - setRenameValue(base === i18n.t("empty.dash") ? "" : base); - setRenameOpened(true); + if (!details?.id) return; + const p = (details?.file_path ?? "").trim(); + if (!p) return; + onOpenRenameModal({ cardId: details.id, filePath: p }); }} > {i18n.t("cardDetails.rename")} @@ -560,7 +449,10 @@ export function CardDetailsActionsPanel({ variant="light" color="red" disabled={!details?.id} - onClick={() => setConfirmDeleteCardOpened(true)} + onClick={() => { + if (!details?.id) return; + onOpenDeleteModal({ cardId: details.id }); + }} > {i18n.t("cardDetails.delete")} @@ -669,19 +561,7 @@ export function CardDetailsActionsPanel({ loading={isSettingMainFile} aria-label={i18n.t("cardDetails.makeMainFile")} > - - - + @@ -695,20 +575,7 @@ export function CardDetailsActionsPanel({ loading={openingDuplicatePath === p} aria-label={i18n.t("cardDetails.showInExplorer")} > - - - - + @@ -725,23 +592,7 @@ export function CardDetailsActionsPanel({ }} aria-label={i18n.t("cardDetails.deleteDuplicate")} > - - - - - - - + @@ -783,69 +634,6 @@ export function CardDetailsActionsPanel({ - setConfirmDeleteCardOpened(false)} - title={i18n.t("cardDetails.confirmDeleteCardTitle")} - > - - - {i18n.t("cardDetails.confirmDeleteCardMessage")} - - - - - - - - - setRenameOpened(false)} - title={i18n.t("cardDetails.renameMainFileTitle")} - > - - - {i18n.t("cardDetails.renameMainFileHint")} - - setRenameValue(e.currentTarget.value)} - placeholder={i18n.t("cardDetails.renameMainFilePlaceholder")} - rightSection={.png} - /> - - - - - - - setSaveOpened(false)} diff --git a/client/src/pages/home/ui/HomePage.tsx b/client/src/pages/home/ui/HomePage.tsx index a8c8a0e..f212422 100644 --- a/client/src/pages/home/ui/HomePage.tsx +++ b/client/src/pages/home/ui/HomePage.tsx @@ -32,6 +32,7 @@ import { } from "@/features/cards-filters"; import { CardDetailsDrawer } from "@/features/card-details"; import { AppSidebar } from "@/widgets/app-sidebar"; +import { CardActionsModals } from "@/entities/cards"; function SunIcon() { return ( @@ -201,6 +202,7 @@ export function HomePage() { + From 791a7b4bcf8b25d737f598654f0a6cd42fcaa59e Mon Sep 17 00:00:00 2001 From: "DESKTOP-80A4L2N\\dima2" Date: Fri, 2 Jan 2026 23:43:38 +0300 Subject: [PATCH 11/28] fix: fav icon --- client/src/entities/cards/ui/Card.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/entities/cards/ui/Card.tsx b/client/src/entities/cards/ui/Card.tsx index e905186..e3c213f 100644 --- a/client/src/entities/cards/ui/Card.tsx +++ b/client/src/entities/cards/ui/Card.tsx @@ -184,7 +184,7 @@ export function Card({ )} - {isFav && ( + {isFav && isSillyTavern && ( Date: Sat, 3 Jan 2026 00:15:08 +0300 Subject: [PATCH 12/28] feat: save st card --- .../ui/components/CardDetailsActionsPanel.tsx | 69 ++++++++++++--- client/src/shared/api/cards/index.ts | 3 +- client/src/shared/i18n/locales/en.ts | 2 + client/src/shared/i18n/locales/ru.ts | 2 + server/src/locales/en.ts | 2 + server/src/locales/ru.ts | 2 + server/src/routes/api/cards.ts | 72 ++++++++++++++-- server/src/services/scan.ts | 83 ++++++++++++++----- 8 files changed, 196 insertions(+), 39 deletions(-) diff --git a/client/src/features/card-details/ui/components/CardDetailsActionsPanel.tsx b/client/src/features/card-details/ui/components/CardDetailsActionsPanel.tsx index 6205281..ace48da 100644 --- a/client/src/features/card-details/ui/components/CardDetailsActionsPanel.tsx +++ b/client/src/features/card-details/ui/components/CardDetailsActionsPanel.tsx @@ -20,6 +20,7 @@ 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 { $settings } from "@/entities/settings"; import { $isOpeningInExplorer, $isPlayingInSillyTavern, @@ -86,16 +87,25 @@ export function CardDetailsActionsPanel({ openDeleteCardModal, ]); - const [draft, altIds, altValues, groupIds, groupValues, lorebook, markSaved] = - useUnit([ - $draft, - $altGreetingIds, - $altGreetingValues, - $groupGreetingIds, - $groupGreetingValues, - $lorebook, - draftSaved, - ]); + const [ + draft, + altIds, + altValues, + groupIds, + groupValues, + lorebook, + markSaved, + settings, + ] = useUnit([ + $draft, + $altGreetingIds, + $altGreetingValues, + $groupGreetingIds, + $groupGreetingValues, + $lorebook, + draftSaved, + $settings, + ]); function canonicalizeForCompare(value: unknown): unknown { if (value === null || value === undefined) return null; @@ -205,6 +215,7 @@ export function CardDetailsActionsPanel({ : undefined; const isHidden = Boolean(details?.innkeeperMeta?.isHidden); + const isSillyTavern = Boolean(details?.is_sillytavern); const duplicates = details?.duplicates ?? []; const hasDuplicates = duplicates.length > 0; @@ -655,6 +666,25 @@ export function CardDetailsActionsPanel({ > {i18n.t("cardDetails.saveAsNew")} + {isSillyTavern && ( + + + + )} ) : ( <> @@ -680,6 +710,25 @@ export function CardDetailsActionsPanel({ > {i18n.t("cardDetails.saveOverwriteWithDuplicates")} + {isSillyTavern && ( + + + + )} )} - - - - + onConfirm={() => void deleteDuplicateConfirmed()} + /> - setSaveOpened(false)} - title={i18n.t("cardDetails.saveModalTitle")} - zIndex={500} - overlayProps={{ zIndex: 499 }} - > - - {!hasDuplicates ? ( - <> - - - {isSillyTavern && ( - - - - )} - - ) : ( - <> - - - - {isSillyTavern && ( - - - - )} - - )} - - - + onSave={(mode) => void doSave(mode)} + /> ); } 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/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..4c5cdd7 --- /dev/null +++ b/client/src/features/card-details/ui/components/sillytavern-chats-info.tsx @@ -0,0 +1,66 @@ +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/ui/sections/SearchAndSortSection.tsx b/client/src/features/cards-filters/ui/sections/SearchAndSortSection.tsx index 999346c..5746b45 100644 --- a/client/src/features/cards-filters/ui/sections/SearchAndSortSection.tsx +++ b/client/src/features/cards-filters/ui/sections/SearchAndSortSection.tsx @@ -15,6 +15,8 @@ const SORT_DATA = [ { 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; diff --git a/client/src/shared/i18n/locales/en.ts b/client/src/shared/i18n/locales/en.ts index c3a7bf6..c9eb551 100644 --- a/client/src/shared/i18n/locales/en.ts +++ b/client/src/shared/i18n/locales/en.ts @@ -179,6 +179,8 @@ const en = { sortChatsCountAsc: "Chats: low → high (ST)", sortLastChatDesc: "Last chat: new → old (ST)", sortLastChatAsc: "Last chat: old → new (ST)", + sortFirstChatDesc: "First chat: new → old (ST)", + sortFirstChatAsc: "First chat: old → new (ST)", chats: "Chats (ST)", chatsCount: "Chats count", chatsCountOp: "Compare", diff --git a/client/src/shared/i18n/locales/ru.ts b/client/src/shared/i18n/locales/ru.ts index 12a3180..594273c 100644 --- a/client/src/shared/i18n/locales/ru.ts +++ b/client/src/shared/i18n/locales/ru.ts @@ -181,6 +181,8 @@ const ru = { sortChatsCountAsc: "Чаты: меньше → больше (ST)", sortLastChatDesc: "Последний чат: новые → старые (ST)", sortLastChatAsc: "Последний чат: старые → новые (ST)", + sortFirstChatDesc: "Первый чат: новые → старые (ST)", + sortFirstChatAsc: "Первый чат: старые → новые (ST)", chats: "Чаты (ST)", chatsCount: "Кол-во чатов", chatsCountOp: "Сравнение", diff --git a/client/src/shared/types/cards-query/index.ts b/client/src/shared/types/cards-query/index.ts index 30d9a34..8932217 100644 --- a/client/src/shared/types/cards-query/index.ts +++ b/client/src/shared/types/cards-query/index.ts @@ -11,6 +11,8 @@ export type CardsSort = | "st_chats_count_asc" | "st_last_chat_at_desc" | "st_last_chat_at_asc" + | "st_first_chat_at_desc" + | "st_first_chat_at_asc" | "relevance"; export type CardsFtsField = diff --git a/server/src/routes/api/cards.ts b/server/src/routes/api/cards.ts index 82f56a4..34267f4 100644 --- a/server/src/routes/api/cards.ts +++ b/server/src/routes/api/cards.ts @@ -177,6 +177,8 @@ router.get("/cards", async (req: Request, res: Response) => { sortRaw === "st_chats_count_asc" || sortRaw === "st_last_chat_at_desc" || sortRaw === "st_last_chat_at_asc" || + sortRaw === "st_first_chat_at_desc" || + sortRaw === "st_first_chat_at_asc" || sortRaw === "relevance" ? sortRaw : undefined; @@ -382,7 +384,9 @@ router.get("/cards", async (req: Request, res: Response) => { sort === "st_chats_count_desc" || sort === "st_chats_count_asc" || sort === "st_last_chat_at_desc" || - sort === "st_last_chat_at_asc" + sort === "st_last_chat_at_asc" || + sort === "st_first_chat_at_desc" || + sort === "st_first_chat_at_asc" ) { params.is_sillytavern = "1"; } diff --git a/server/src/services/cards-filters-state.ts b/server/src/services/cards-filters-state.ts index af7bcf1..a867b5f 100644 --- a/server/src/services/cards-filters-state.ts +++ b/server/src/services/cards-filters-state.ts @@ -16,6 +16,8 @@ export type CardsSort = | "st_chats_count_asc" | "st_last_chat_at_desc" | "st_last_chat_at_asc" + | "st_first_chat_at_desc" + | "st_first_chat_at_asc" | "relevance"; export type CardsFtsField = @@ -124,6 +126,8 @@ const SORT_VALUES: CardsSort[] = [ "st_chats_count_asc", "st_last_chat_at_desc", "st_last_chat_at_asc", + "st_first_chat_at_desc", + "st_first_chat_at_asc", "relevance", ]; diff --git a/server/src/services/cards.ts b/server/src/services/cards.ts index c1a0860..cbfd111 100644 --- a/server/src/services/cards.ts +++ b/server/src/services/cards.ts @@ -32,6 +32,8 @@ export type CardsSort = | "st_chats_count_asc" | "st_last_chat_at_desc" | "st_last_chat_at_asc" + | "st_first_chat_at_desc" + | "st_first_chat_at_asc" | "relevance"; export type CardsFtsField = @@ -123,6 +125,8 @@ export class CardsService { effectiveSort === "st_chats_count_asc" || effectiveSort === "st_last_chat_at_desc" || effectiveSort === "st_last_chat_at_asc" || + effectiveSort === "st_first_chat_at_desc" || + effectiveSort === "st_first_chat_at_asc" || params.st_has_chats === true || (typeof params.st_chats_count === "number" && Number.isFinite(params.st_chats_count) && @@ -430,7 +434,8 @@ export class CardsService { SELECT cf.card_id as card_id, SUM(COALESCE(cf.st_chats_count, 0)) as st_chats_count_sum, - MAX(COALESCE(cf.st_last_chat_at, 0)) as st_last_chat_at_max + MAX(COALESCE(cf.st_last_chat_at, 0)) as st_last_chat_at_max, + MIN(NULLIF(cf.st_first_chat_at, 0)) as st_first_chat_at_min FROM card_files cf GROUP BY cf.card_id ) stagg ON stagg.card_id = c.id @@ -488,6 +493,10 @@ export class CardsService { return `ORDER BY COALESCE(stagg.st_last_chat_at_max, 0) ASC, c.created_at DESC`; case "st_last_chat_at_desc": return `ORDER BY COALESCE(stagg.st_last_chat_at_max, 0) DESC, c.created_at DESC`; + case "st_first_chat_at_asc": + return `ORDER BY COALESCE(stagg.st_first_chat_at_min, 0) ASC, c.created_at DESC`; + case "st_first_chat_at_desc": + return `ORDER BY COALESCE(stagg.st_first_chat_at_min, 0) DESC, c.created_at DESC`; case "created_at_desc": default: return `ORDER BY c.created_at DESC`; diff --git a/server/src/services/sillytavern.ts b/server/src/services/sillytavern.ts index 62b7354..f8f4f0d 100644 --- a/server/src/services/sillytavern.ts +++ b/server/src/services/sillytavern.ts @@ -35,6 +35,18 @@ function getMtimeMsSafe(filePath: string): number { } } +function getBirthtimeOrMtimeMsSafe(filePath: string): number { + try { + const st = statSync(filePath); + const birth = st.birthtimeMs; + const mtime = st.mtimeMs; + if (Number.isFinite(birth) && birth > 0) return birth; + return Number.isFinite(mtime) ? mtime : 0; + } catch { + return 0; + } +} + async function getChatsFolderStats( chatsFolderPath: string ): Promise<{ chatsCount: number; lastChatAt: number; firstChatAt: number }> { @@ -55,10 +67,13 @@ async function getChatsFolderStats( if (typeof name !== "string") continue; if (!name.toLowerCase().endsWith(".jsonl")) continue; chatsCount += 1; - const mtime = getMtimeMsSafe(join(chatsFolderPath, name)); - if (mtime > 0) { - if (mtime > lastChatAt) lastChatAt = mtime; - if (firstChatAt === 0 || mtime < firstChatAt) firstChatAt = mtime; + const fullPath = join(chatsFolderPath, name); + const mtime = getMtimeMsSafe(fullPath); + if (mtime > lastChatAt) lastChatAt = mtime; + + const createdAt = getBirthtimeOrMtimeMsSafe(fullPath); + if (createdAt > 0) { + if (firstChatAt === 0 || createdAt < firstChatAt) firstChatAt = createdAt; } } From 2b4629701b5006edb369fd09ec665a9b1cf455f6 Mon Sep 17 00:00:00 2001 From: "DESKTOP-80A4L2N\\dima2" Date: Sun, 4 Jan 2026 01:33:59 +0300 Subject: [PATCH 21/28] up --- .../ui/components/sillytavern-chats-info.tsx | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) 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 index 4c5cdd7..5d5b08d 100644 --- a/client/src/features/card-details/ui/components/sillytavern-chats-info.tsx +++ b/client/src/features/card-details/ui/components/sillytavern-chats-info.tsx @@ -21,18 +21,25 @@ export function SillyTavernChatsInfo({ 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), + (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), + 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; + 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); @@ -43,7 +50,9 @@ export function SillyTavernChatsInfo({ {i18n.t("cardDetails.stChatsCount")} - {totalChats > 0 ? String(totalChats) : i18n.t("empty.none")} + + {totalChats > 0 ? String(totalChats) : i18n.t("empty.none")} + @@ -62,5 +71,3 @@ export function SillyTavernChatsInfo({ ); } - - From b19f2fd71c49526c411384e6b4576ff32ad55e41 Mon Sep 17 00:00:00 2001 From: "DESKTOP-80A4L2N\\dima2" Date: Sun, 4 Jan 2026 18:51:51 +0300 Subject: [PATCH 22/28] feat: add show chats --- client/src/features/card-details/index.ts | 1 + .../src/features/card-details/model.chats.ts | 167 +++++++++ .../ui/components/CardDetailsTabs.tsx | 10 + .../ui/components/chats/card-chats-tab.tsx | 229 +++++++++++++ client/src/shared/api/cards/index.ts | 31 ++ client/src/shared/i18n/locales/en.ts | 12 + client/src/shared/i18n/locales/ru.ts | 12 + client/src/shared/types/card-chats/index.ts | 29 ++ server/src/locales/en.ts | 5 + server/src/locales/ru.ts | 5 + server/src/routes/api/card-chats.ts | 65 ++++ server/src/routes/api/index.ts | 2 + server/src/services/card-chats.ts | 321 ++++++++++++++++++ 13 files changed, 889 insertions(+) create mode 100644 client/src/features/card-details/model.chats.ts create mode 100644 client/src/features/card-details/ui/components/chats/card-chats-tab.tsx create mode 100644 client/src/shared/types/card-chats/index.ts create mode 100644 server/src/routes/api/card-chats.ts create mode 100644 server/src/services/card-chats.ts 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/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/chats/card-chats-tab.tsx b/client/src/features/card-details/ui/components/chats/card-chats-tab.tsx new file mode 100644 index 0000000..5302b64 --- /dev/null +++ b/client/src/features/card-details/ui/components/chats/card-chats-tab.tsx @@ -0,0 +1,229 @@ +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"; + return new Date(ms).toLocaleString(locale); +} + +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; + + 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/shared/api/cards/index.ts b/client/src/shared/api/cards/index.ts index 0c17c0b..add9abe 100644 --- a/client/src/shared/api/cards/index.ts +++ b/client/src/shared/api/cards/index.ts @@ -1,4 +1,5 @@ import type { CardDetails, CardListItem } from "@/shared/types/cards"; +import type { CardChatDetails, CardChatSummary } from "@/shared/types/card-chats"; import type { CardsQuery } from "@/shared/types/cards-query"; import type { CardsFiltersResponse } from "@/shared/types/cards-filters"; import i18n from "@/shared/i18n/i18n"; @@ -163,6 +164,36 @@ export async function getCardDetails(id: string): Promise { return response.json(); } +export async function getCardChats(cardId: string): Promise { + const response = await fetch(`/api/cards/${encodeURIComponent(cardId)}/chats`); + + if (!response.ok) { + const errorText = (await response.text().catch(() => "")).trim(); + if (errorText) throw new Error(errorText); + throw new Error(`${i18n.t("errors.loadChats")}: ${response.statusText}`); + } + + const data = (await response.json()) as { chats?: CardChatSummary[] }; + return Array.isArray(data?.chats) ? data.chats : []; +} + +export async function getCardChat( + cardId: string, + chatId: string +): Promise { + const response = await fetch( + `/api/cards/${encodeURIComponent(cardId)}/chats/${encodeURIComponent(chatId)}` + ); + + if (!response.ok) { + const errorText = (await response.text().catch(() => "")).trim(); + if (errorText) throw new Error(errorText); + throw new Error(`${i18n.t("errors.loadChat")}: ${response.statusText}`); + } + + return response.json(); +} + export async function deleteCardFileDuplicate( cardId: string, filePath: string diff --git a/client/src/shared/i18n/locales/en.ts b/client/src/shared/i18n/locales/en.ts index c9eb551..af6ef34 100644 --- a/client/src/shared/i18n/locales/en.ts +++ b/client/src/shared/i18n/locales/en.ts @@ -34,6 +34,8 @@ const en = { saveSettings: "Failed to save settings", loadCards: "Failed to load cards", loadCard: "Failed to load card", + loadChats: "Failed to load chats", + loadChat: "Failed to load chat", loadFiltersTitle: "Failed to load filters", loadFiltersStateTitle: "Failed to load saved filters", saveFiltersStateTitle: "Failed to save filters", @@ -241,8 +243,18 @@ const en = { tabsMain: "Main", tabsAlt: "Alt greetings", tabsSystem: "System", + tabsChats: "Chats", tabsLorebook: "Lorebook", tabsRaw: "Raw", + chats: { + title: "Chats", + empty: "No chats", + pickChat: "Pick a chat", + selectChatHint: "Select a chat on the left", + chatTitle: "Chat: {{title}}", + chatTitleEmpty: "Chat", + messagesCount: "Messages: {{count}}", + }, fieldName: "Name", fieldTags: "Tags", fieldCreator: "Creator", diff --git a/client/src/shared/i18n/locales/ru.ts b/client/src/shared/i18n/locales/ru.ts index 594273c..0477360 100644 --- a/client/src/shared/i18n/locales/ru.ts +++ b/client/src/shared/i18n/locales/ru.ts @@ -34,6 +34,8 @@ const ru = { saveSettings: "Ошибка сохранения настроек", loadCards: "Ошибка загрузки карточек", loadCard: "Ошибка загрузки карточки", + loadChats: "Ошибка загрузки чатов", + loadChat: "Ошибка загрузки чата", loadFiltersTitle: "Ошибка загрузки фильтров", loadFiltersStateTitle: "Ошибка загрузки сохранённых фильтров", saveFiltersStateTitle: "Ошибка сохранения фильтров", @@ -244,7 +246,17 @@ const ru = { tabsAlt: "Альтернативные приветствия", tabsSystem: "Системное", tabsLorebook: "Лорбук", + tabsChats: "Чаты", tabsRaw: "JSON", + chats: { + title: "Чаты", + empty: "Чатов нет", + pickChat: "Выберите чат", + selectChatHint: "Выберите чат слева", + chatTitle: "Чат: {{title}}", + chatTitleEmpty: "Чат", + messagesCount: "Сообщений: {{count}}", + }, fieldName: "Имя", fieldTags: "Теги", fieldCreator: "Создатель", diff --git a/client/src/shared/types/card-chats/index.ts b/client/src/shared/types/card-chats/index.ts new file mode 100644 index 0000000..878fd2f --- /dev/null +++ b/client/src/shared/types/card-chats/index.ts @@ -0,0 +1,29 @@ +export interface CardChatSummary { + id: string; // file basename, e.g. "Foo.jsonl" + title: string; + messages_count: number; + last_message_at: number; // ms timestamp +} + +export interface CardChatMessage { + name: string; + is_user: boolean; + is_system: boolean; + mes: string; + swipes?: string[]; + swipe_id?: number; + send_date: string | number | null; + send_date_ms: number; +} + +export interface CardChatDetails { + id: string; + title: string; + meta: { + user_name?: string; + character_name?: string; + }; + messages: CardChatMessage[]; +} + + diff --git a/server/src/locales/en.ts b/server/src/locales/en.ts index 40a1add..ebe2644 100644 --- a/server/src/locales/en.ts +++ b/server/src/locales/en.ts @@ -99,6 +99,11 @@ const en = { "api.cards.save_failed": "Could not save card", "api.cards.cardsFolderPath_not_set": "Library folder is not configured (cardsFolderPath)", + "api.cards.chats_list_failed": "Could not get chats list", + "api.cards.chat_failed": "Could not get chat", + "api.cards.invalid_chatId": "Invalid chatId", + "api.cards.chats_folder_not_found": "Chats folder was not found", + "api.cards.chat_not_found": "Chat was not found", "api.cardsImport.invalid_format": "Invalid data format. Expected object with sourceFolderPath, importMode, duplicatesMode", diff --git a/server/src/locales/ru.ts b/server/src/locales/ru.ts index 7f7d0ab..097e40f 100644 --- a/server/src/locales/ru.ts +++ b/server/src/locales/ru.ts @@ -112,6 +112,11 @@ const ru = { "api.cards.save_failed": "Не удалось сохранить карточку", "api.cards.cardsFolderPath_not_set": "Папка библиотеки не настроена (cardsFolderPath)", + "api.cards.chats_list_failed": "Не удалось получить список чатов", + "api.cards.chat_failed": "Не удалось получить чат", + "api.cards.invalid_chatId": "Некорректный chatId", + "api.cards.chats_folder_not_found": "Папка чатов не найдена", + "api.cards.chat_not_found": "Чат не найден", "api.cardsImport.invalid_format": "Неверный формат данных. Ожидается объект с полями sourceFolderPath, importMode, duplicatesMode", diff --git a/server/src/routes/api/card-chats.ts b/server/src/routes/api/card-chats.ts new file mode 100644 index 0000000..c635413 --- /dev/null +++ b/server/src/routes/api/card-chats.ts @@ -0,0 +1,65 @@ +import { Router, type Request, type Response } from "express"; +import type Database from "better-sqlite3"; +import { AppError } from "../../errors/app-error"; +import { sendError } from "../../errors/http"; +import { logger } from "../../utils/logger"; +import { listCardChats, readCardChat } from "../../services/card-chats"; + +const router = Router(); + +function getDb(req: Request): Database.Database { + return req.app.locals.db as Database.Database; +} + +// GET /api/cards/:id/chats - list chat files for SillyTavern card +router.get("/cards/:id/chats", async (req: Request, res: Response) => { + try { + const { id } = req.params; + const chats = await listCardChats(getDb(req), String(id ?? "").trim()); + res.json({ chats }); + } catch (error) { + logger.errorKey(error, "api.cards.chats_list_failed"); + return sendError(res, error, { + status: 500, + code: "api.cards.chats_list_failed", + }); + } +}); + +// GET /api/cards/:id/chats/:chatId - read full chat +router.get("/cards/:id/chats/:chatId", async (req: Request, res: Response) => { + try { + const { id, chatId } = req.params; + if (typeof chatId !== "string" || chatId.trim().length === 0) { + throw new AppError({ status: 400, code: "api.cards.invalid_chatId" }); + } + + try { + const chat = await readCardChat( + getDb(req), + String(id ?? "").trim(), + chatId.trim() + ); + if (!chat) { + throw new AppError({ status: 404, code: "api.cards.chat_not_found" }); + } + res.json(chat); + return; + } catch (e) { + if (e instanceof Error && e.message === "invalid_chat_id") { + throw new AppError({ status: 400, code: "api.cards.invalid_chatId" }); + } + throw e; + } + } catch (error) { + logger.errorKey(error, "api.cards.chat_failed"); + return sendError(res, error, { + status: 500, + code: "api.cards.chat_failed", + }); + } +}); + +export default router; + + diff --git a/server/src/routes/api/index.ts b/server/src/routes/api/index.ts index 16eb13a..e5b72ee 100644 --- a/server/src/routes/api/index.ts +++ b/server/src/routes/api/index.ts @@ -8,6 +8,7 @@ import settings from "./settings"; import viewSettings from "./view-settings"; import cardsImport from "./cards-import"; import cards from "./cards"; +import cardChats from "./card-chats"; import tags from "./tags"; import thumbnail from "./thumbnail"; import image from "./image"; @@ -81,6 +82,7 @@ router.use(settings); router.use(viewSettings); router.use(cardsImport); router.use(cards); +router.use(cardChats); router.use(tags); router.use(thumbnail); router.use(image); diff --git a/server/src/services/card-chats.ts b/server/src/services/card-chats.ts new file mode 100644 index 0000000..2b320bf --- /dev/null +++ b/server/src/services/card-chats.ts @@ -0,0 +1,321 @@ +import type Database from "better-sqlite3"; +import { createReadStream, existsSync, statSync } from "node:fs"; +import { readdir } from "node:fs/promises"; +import { basename, join, parse, resolve, sep } from "node:path"; +import readline from "node:readline"; + +export interface CardChatSummary { + id: string; // file basename, e.g. "Foo.jsonl" + title: string; // filename without extension by default + messages_count: number; + last_message_at: number; // ms timestamp +} + +export interface CardChatMessage { + name: string; + is_user: boolean; + is_system: boolean; + mes: string; + swipes?: string[]; + swipe_id?: number; + send_date: string | number | null; + send_date_ms: number; +} + +export interface CardChatDetails { + id: string; + title: string; + meta: { + user_name?: string; + character_name?: string; + }; + messages: CardChatMessage[]; +} + +function isNonEmptyString(v: unknown): v is string { + return typeof v === "string" && v.trim().length > 0; +} + +function toSafeMs(v: unknown): number { + if (typeof v === "number" && Number.isFinite(v)) return v; + if (typeof v === "string") { + const s = v.trim(); + if (!s) return 0; + const n = Number(s); + if (Number.isFinite(n)) return n; + const t = Date.parse(s); + return Number.isFinite(t) ? t : 0; + } + return 0; +} + +function getDbCardMainFilePath( + db: Database.Database, + cardId: string +): string | null { + const row = db + .prepare( + ` + SELECT + c.primary_file_path, + ( + SELECT cf.file_path + FROM card_files cf + WHERE cf.card_id = c.id + ORDER BY cf.file_birthtime ASC, cf.file_path ASC + LIMIT 1 + ) AS first_file_path + FROM cards c + WHERE c.id = ? + LIMIT 1 + ` + ) + .get(cardId) as + | { primary_file_path: string | null; first_file_path: string | null } + | undefined; + + if (!row) return null; + const primary = row.primary_file_path?.trim() + ? row.primary_file_path.trim() + : null; + const first = row.first_file_path?.trim() + ? row.first_file_path.trim() + : null; + return primary ?? first; +} + +export function resolveCardChatsFolderPath( + db: Database.Database, + cardId: string +): string | null { + const mainFilePath = getDbCardMainFilePath(db, cardId); + if (mainFilePath) { + const metaRow = db + .prepare( + ` + SELECT st_chats_folder_path + FROM card_files + WHERE file_path = ? + LIMIT 1 + ` + ) + .get(mainFilePath) as { st_chats_folder_path: string | null } | undefined; + + const p = metaRow?.st_chats_folder_path?.trim(); + if (p) return p; + } + + const fallbackRow = db + .prepare( + ` + SELECT st_chats_folder_path + FROM card_files + WHERE + card_id = ? + AND st_chats_folder_path IS NOT NULL + AND TRIM(st_chats_folder_path) <> '' + ORDER BY + COALESCE(st_last_chat_at, 0) DESC, + COALESCE(file_birthtime, 0) ASC, + file_path ASC + LIMIT 1 + ` + ) + .get(cardId) as { st_chats_folder_path: string | null } | undefined; + + const p = fallbackRow?.st_chats_folder_path?.trim(); + return p ? p : null; +} + +function assertChatIdSafe(chatId: string): void { + const id = String(chatId ?? "").trim(); + // No slashes and must be a single basename + if (!id) throw new Error("invalid_chat_id"); + if (id.includes("/") || id.includes("\\") || id.includes("\0")) { + throw new Error("invalid_chat_id"); + } + if (basename(id) !== id) throw new Error("invalid_chat_id"); + if (!id.toLowerCase().endsWith(".jsonl")) throw new Error("invalid_chat_id"); +} + +function safeJoinUnderDir(dir: string, leaf: string): string { + const base = resolve(dir); + const full = resolve(base, leaf); + const prefix = base.endsWith(sep) ? base : base + sep; + if (full === base) return full; + if (!full.startsWith(prefix)) throw new Error("invalid_chat_id"); + return full; +} + +async function scanChatFileForSummary(filePath: string): Promise<{ + messagesCount: number; + lastMessageAt: number; +}> { + let messagesCount = 0; + let lastMessageAt = 0; + let lineIdx = 0; + + const rl = readline.createInterface({ + input: createReadStream(filePath, { encoding: "utf-8" }), + crlfDelay: Infinity, + }); + + try { + for await (const line of rl) { + const trimmed = String(line ?? "").trim(); + if (!trimmed) continue; + lineIdx += 1; + if (lineIdx === 1) { + // metadata line + continue; + } + messagesCount += 1; + try { + const obj = JSON.parse(trimmed) as any; + const ms = toSafeMs(obj?.send_date); + if (ms > lastMessageAt) lastMessageAt = ms; + } catch { + // ignore malformed message lines + } + } + } finally { + rl.close(); + } + + if (!(lastMessageAt > 0)) { + try { + const st = statSync(filePath); + const mtime = st.mtimeMs; + if (Number.isFinite(mtime) && mtime > 0) lastMessageAt = mtime; + } catch { + // ignore + } + } + + return { messagesCount, lastMessageAt }; +} + +export async function listCardChats( + db: Database.Database, + cardId: string +): Promise { + const folder = resolveCardChatsFolderPath(db, cardId); + if (!folder) return []; + if (!existsSync(folder)) return []; + + let entries: Array<{ name: string; fullPath: string }> = []; + try { + const files = await readdir(folder, { withFileTypes: true }); + entries = files + .filter((f) => f.isFile()) + .map((f) => String(f.name ?? "")) + .filter((name) => name.toLowerCase().endsWith(".jsonl")) + .map((name) => ({ name, fullPath: join(folder, name) })); + } catch { + return []; + } + + const out: CardChatSummary[] = []; + // Keep it sequential to avoid unbounded parallel I/O. + for (const e of entries) { + try { + const { messagesCount, lastMessageAt } = await scanChatFileForSummary( + e.fullPath + ); + const title = parse(e.name).name; + out.push({ + id: e.name, + title, + messages_count: messagesCount, + last_message_at: lastMessageAt, + }); + } catch { + continue; + } + } + + out.sort((a, b) => { + if (a.last_message_at !== b.last_message_at) + return b.last_message_at - a.last_message_at; + return a.id.localeCompare(b.id); + }); + + return out; +} + +export async function readCardChat( + db: Database.Database, + cardId: string, + chatId: string +): Promise { + const folder = resolveCardChatsFolderPath(db, cardId); + if (!folder) return null; + if (!existsSync(folder)) return null; + + assertChatIdSafe(chatId); + const filePath = safeJoinUnderDir(folder, chatId); + if (!existsSync(filePath)) return null; + + const messages: CardChatMessage[] = []; + const meta: { user_name?: string; character_name?: string } = {}; + let lineIdx = 0; + + const rl = readline.createInterface({ + input: createReadStream(filePath, { encoding: "utf-8" }), + crlfDelay: Infinity, + }); + + try { + for await (const line of rl) { + const trimmed = String(line ?? "").trim(); + if (!trimmed) continue; + lineIdx += 1; + if (lineIdx === 1) { + try { + const obj = JSON.parse(trimmed) as any; + if (isNonEmptyString(obj?.user_name)) + meta.user_name = obj.user_name.trim(); + if (isNonEmptyString(obj?.character_name)) + meta.character_name = obj.character_name.trim(); + } catch { + // ignore metadata parse errors + } + continue; + } + + try { + const obj = JSON.parse(trimmed) as any; + const sendDateRaw = obj?.send_date ?? null; + const sendDateMs = toSafeMs(sendDateRaw); + messages.push({ + name: isNonEmptyString(obj?.name) ? obj.name : "", + is_user: Boolean(obj?.is_user), + is_system: Boolean(obj?.is_system), + mes: isNonEmptyString(obj?.mes) ? obj.mes : String(obj?.mes ?? ""), + swipes: Array.isArray(obj?.swipes) + ? obj.swipes.map((x: any) => String(x)) + : undefined, + swipe_id: + typeof obj?.swipe_id === "number" && Number.isFinite(obj.swipe_id) + ? obj.swipe_id + : undefined, + send_date: sendDateRaw, + send_date_ms: sendDateMs, + }); + } catch { + // ignore malformed message lines + } + } + } finally { + rl.close(); + } + + return { + id: chatId, + title: parse(chatId).name, + meta, + messages, + }; +} + + From 908ab9b773c8c56dded028231d0a1b53ba6f6ee3 Mon Sep 17 00:00:00 2001 From: "DESKTOP-80A4L2N\\dima2" Date: Sun, 4 Jan 2026 19:12:22 +0300 Subject: [PATCH 23/28] fix: chat dates --- .../ui/components/chats/card-chats-tab.tsx | 52 ++++++--- client/src/shared/types/card-chats/index.ts | 2 +- server/src/services/card-chats.ts | 107 ++++++++++++++++-- 3 files changed, 135 insertions(+), 26 deletions(-) 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 index 5302b64..3fd1409 100644 --- 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 @@ -15,7 +15,10 @@ import { 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 type { + CardChatMessage, + CardChatSummary, +} from "@/shared/types/card-chats"; import { $chat, $chatError, @@ -30,7 +33,16 @@ import { function formatDateOrDash(ms: number): string { if (!(ms > 0)) return i18n.t("empty.dash"); const locale = i18n.language === "ru" ? "ru-RU" : "en-US"; - return new Date(ms).toLocaleString(locale); + // 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({ @@ -72,11 +84,7 @@ function ChatListItem({ ); } -function ChatMessage({ - msg, -}: { - msg: CardChatMessage; -}) { +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); @@ -150,11 +158,21 @@ export function CardChatsTab() { }, [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} @@ -169,7 +187,7 @@ export function CardChatsTab() { {!isChatsLoading && !hasChats ? ( {t("cardDetails.chats.empty")} ) : ( - + {chats.map((c) => ( - + {chat?.title @@ -205,11 +231,11 @@ export function CardChatsTab() { {!selectedChatId ? ( {t("cardDetails.chats.selectChatHint")} ) : !chat && isChatLoading ? ( - + ) : chat ? ( - + {chat.messages.map((m, idx) => ( @@ -225,5 +251,3 @@ export function CardChatsTab() { ); } - - diff --git a/client/src/shared/types/card-chats/index.ts b/client/src/shared/types/card-chats/index.ts index 878fd2f..cb7e30f 100644 --- a/client/src/shared/types/card-chats/index.ts +++ b/client/src/shared/types/card-chats/index.ts @@ -12,7 +12,7 @@ export interface CardChatMessage { mes: string; swipes?: string[]; swipe_id?: number; - send_date: string | number | null; + send_date: string | null; // normalized ISO timestamp (UTC) or null send_date_ms: number; } diff --git a/server/src/services/card-chats.ts b/server/src/services/card-chats.ts index 2b320bf..01f446f 100644 --- a/server/src/services/card-chats.ts +++ b/server/src/services/card-chats.ts @@ -18,7 +18,11 @@ export interface CardChatMessage { mes: string; swipes?: string[]; swipe_id?: number; - send_date: string | number | null; + /** + * Normalized ISO timestamp (UTC) or null if unavailable. + * We intentionally do not return raw original formats from ST. + */ + send_date: string | null; send_date_ms: number; } @@ -37,12 +41,95 @@ function isNonEmptyString(v: unknown): v is string { } function toSafeMs(v: unknown): number { - if (typeof v === "number" && Number.isFinite(v)) return v; + if (typeof v === "number" && Number.isFinite(v)) { + // Heuristic: seconds vs milliseconds + if (v > 0 && v < 1e12) return Math.floor(v * 1000); + return Math.floor(v); + } if (typeof v === "string") { const s = v.trim(); if (!s) return 0; const n = Number(s); - if (Number.isFinite(n)) return n; + if (Number.isFinite(n)) { + if (n > 0 && n < 1e12) return Math.floor(n * 1000); + return Math.floor(n); + } + // Common SillyTavern formats: + // - 2025-05-23@17h35m00s + // - 2025-05-23 @17h35m00s + // - 23.05.2025, 17:58:29 + // - November 11, 2025 3:17am + { + const m = + /^(\d{4})-(\d{2})-(\d{2})\s*@\s*(\d{2})h(\d{2})m(\d{2})s$/i.exec(s); + if (m) { + const [, yy, mm, dd, hh, mi, ss] = m; + const iso = `${yy}-${mm}-${dd}T${hh}:${mi}:${ss}`; + const t = Date.parse(iso); + return Number.isFinite(t) ? t : 0; + } + } + { + const m = + /^(\d{1,2})\.(\d{1,2})\.(\d{4})(?:,\s*|\s+)(\d{1,2}):(\d{2})(?::(\d{2}))?$/i.exec( + s + ); + if (m) { + const [, dd, mm, yy, hh, mi, ss = "0"] = m; + const iso = `${yy}-${String(mm).padStart(2, "0")}-${String(dd).padStart( + 2, + "0" + )}T${String(hh).padStart(2, "0")}:${mi}:${String(ss).padStart(2, "0")}`; + const t = Date.parse(iso); + return Number.isFinite(t) ? t : 0; + } + } + { + const m = + /^([A-Za-z]+)\s+(\d{1,2}),\s*(\d{4})(?:,\s*|\s+)(\d{1,2}):(\d{2})(?::(\d{2}))?\s*([AaPp])[Mm]$/.exec( + s + ); + if (m) { + const [, monthRaw, ddRaw, yyRaw, hhRaw, miRaw, ssRaw = "0", apRaw] = m; + const monthKey = monthRaw.toLowerCase(); + const monthMap: Record = { + january: 0, + february: 1, + march: 2, + april: 3, + may: 4, + june: 5, + july: 6, + august: 7, + september: 8, + october: 9, + november: 10, + december: 11, + }; + const month = monthMap[monthKey]; + if (typeof month === "number") { + const yy = Number(yyRaw); + const dd = Number(ddRaw); + let hh = Number(hhRaw); + const mi = Number(miRaw); + const ss = Number(ssRaw); + if ( + Number.isFinite(yy) && + Number.isFinite(dd) && + Number.isFinite(hh) && + Number.isFinite(mi) && + Number.isFinite(ss) + ) { + const isPm = apRaw.toLowerCase() === "p"; + if (hh === 12) hh = isPm ? 12 : 0; + else if (isPm) hh += 12; + const t = new Date(yy, month, dd, hh, mi, ss).getTime(); + return Number.isFinite(t) ? t : 0; + } + } + } + } + const t = Date.parse(s); return Number.isFinite(t) ? t : 0; } @@ -78,9 +165,7 @@ function getDbCardMainFilePath( const primary = row.primary_file_path?.trim() ? row.primary_file_path.trim() : null; - const first = row.first_file_path?.trim() - ? row.first_file_path.trim() - : null; + const first = row.first_file_path?.trim() ? row.first_file_path.trim() : null; return primary ?? first; } @@ -172,7 +257,7 @@ async function scanChatFileForSummary(filePath: string): Promise<{ messagesCount += 1; try { const obj = JSON.parse(trimmed) as any; - const ms = toSafeMs(obj?.send_date); + const ms = toSafeMs(obj?.send_date_ms ?? obj?.send_date); if (ms > lastMessageAt) lastMessageAt = ms; } catch { // ignore malformed message lines @@ -285,8 +370,10 @@ export async function readCardChat( try { const obj = JSON.parse(trimmed) as any; - const sendDateRaw = obj?.send_date ?? null; + const sendDateRaw = obj?.send_date_ms ?? obj?.send_date ?? null; const sendDateMs = toSafeMs(sendDateRaw); + const sendDateIso = + sendDateMs > 0 ? new Date(sendDateMs).toISOString() : null; messages.push({ name: isNonEmptyString(obj?.name) ? obj.name : "", is_user: Boolean(obj?.is_user), @@ -299,7 +386,7 @@ export async function readCardChat( typeof obj?.swipe_id === "number" && Number.isFinite(obj.swipe_id) ? obj.swipe_id : undefined, - send_date: sendDateRaw, + send_date: sendDateIso, send_date_ms: sendDateMs, }); } catch { @@ -317,5 +404,3 @@ export async function readCardChat( messages, }; } - - From d3e3f612d354f9b88902f38fbbc5771f54e183bd Mon Sep 17 00:00:00 2001 From: "DESKTOP-80A4L2N\\dima2" Date: Sun, 4 Jan 2026 22:01:58 +0300 Subject: [PATCH 24/28] fix: show preloader --- client/src/features/cards-filters/model.ts | 26 ++++++++++++++++++- .../ui/sections/ChatsSection.tsx | 3 ++- client/src/features/cards-live-sync/model.ts | 4 +-- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/client/src/features/cards-filters/model.ts b/client/src/features/cards-filters/model.ts index acd4f3c..6e2319e 100644 --- a/client/src/features/cards-filters/model.ts +++ b/client/src/features/cards-filters/model.ts @@ -230,6 +230,9 @@ 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(); @@ -272,6 +275,7 @@ $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 })) @@ -400,6 +404,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))); @@ -408,7 +424,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({ @@ -543,6 +566,7 @@ const persistNonTextChanged = merge([ setCreators, setSpecVersions, setTags, + syncTagsFromOptions, setCreatedFrom, setCreatedTo, setPromptTokensMin, diff --git a/client/src/features/cards-filters/ui/sections/ChatsSection.tsx b/client/src/features/cards-filters/ui/sections/ChatsSection.tsx index 301e0af..f603952 100644 --- a/client/src/features/cards-filters/ui/sections/ChatsSection.tsx +++ b/client/src/features/cards-filters/ui/sections/ChatsSection.tsx @@ -58,7 +58,8 @@ export function ChatsSection() { { if (typeof v !== "number" || !Number.isFinite(v)) { onSetCount(undefined); diff --git a/client/src/features/cards-live-sync/model.ts b/client/src/features/cards-live-sync/model.ts index 116a990..565e2ef 100644 --- a/client/src/features/cards-live-sync/model.ts +++ b/client/src/features/cards-live-sync/model.ts @@ -90,7 +90,7 @@ sample({ sample({ clock: cardsResynced, - target: applyFilters, + target: applyFiltersSilent, }); // При подключении: один раз синхронизируем UI (важно, если стартовый scan прошёл до подключения SSE) @@ -101,7 +101,7 @@ sample({ sample({ clock: connected, - target: applyFilters, + target: applyFiltersSilent, }); // Прогресс сканирования (через Notifications) From f405c7167076d6778958b2263ccdb0c6c43669fa Mon Sep 17 00:00:00 2001 From: "DESKTOP-80A4L2N\\dima2" Date: Sun, 4 Jan 2026 22:22:19 +0300 Subject: [PATCH 25/28] feat: add delete card chats --- client/src/entities/cards/model.actions.ts | 59 +++++++++++++---- client/src/entities/cards/ui/Card.tsx | 1 + .../entities/cards/ui/card-actions-menu.tsx | 10 ++- .../entities/cards/ui/card-actions-modals.tsx | 17 ++++- .../ui/components/CardDetailsActionsPanel.tsx | 5 +- client/src/features/cards-live-sync/model.ts | 1 - client/src/shared/api/cards/index.ts | 15 ++++- client/src/shared/i18n/locales/en.ts | 4 ++ client/src/shared/i18n/locales/ru.ts | 4 ++ server/src/routes/api/cards.ts | 63 ++++++++++++++++++- 10 files changed, 159 insertions(+), 20 deletions(-) diff --git a/client/src/entities/cards/model.actions.ts b/client/src/entities/cards/model.actions.ts index 3af5a1f..e1ae2ea 100644 --- a/client/src/entities/cards/model.actions.ts +++ b/client/src/entities/cards/model.actions.ts @@ -29,9 +29,13 @@ export const closeRenameMainFileModal = createEvent(); export const renameMainFileValueChanged = createEvent(); export const renameMainFileConfirmed = createEvent(); -export const openDeleteCardModal = createEvent<{ cardId: string }>(); +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 }) => { @@ -71,12 +75,14 @@ const renameMainFileFx = createEffect< return { cardId }; }); -const deleteCardFx = createEffect<{ cardId: string }, { cardId: string }, Error>( - async ({ cardId }) => { - await deleteCard(cardId); - 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, @@ -97,6 +103,8 @@ type RenameModalState = { type DeleteModalState = { opened: boolean; cardId: string | null; + isSillyTavern: boolean; + deleteChats: boolean; }; export const $renameMainFileModal = createStore({ @@ -125,9 +133,25 @@ export const $renameMainFileModal = createStore({ export const $deleteCardModal = createStore({ opened: false, cardId: null, + isSillyTavern: false, + deleteChats: false, }) - .on(openDeleteCardModal, (_, p) => ({ opened: true, cardId: p.cardId })) - .on(closeDeleteCardModal, () => ({ opened: false, cardId: null })); + .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, @@ -253,16 +277,29 @@ sample({ clock: deleteCardConfirmed, source: $deleteCardModal, filter: (m) => Boolean(m.opened && m.cardId), - fn: (m) => ({ cardId: m.cardId as string }), + fn: (m) => ({ + cardId: m.cardId as string, + deleteChats: Boolean(m.isSillyTavern && m.deleteChats), + }), target: deleteCardFx, }); -deleteCardFx.doneData.watch(() => { +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({ diff --git a/client/src/entities/cards/ui/Card.tsx b/client/src/entities/cards/ui/Card.tsx index e3c213f..53ce673 100644 --- a/client/src/entities/cards/ui/Card.tsx +++ b/client/src/entities/cards/ui/Card.tsx @@ -276,6 +276,7 @@ export function Card({ cardId={card.id} filePath={card.file_path} isHidden={Boolean(card.innkeeperMeta?.isHidden)} + isSillyTavern={isSillyTavern} /> diff --git a/client/src/entities/cards/ui/card-actions-menu.tsx b/client/src/entities/cards/ui/card-actions-menu.tsx index 5d5795a..0d862ff 100644 --- a/client/src/entities/cards/ui/card-actions-menu.tsx +++ b/client/src/entities/cards/ui/card-actions-menu.tsx @@ -26,9 +26,15 @@ export type CardActionsMenuProps = { cardId: string; filePath: string | null; isHidden: boolean; + isSillyTavern: boolean; }; -export function CardActionsMenu({ cardId, filePath, isHidden }: CardActionsMenuProps) { +export function CardActionsMenu({ + cardId, + filePath, + isHidden, + isSillyTavern, +}: CardActionsMenuProps) { const { t } = useTranslation(); const [isPlaying, isTogglingHidden, isOpeningInExplorer] = useUnit([ @@ -147,7 +153,7 @@ export function CardActionsMenu({ cardId, filePath, isHidden }: CardActionsMenuP leftSection={} onClick={(e) => { e.stopPropagation(); - onOpenDelete({ cardId }); + 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 index 80d0e49..2786377 100644 --- a/client/src/entities/cards/ui/card-actions-modals.tsx +++ b/client/src/entities/cards/ui/card-actions-modals.tsx @@ -1,4 +1,4 @@ -import { Button, Group, Modal, Stack, Text, TextInput } from "@mantine/core"; +import { Button, Checkbox, Group, Modal, Stack, Text, TextInput } from "@mantine/core"; import { useUnit } from "effector-react"; import i18n from "@/shared/i18n/i18n"; import { @@ -9,6 +9,7 @@ import { closeDeleteCardModal, closeRenameMainFileModal, deleteCardConfirmed, + deleteChatsToggled, renameMainFileConfirmed, renameMainFileValueChanged, } from "../model.actions"; @@ -27,12 +28,14 @@ export function CardActionsModals() { onRenameConfirm, onCloseDelete, onDeleteConfirm, + onDeleteChatsToggled, ] = useUnit([ closeRenameMainFileModal, renameMainFileValueChanged, renameMainFileConfirmed, closeDeleteCardModal, deleteCardConfirmed, + deleteChatsToggled, ]); return ( @@ -83,6 +86,18 @@ export function CardActionsModals() { > {i18n.t("cardDetails.confirmDeleteCardMessage")} + {deleteModal.isSillyTavern ? ( + + {i18n.t("cardDetails.confirmDeleteCardStWarning")} + + ) : null} + {deleteModal.isSillyTavern ? ( + onDeleteChatsToggled(e.currentTarget.checked)} + /> + ) : null}