Export JanitorAI character cards as SillyTavern-compatible PNGs.
// ==UserScript==
// @name JanitorAI Character Card Scraper
// @namespace https://sleazyfork.org/en/scripts/537206-janitorai-character-card-scraper
// @version 0.5.0
// @description Export JanitorAI character cards as SillyTavern-compatible PNGs.
// @match https://janitorai.com/*
// @icon https://ella.janitorai.com/hotlink-ok/favicon-32x32.png
// @grant none
// @run-at document-idle
// @license MIT
// ==/UserScript==
var JanitorAICharacterCardScraper = (function(exports) {
"use strict";
function isObjectLike(value) {
return value !== null && typeof value === "object";
}
function isPlainObject(value) {
return isObjectLike(value) && !Array.isArray(value);
}
function asPlainObject(value) {
return isPlainObject(value) ? value : null;
}
function asPlainObjectOrEmpty(value) {
return asPlainObject(value) ?? {};
}
function cloneShallow(value) {
if (Array.isArray(value)) return [...value];
if (isPlainObject(value)) return { ...value };
return value;
}
function asArray(value) {
if (Array.isArray(value)) return value;
if (value === null || value === void 0) return [];
return [value];
}
function asArrayStrict(value) {
return Array.isArray(value) ? value : [];
}
function uniqueStrings(values, { allowEmpty = false } = {}) {
const seen = /* @__PURE__ */ new Set();
const output = [];
for (const item of asArray(values)) {
const normalized = typeof item === "string" ? item.trim() : "";
if (!allowEmpty && !normalized) continue;
if (seen.has(normalized)) continue;
seen.add(normalized);
output.push(normalized);
}
return output;
}
function asString(value) {
return typeof value === "string" ? value : "";
}
function asTrimmedString(value) {
return typeof value === "string" ? value.trim() : "";
}
function normalizeOptionalString(value) {
const normalized = asTrimmedString(value);
return normalized || null;
}
function normalizeStringArray(values) {
return uniqueStrings(values);
}
function toAbsoluteUrl(baseUrl, path) {
if (path.startsWith("http://") || path.startsWith("https://")) {
return path;
}
return `${baseUrl}${path}`;
}
function normalizeChatIdValue(value) {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
const normalizedString = asTrimmedString(value);
if (!normalizedString) return null;
if (/^\d+$/.test(normalizedString)) {
const numeric = Number(normalizedString);
if (Number.isFinite(numeric)) return numeric;
}
return normalizedString;
}
function normalizeBoolean(value, fallback = false) {
return typeof value === "boolean" ? value : fallback;
}
function resolveLatestMessageCandidate(messageResponse) {
if (Array.isArray(messageResponse)) {
for (let index = messageResponse.length - 1; index >= 0; index -= 1) {
const candidate = messageResponse[index];
if (isPlainObject(candidate)) return candidate;
}
return null;
}
return isPlainObject(messageResponse) ? messageResponse : null;
}
function mergeChatMessages(baseMessages, latestMessage) {
const merged = Array.isArray(baseMessages) ? [...baseMessages] : [];
if (!latestMessage) return merged;
const knownIds = new Set(merged.map((message) => message.id));
if (!knownIds.has(latestMessage.id)) {
merged.push(latestMessage);
}
return merged;
}
function resolveChatSource(chatPayload = null, chat = null) {
const directChat = asPlainObjectOrEmpty(chat);
if (Object.keys(directChat).length > 0) return directChat;
const nestedChat = asPlainObjectOrEmpty(chatPayload?.chat);
if (Object.keys(nestedChat).length > 0) return nestedChat;
return asPlainObjectOrEmpty(chatPayload);
}
function buildGenerateAlphaChat(chatPayload = null, chat = null) {
const source = resolveChatSource(chatPayload, chat);
const normalizedChat = {
character_id: asString(source.character_id ?? source.characterId).trim(),
id: normalizeChatIdValue(source.id ?? source.chat_id ?? source.chatId),
summary: asString(source.summary),
user_id: asString(source.user_id ?? source.userId).trim()
};
if (!normalizedChat.character_id) {
throw new Error("GenerateAlpha payload requires a character_id.");
}
if (normalizedChat.id === null) {
throw new Error("GenerateAlpha payload requires a chat ID.");
}
return normalizedChat;
}
function buildGenerateAlphaChatMessage(messageCandidate) {
const source = asPlainObjectOrEmpty(messageCandidate);
const message = typeof source.message === "string" ? source.message : "";
if (!message.trim()) return null;
const chatId = normalizeChatIdValue(source.chat_id ?? source.chatId);
const messageId = normalizeChatIdValue(source.id);
if (chatId === null || messageId === null) return null;
return {
chat_id: chatId,
created_at: asString(source.created_at ?? source.createdAt),
id: messageId,
is_bot: normalizeBoolean(source.is_bot ?? source.isBot, false),
is_main: normalizeBoolean(source.is_main ?? source.isMain, true),
message
};
}
function resolveChatMessagesSource({
chatMessages = null,
chatPayload = null
} = {}) {
if (Array.isArray(chatMessages)) return chatMessages;
if (Array.isArray(chatPayload?.chatMessages)) return chatPayload.chatMessages;
return [];
}
const Placeholder = (() => {
const uuidSegment = typeof globalThis.crypto?.randomUUID === "function" ? globalThis.crypto.randomUUID().split("-")[0] : Math.random().toString(36).slice(2, 10);
const prefix = `__VM_${uuidSegment}_`;
let c = 0;
return {
generate: () => prefix + c++,
prefix
};
})();
function buildGenerateAlphaProfile(profile = null) {
if (!isPlainObject(profile)) return null;
const source = asPlainObjectOrEmpty(profile);
const normalizedProfile = {};
const id = normalizeOptionalString(source.id);
const userAppearance = normalizeOptionalString(
source.user_appearance ?? source.userAppearance
);
const userName = normalizeOptionalString(source.user_name ?? source.userName);
if (id) normalizedProfile.id = id;
normalizedProfile.name = Placeholder.generate();
if (userAppearance) normalizedProfile.user_appearance = userAppearance;
if (userName) normalizedProfile.user_name = userName;
return Object.keys(normalizedProfile).length > 0 ? normalizedProfile : null;
}
function hasOwn(source, propertyName) {
return Object.prototype.hasOwnProperty.call(source, propertyName);
}
function readOwnMappedValue(source, propertyNames) {
for (const propertyName of propertyNames) {
if (!hasOwn(source, propertyName)) continue;
return { found: true, value: source[propertyName] };
}
return { found: false, value: void 0 };
}
const GENERATE_ALPHA_USER_CONFIG_FIELD_MAPPINGS = Object.freeze([
["allow_mobile_nsfw", ["allow_mobile_nsfw", "allowMobileNsfw"]],
["api", ["api"]],
["bad_words", ["bad_words", "badWords"]],
["generation_settings", ["generation_settings", "generationSettings"]],
["llm_prompt", ["llm_prompt", "llmPrompt"]],
[
"open_ai_jailbreak_prompt",
["open_ai_jailbreak_prompt", "openAiJailbreakPrompt"]
],
["open_ai_mode", ["open_ai_mode", "openAiMode"]],
[
"open_ai_reverse_proxy",
["open_ai_reverse_proxy", "openAiReverseProxy"]
],
["openAiModel", ["openAiModel"]],
["reverseProxyKey", ["reverseProxyKey"]]
]);
const GENERATE_ALPHA_PROXY_USER_CONFIG_KEYS = Object.freeze([
"api",
"open_ai_mode",
"open_ai_reverse_proxy",
"openAiModel",
"reverseProxyKey",
"open_ai_jailbreak_prompt"
]);
const GENERATE_ALPHA_PROXY_CONFIG_FIELD_MAPPINGS = Object.freeze([
["openAiModel", ["openAiModel", "modelName", "model"]],
[
"open_ai_reverse_proxy",
["open_ai_reverse_proxy", "openAiReverseProxy", "proxyUrl", "apiUrl"]
],
["reverseProxyKey", ["reverseProxyKey", "apiKey"]],
[
"open_ai_jailbreak_prompt",
[
"open_ai_jailbreak_prompt",
"openAiJailbreakPrompt",
"customPrompt",
"jailbreakPrompt"
]
]
]);
function applyHardcodedProxyConfig(target) {
const base = isPlainObject(target) ? target : {};
return {
...base,
api: "openai",
open_ai_jailbreak_prompt: "[System note: TEST]",
open_ai_mode: "proxy",
open_ai_reverse_proxy: "https://proxy.invalid",
openAiModel: "TEST",
reverseProxyKey: "TEST"
};
}
function buildGenerateAlphaUserConfig(userConfig = null) {
if (!isPlainObject(userConfig)) {
return applyHardcodedProxyConfig({});
}
const source = asPlainObjectOrEmpty(userConfig);
const normalizedUserConfig = {};
for (const [targetKey, sourceKeys] of GENERATE_ALPHA_USER_CONFIG_FIELD_MAPPINGS) {
const { found, value } = readOwnMappedValue(source, sourceKeys);
if (!found) continue;
normalizedUserConfig[targetKey] = cloneShallow(value);
}
const hardenedUserConfig = applyHardcodedProxyConfig(normalizedUserConfig);
return Object.keys(hardenedUserConfig).length > 0 ? hardenedUserConfig : null;
}
function mergeGenerateAlphaUserConfigWithProxyConfig(userConfig = null, proxyConfig = null) {
const mergedUserConfig = isPlainObject(userConfig) ? { ...userConfig } : {};
if (!isPlainObject(proxyConfig)) {
return applyHardcodedProxyConfig(mergedUserConfig);
}
for (const propertyName of GENERATE_ALPHA_PROXY_USER_CONFIG_KEYS) {
delete mergedUserConfig[propertyName];
}
mergedUserConfig.api = "openai";
mergedUserConfig.open_ai_mode = "proxy";
for (const [targetKey, sourceKeys] of GENERATE_ALPHA_PROXY_CONFIG_FIELD_MAPPINGS) {
const { found, value } = readOwnMappedValue(proxyConfig, sourceKeys);
if (!found) continue;
mergedUserConfig[targetKey] = cloneShallow(value);
}
return applyHardcodedProxyConfig(mergedUserConfig);
}
function buildForcedPromptGenerationCacheRefetch(value) {
if (!isPlainObject(value)) return null;
return cloneShallow(value);
}
function buildGenerateAlphaPayload({
chatPayload = null,
chat = null,
chatMessages = null,
messageResponse = null,
profile = null,
suggestionMode = void 0,
suggestionPerspective = void 0,
userConfig = null,
clientPlatform = "web",
generateMode = "NEW",
generateType = "CHAT",
forcedPromptGenerationCacheRefetch = null
} = {}) {
const normalizedChat = buildGenerateAlphaChat(chatPayload, chat);
const baseMessages = resolveChatMessagesSource({
chatMessages,
chatPayload
}).map(buildGenerateAlphaChatMessage).filter(Boolean);
const latestMessageCandidate = resolveLatestMessageCandidate(messageResponse);
const latestMessage = buildGenerateAlphaChatMessage(latestMessageCandidate);
if (!latestMessage) {
throw new Error(
"GenerateAlpha payload requires latest user message context."
);
}
let normalizedMessages = mergeChatMessages(baseMessages, latestMessage);
if (normalizedMessages.length === 0) {
throw new Error("GenerateAlpha payload requires at least one chat message.");
}
const normalizedProfile = buildGenerateAlphaProfile(profile);
if (!normalizedProfile) {
throw new Error("GenerateAlpha payload requires profile context.");
}
const normalizedUserConfig = buildGenerateAlphaUserConfig(userConfig);
if (!normalizedUserConfig) {
throw new Error("GenerateAlpha payload requires userConfig context.");
}
const normalizedForcedPromptGenerationCacheRefetch = buildForcedPromptGenerationCacheRefetch(forcedPromptGenerationCacheRefetch);
if (!normalizedForcedPromptGenerationCacheRefetch) {
throw new Error(
"GenerateAlpha payload requires forcedPromptGenerationCacheRefetch context."
);
}
const payload = {
chat: normalizedChat,
chatMessages: normalizedMessages,
clientPlatform: asString(clientPlatform).trim() || "web",
forcedPromptGenerationCacheRefetch: normalizedForcedPromptGenerationCacheRefetch,
generateMode: asString(generateMode).trim() || "NEW",
generateType: asString(generateType).trim() || "CHAT",
profile: normalizedProfile,
userConfig: normalizedUserConfig
};
if (suggestionMode !== void 0) {
payload.suggestionMode = suggestionMode;
}
if (suggestionPerspective !== void 0) {
payload.suggestionPerspective = suggestionPerspective;
}
return payload;
}
function buildSendMessageRequestBody(chatId, payload = {}) {
const source = asPlainObjectOrEmpty(payload);
const resolvedChatId = normalizeChatIdValue(
source.chat_id ?? source.chatId ?? chatId
);
if (resolvedChatId === null) {
throw new Error("Send-message payload requires a valid chat ID.");
}
const message = typeof source.message === "string" ? source.message : "";
if (!message.trim()) {
throw new Error("Send-message payload requires a non-empty message.");
}
const characterId = asString(
source.character_id ?? source.characterId
).trim();
const body = {
is_bot: normalizeBoolean(source.is_bot ?? source.isBot, false),
is_main: normalizeBoolean(source.is_main ?? source.isMain, true),
message,
chat_id: resolvedChatId
};
const scriptIds = normalizeStringArray(source.script_ids ?? source.scriptIds);
if (scriptIds.length > 0) {
body.script_ids = scriptIds;
}
if (characterId) {
body.character_id = characterId;
}
const excludedKeys = /* @__PURE__ */ new Set([
"characterId",
"character_id",
"chatId",
"chat_id",
"isBot",
"is_bot",
"isMain",
"is_main",
"message",
"scriptIds",
"script_ids"
]);
for (const [key, value] of Object.entries(source)) {
if (excludedKeys.has(key) || value === void 0) continue;
body[key] = value;
}
return body;
}
const SUPABASE_AUTH_STORAGE_KEY_PATTERN = /(?:^|:)(sb-[a-z0-9-]+-auth-token)(?:\.(\d+))?$/i;
const SUPABASE_LEGACY_AUTH_STORAGE_KEY = "supabase.auth.token";
const BASE64_VALUE_PREFIX = "base64-";
function normalizeAccessToken(value) {
const normalized = asTrimmedString(value);
if (!normalized) return null;
return /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(normalized) ? normalized : null;
}
function readDocumentCookie(documentRef = globalThis.document) {
try {
return typeof documentRef?.cookie === "string" ? documentRef.cookie : "";
} catch {
return "";
}
}
function parseCookieEntries(cookieString) {
if (typeof cookieString !== "string" || !cookieString.trim()) {
return [];
}
return cookieString.split(/;\s*/).map((cookiePart) => {
const separatorIndex = cookiePart.indexOf("=");
if (separatorIndex === -1) return null;
const key = cookiePart.slice(0, separatorIndex).trim();
const value = cookiePart.slice(separatorIndex + 1).trim();
return key ? [key, value] : null;
}).filter(Boolean);
}
function parseSupabaseAuthKey(key) {
if (typeof key !== "string") return null;
if (key === SUPABASE_LEGACY_AUTH_STORAGE_KEY) {
return { baseKey: key, chunkIndex: null };
}
const match = key.match(SUPABASE_AUTH_STORAGE_KEY_PATTERN);
if (!match?.[1]) return null;
return {
baseKey: match[1],
chunkIndex: match[2] ? Number(match[2]) : null
};
}
function decodeBase64Value(value) {
const normalized = asTrimmedString(value);
if (!normalized) return null;
const encoded = normalized.startsWith(BASE64_VALUE_PREFIX) ? normalized.slice(BASE64_VALUE_PREFIX.length) : normalized;
if (!encoded) return null;
try {
if (typeof globalThis.atob === "function") {
const binary = globalThis.atob(encoded);
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
if (typeof TextDecoder !== "undefined") {
return new TextDecoder().decode(bytes);
}
return binary;
}
} catch {
}
try {
if (typeof Buffer !== "undefined") {
return Buffer.from(encoded, "base64").toString("utf8");
}
} catch {
}
return null;
}
function extractAccessTokenFromSessionCandidate(candidate, depth = 0) {
if (depth > 6 || candidate == null) return null;
const directToken = normalizeAccessToken(candidate);
if (directToken) return directToken;
if (typeof candidate === "string") {
const trimmed = candidate.trim();
if (!trimmed) return null;
try {
return extractAccessTokenFromSessionCandidate(JSON.parse(trimmed), depth + 1);
} catch {
if (trimmed.startsWith(BASE64_VALUE_PREFIX)) {
const decoded = decodeBase64Value(trimmed);
if (decoded) {
return extractAccessTokenFromSessionCandidate(decoded, depth + 1);
}
}
return null;
}
}
if (Array.isArray(candidate)) {
for (const item of candidate) {
const nestedToken = extractAccessTokenFromSessionCandidate(item, depth + 1);
if (nestedToken) return nestedToken;
}
return null;
}
if (typeof candidate === "object") {
const directCandidates = [
candidate.access_token,
candidate.currentSession,
candidate.session,
candidate.data,
candidate.user
];
for (const value of directCandidates) {
const nestedToken = extractAccessTokenFromSessionCandidate(value, depth + 1);
if (nestedToken) return nestedToken;
}
for (const value of Object.values(candidate)) {
const nestedToken = extractAccessTokenFromSessionCandidate(value, depth + 1);
if (nestedToken) return nestedToken;
}
}
return null;
}
function collectSupabaseSessionCandidates(entries = []) {
const groupedCandidates = /* @__PURE__ */ new Map();
const directCandidates = [];
for (const [key, value] of entries) {
const parsedKey = parseSupabaseAuthKey(key);
if (!parsedKey) continue;
if (parsedKey.baseKey === SUPABASE_LEGACY_AUTH_STORAGE_KEY) {
directCandidates.push(value);
continue;
}
const group = groupedCandidates.get(parsedKey.baseKey) ?? { directValues: [], chunks: /* @__PURE__ */ new Map() };
if (parsedKey.chunkIndex === null) {
group.directValues.push(value);
} else {
group.chunks.set(parsedKey.chunkIndex, value);
}
groupedCandidates.set(parsedKey.baseKey, group);
}
const candidates = [...directCandidates];
for (const group of groupedCandidates.values()) {
candidates.push(...group.directValues);
if (group.chunks.size > 0) {
candidates.push(
Array.from(group.chunks.entries()).sort((left, right) => left[0] - right[0]).map(([, value]) => value).join("")
);
}
}
return candidates.filter((candidate) => candidate != null && candidate !== "");
}
function readAccessTokenFromEntries(entries = []) {
for (const sessionCandidate of collectSupabaseSessionCandidates(entries)) {
const token = extractAccessTokenFromSessionCandidate(sessionCandidate);
if (token) return token;
}
return null;
}
function readAccessTokenFromDocumentCookies(documentRef = globalThis.document) {
return readAccessTokenFromEntries(
parseCookieEntries(readDocumentCookie(documentRef))
);
}
function isSupabaseLikeClient$1(value) {
return isObjectLike(value) && typeof value.auth?.getSession === "function";
}
async function readPageRuntimeAccessToken(runtimeBridge) {
if (!isSupabaseLikeClient$1(runtimeBridge?.supabase)) return null;
try {
const sessionResult = await runtimeBridge.supabase.auth.getSession();
return extractAccessTokenFromSessionCandidate(
sessionResult?.data?.session ?? sessionResult?.data ?? sessionResult?.session ?? sessionResult
);
} catch {
return null;
}
}
function createAccessTokenResolver({
documentRef,
ensurePageRuntimeBridge
}) {
return async function resolveAccessToken({
currentAuthorization = null,
allowRuntimeFallback = true
} = {}) {
const heuristicAccessToken = readAccessTokenFromDocumentCookies(documentRef);
if (heuristicAccessToken && (!currentAuthorization || `Bearer ${heuristicAccessToken}` !== currentAuthorization || !allowRuntimeFallback)) {
return heuristicAccessToken;
}
if (!allowRuntimeFallback) {
return heuristicAccessToken;
}
const runtimeAccessToken = await readPageRuntimeAccessToken(
await ensurePageRuntimeBridge()
);
return runtimeAccessToken ?? heuristicAccessToken;
};
}
const DEFAULT_TIMEOUT_MS = 15e3;
const DEFAULT_ACCEPT_HEADER = "application/json, text/plain, */*";
function parseMaybeJson(text) {
if (typeof text !== "string" || !text.trim()) return null;
try {
return JSON.parse(text);
} catch {
return { rawText: text };
}
}
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function createFetchHeadersInit(headers) {
const headerObject = Object.fromEntries(headers.entries());
Object.defineProperties(headerObject, {
get: {
enumerable: false,
value(name) {
const normalizedName = asTrimmedString(name).toLowerCase();
return normalizedName ? headerObject[normalizedName] ?? null : null;
}
},
has: {
enumerable: false,
value(name) {
const normalizedName = asTrimmedString(name).toLowerCase();
return normalizedName ? Object.prototype.hasOwnProperty.call(headerObject, normalizedName) : false;
}
}
});
return headerObject;
}
function attachAbortSignal(externalSignal, controller) {
if (!externalSignal) return;
if (externalSignal.aborted) {
controller.abort(externalSignal.reason);
return;
}
externalSignal.addEventListener(
"abort",
() => {
controller.abort(externalSignal.reason);
},
{ once: true }
);
}
function toJsonBody(headers, body) {
if (body === null || body === void 0) return void 0;
const hasFormData = typeof FormData !== "undefined";
const hasBlob = typeof Blob !== "undefined";
const hasArrayBuffer = typeof ArrayBuffer !== "undefined";
if (typeof body === "string" || body instanceof URLSearchParams || hasFormData && body instanceof FormData || hasBlob && body instanceof Blob || hasArrayBuffer && body instanceof ArrayBuffer) {
return body;
}
if (!headers.has("content-type")) {
headers.set("content-type", "application/json");
}
return JSON.stringify(body);
}
function createJanitorRequestTransport({
baseUrl,
fetchImpl,
defaultHeaders,
defaultRetries,
resolveAccessToken
}) {
return async function request(path, {
method = "GET",
headers = {},
body = void 0,
timeoutMs = DEFAULT_TIMEOUT_MS,
retries = defaultRetries,
signal = null
} = {}) {
const url = toAbsoluteUrl(baseUrl, path);
let attempt = 0;
let refreshedAuthorizationAfter401 = false;
let accessTokenOverride = null;
while (attempt <= retries) {
const controller = new AbortController();
attachAbortSignal(signal, controller);
const timeoutId = setTimeout(() => {
controller.abort(new Error("Request timed out."));
}, timeoutMs);
try {
const mergedHeaders = new Headers(defaultHeaders);
for (const [key, value] of Object.entries(headers)) {
if (value !== void 0 && value !== null) {
mergedHeaders.set(key, value);
}
}
if (!mergedHeaders.has("accept")) {
mergedHeaders.set("accept", DEFAULT_ACCEPT_HEADER);
}
const hadConfiguredAuthorization = mergedHeaders.has("authorization");
if (!hadConfiguredAuthorization) {
const accessToken = accessTokenOverride ?? await resolveAccessToken({
allowRuntimeFallback: true
});
if (accessToken) {
mergedHeaders.set("authorization", `Bearer ${accessToken}`);
}
}
const requestAuthorization = mergedHeaders.get("authorization");
const finalBody = toJsonBody(mergedHeaders, body);
const response = await fetchImpl(url, {
method,
headers: createFetchHeadersInit(mergedHeaders),
body: finalBody,
credentials: "include",
signal: controller.signal
});
const rawText = await response.text();
const data = parseMaybeJson(rawText);
if (!response.ok) {
const error = new Error(
`Janitor request failed: ${method} ${path} -> HTTP ${response.status}`
);
error.status = response.status;
error.data = data;
if (response.status === 401 && !hadConfiguredAuthorization && !refreshedAuthorizationAfter401) {
refreshedAuthorizationAfter401 = true;
const refreshedAccessToken = await resolveAccessToken({
currentAuthorization: requestAuthorization,
allowRuntimeFallback: true
});
if (refreshedAccessToken && `Bearer ${refreshedAccessToken}` !== requestAuthorization) {
accessTokenOverride = refreshedAccessToken;
continue;
}
}
throw error;
}
return data;
} catch (error) {
if (typeof error?.status === "number") {
throw error;
}
const isLastAttempt = attempt === retries;
if (isLastAttempt) throw error;
await wait(150 * (attempt + 1));
} finally {
clearTimeout(timeoutId);
}
attempt += 1;
}
throw new Error(`Unexpected retry loop exit for ${method} ${path}`);
};
}
const GENERATE_ALPHA_USER_CONFIG_KEYS = Object.freeze([
"allow_mobile_nsfw",
"api",
"bad_words",
"generation_settings",
"llm_prompt",
"openAiModel",
"open_ai_jailbreak_prompt",
"open_ai_mode",
"open_ai_reverse_proxy"
]);
const GENERATE_ALPHA_RUNTIME_BRIDGE_STORE_KEYS = Object.freeze([
"userStore",
"userPersonaStore",
"storageStore",
"chatStore"
]);
function readDocumentHtml(documentRef = globalThis.document) {
try {
if (typeof documentRef?.documentElement?.innerHTML === "string") {
return documentRef.documentElement.innerHTML;
}
if (typeof documentRef?.documentElement?.outerHTML === "string") {
return documentRef.documentElement.outerHTML;
}
if (typeof documentRef?.documentElement?.textContent === "string") {
return documentRef.documentElement.textContent;
}
} catch {
}
return "";
}
function extractJsonObjectLiteral(source, startIndex) {
let depth = 0;
let inString = false;
let escaped = false;
for (let i2 = startIndex; i2 < source.length; i2 += 1) {
const char = source[i2];
if (inString) {
if (escaped) {
escaped = false;
continue;
}
if (char === "\\") {
escaped = true;
continue;
}
if (char === '"') {
inString = false;
}
continue;
}
if (char === '"') {
inString = true;
continue;
}
if (char === "{") {
if (depth === 0) {
startIndex = i2;
}
depth += 1;
continue;
}
if (char === "}") {
depth -= 1;
if (depth === 0) {
return source.slice(startIndex, i2 + 1);
}
}
}
return null;
}
function extractQuotedStringLiteral(source, startIndex) {
const quote = source[startIndex];
if (quote !== '"' && quote !== "'") return null;
let escaped = false;
for (let i2 = startIndex + 1; i2 < source.length; i2 += 1) {
const char = source[i2];
if (escaped) {
escaped = false;
continue;
}
if (char === "\\") {
escaped = true;
continue;
}
if (char === quote) {
return source.slice(startIndex, i2 + 1);
}
}
return null;
}
function parseStoreStateFromHtml(html) {
const source = typeof html === "string" ? html : "";
if (!source) return null;
const assignPattern = /window\._storeState_\s*=\s*(?=\{|JSON\.parse\s*\()/;
const assignMatch = assignPattern.exec(source);
if (!assignMatch) return null;
const cursor = assignMatch.index + assignMatch[0].length;
const assignmentSlice = source.slice(cursor);
if (assignmentSlice.startsWith("JSON.parse")) {
const parenIndex = assignmentSlice.indexOf("(");
if (parenIndex === -1) return null;
let stringCursor = parenIndex + 1;
while (stringCursor < assignmentSlice.length && /\s/.test(assignmentSlice[stringCursor])) {
stringCursor += 1;
}
const rawLiteral = extractQuotedStringLiteral(assignmentSlice, stringCursor);
if (!rawLiteral) return null;
const decodedLiteral = JSON.parse(rawLiteral);
return JSON.parse(decodedLiteral);
}
const objectStart = assignmentSlice.indexOf("{");
if (objectStart === -1) return null;
const rawObject = extractJsonObjectLiteral(assignmentSlice, objectStart);
if (!rawObject) return null;
return JSON.parse(rawObject);
}
function getDocumentWindow(documentRef = globalThis.document) {
try {
return documentRef?.defaultView ?? null;
} catch {
return null;
}
}
function readStoreStateCandidate(store) {
try {
if (typeof store?.getState === "function") {
const state2 = store.getState();
if (isObjectLike(state2)) return state2;
}
} catch {
}
return isObjectLike(store) ? store : null;
}
function readBootstrapStoreState(documentRef = globalThis.document) {
try {
const windowRef = getDocumentWindow(documentRef) ?? globalThis;
const liveState = windowRef?._storeState_;
const resolvedLiveState = readStoreStateCandidate(liveState);
if (resolvedLiveState) return resolvedLiveState;
} catch {
}
const html = readDocumentHtml(documentRef);
const storeState = parseStoreStateFromHtml(html);
if (!storeState) {
throw new Error("Unable to locate window._storeState_ in document HTML.");
}
return storeState;
}
function cloneRuntimeValue(value) {
return cloneShallow(value);
}
function buildRuntimeProfile(userStoreState) {
const source = asPlainObject(userStoreState);
if (!source) return null;
const profile = {};
const id = asTrimmedString(source.id);
const name = asTrimmedString(source.name);
const userAppearance = asTrimmedString(source.profile);
const userName = asTrimmedString(source.user_name ?? source.userName);
if (id) profile.id = id;
if (name) profile.name = name;
if (userAppearance) profile.user_appearance = userAppearance;
if (userName) profile.user_name = userName;
return Object.keys(profile).length > 0 ? profile : null;
}
function buildRuntimeUserConfig(userStoreState) {
const mergedSource = asPlainObject(userStoreState?.config ?? userStoreState);
if (!mergedSource) return null;
const userConfig = {};
for (const propertyName of GENERATE_ALPHA_USER_CONFIG_KEYS) {
if (!Object.prototype.hasOwnProperty.call(mergedSource, propertyName)) continue;
userConfig[propertyName] = cloneRuntimeValue(mergedSource[propertyName]);
}
const selectedProxyConfigId = asTrimmedString(mergedSource.selectedProxyConfigId);
const proxyConfigurations = Array.isArray(mergedSource.proxyConfigurations) ? mergedSource.proxyConfigurations : [];
if (selectedProxyConfigId && proxyConfigurations.length > 0) {
const matchedConfig = proxyConfigurations.find(
(config) => asTrimmedString(config?.id) === selectedProxyConfigId
);
if (matchedConfig && Object.prototype.hasOwnProperty.call(matchedConfig, "apiKey")) {
userConfig.reverseProxyKey = cloneRuntimeValue(matchedConfig.apiKey);
}
}
return Object.keys(userConfig).length > 0 ? userConfig : null;
}
function buildRuntimeForcedPromptGenerationCacheRefetch(userStoreState) {
const source = asPlainObject(
userStoreState?.forcedPromptGenerationCacheRefetch ?? userStoreState
);
if (!source) return null;
return { ...source };
}
function readGenerateAlphaRuntimeContext({ documentRef } = {}) {
const storeState = readBootstrapStoreState(documentRef);
const userState = asPlainObject(storeState?.user);
if (!userState) {
throw new Error("GenerateAlpha bootstrap is missing user state.");
}
const profile = buildRuntimeProfile(userState.profile);
if (!profile) {
throw new Error("GenerateAlpha requires user.profile bootstrap data.");
}
const userConfig = buildRuntimeUserConfig(userState.config);
if (!userConfig) {
throw new Error("GenerateAlpha requires user.config bootstrap data.");
}
const forcedPromptGenerationCacheRefetch = buildRuntimeForcedPromptGenerationCacheRefetch(
userState.forcedPromptGenerationCacheRefetch
);
if (!forcedPromptGenerationCacheRefetch) {
throw new Error(
"GenerateAlpha requires forcedPromptGenerationCacheRefetch bootstrap data."
);
}
const context = {
profile,
userConfig,
forcedPromptGenerationCacheRefetch
};
return context;
}
function pushUniqueRef(refs, candidate) {
if (!candidate || refs.includes(candidate)) return;
refs.push(candidate);
}
function getDocumentScripts(documentRef) {
if (!documentRef) return [];
if (documentRef.scripts && typeof documentRef.scripts.length === "number") {
return Array.from(documentRef.scripts);
}
if (typeof documentRef.querySelectorAll === "function") {
return Array.from(documentRef.querySelectorAll("script"));
}
return [];
}
function getPerformanceResourceUrls(documentRef) {
const documentWindow = getDocumentWindow(documentRef);
const performanceRef = documentWindow?.performance ?? globalThis.performance;
if (!performanceRef || typeof performanceRef.getEntriesByType !== "function") {
return [];
}
try {
return performanceRef.getEntriesByType("resource").filter((entry) => {
const name = asTrimmedString(entry?.name);
const initiatorType = asTrimmedString(entry?.initiatorType).toLowerCase();
return Boolean(name) && (initiatorType === "script" || initiatorType === "link");
}).map((entry) => entry.name);
} catch {
return [];
}
}
function collectCandidateScriptUrls(documentRef, baseUrl) {
let baseHostname = null;
try {
baseHostname = new URL(baseUrl).hostname;
} catch {
return [];
}
const uniqueUrls = /* @__PURE__ */ new Set();
const rawUrls = [];
for (const script of getDocumentScripts(documentRef)) {
const rawUrl = typeof script?.src === "string" && script.src ? script.src : script?.getAttribute?.("src");
if (rawUrl) rawUrls.push(rawUrl);
}
if (typeof documentRef?.querySelectorAll === "function") {
for (const assetEl of documentRef.querySelectorAll(
'link[rel="modulepreload"][href], link[rel="preload"][as="script"][href], link[as="script"][href]'
)) {
const rawUrl = typeof assetEl?.href === "string" && assetEl.href ? assetEl.href : assetEl?.getAttribute?.("href");
if (rawUrl) rawUrls.push(rawUrl);
}
}
rawUrls.push(...getPerformanceResourceUrls(documentRef));
for (const rawUrl of rawUrls) {
if (!rawUrl) continue;
try {
const resolvedUrl = new URL(rawUrl, baseUrl).toString();
const { hostname } = new URL(resolvedUrl);
if (hostname !== baseHostname && !hostname.endsWith(`.${baseHostname}`) && !baseHostname.endsWith(`.${hostname}`)) {
continue;
}
uniqueUrls.add(resolvedUrl);
} catch {
}
}
return Array.from(uniqueUrls).sort((left, right) => {
const score = (url) => {
let value = 0;
if (url.includes("assets.")) value += 4;
if (url.includes("index-")) value += 3;
if (url.includes("/_next/static/chunks/")) value += 2;
if (url.includes("app-")) value += 1;
if (url.includes("main")) value += 1;
if (url.includes("webpack")) value -= 1;
return value;
};
return score(right) - score(left);
});
}
function isSupabaseLikeClient(value) {
return isObjectLike(value) && typeof value.auth?.getSession === "function";
}
function isAxiosLikeClient(value) {
return isObjectLike(value) && typeof value.request === "function" && isObjectLike(value.defaults);
}
function isStoreManagerInstance(value) {
if (!isObjectLike(value)) return false;
try {
const stores = typeof value.getStores === "function" ? value.getStores() : value.stores;
return stores instanceof Map;
} catch {
return false;
}
}
function getStoreManagerRoots(manager) {
const roots = [];
pushUniqueRef(roots, manager?.initState);
try {
const stores = typeof manager?.getStores === "function" ? manager.getStores() : manager?.stores;
pushUniqueRef(roots, stores);
} catch {
}
return roots;
}
function extractPageRuntimeStoreContainerCandidate(value) {
if (!isObjectLike(value)) return null;
const candidate = { root: value };
for (const propertyName of GENERATE_ALPHA_RUNTIME_BRIDGE_STORE_KEYS) {
const store = value[propertyName];
if (!readStoreStateCandidate(store)) continue;
candidate[propertyName] = store;
}
return Object.keys(candidate).length > 1 ? candidate : null;
}
function composePageRuntimeBridge(connectionCandidate, storeCandidate) {
if (!connectionCandidate) return null;
return {
...connectionCandidate,
storeRoot: storeCandidate?.root ?? connectionCandidate.root,
userStore: storeCandidate?.userStore ?? connectionCandidate.root?.userStore ?? null,
userPersonaStore: storeCandidate?.userPersonaStore ?? connectionCandidate.root?.userPersonaStore ?? null,
storageStore: storeCandidate?.storageStore ?? connectionCandidate.root?.storageStore ?? null,
chatStore: storeCandidate?.chatStore ?? connectionCandidate.root?.chatStore ?? null
};
}
function extractPageRuntimeBridgeCandidate(value) {
if (!isObjectLike(value)) return null;
const supabase = isSupabaseLikeClient(value.supabase) ? value.supabase : isSupabaseLikeClient(value) ? value : null;
const endpoints = isObjectLike(value.endpoints) ? value.endpoints : null;
const apiClient = isAxiosLikeClient(value.apiClient) ? value.apiClient : isAxiosLikeClient(endpoints?.apiClient) ? endpoints.apiClient : isAxiosLikeClient(value) ? value : null;
if (endpoints && supabase) {
return { root: value, endpoints, supabase, apiClient };
}
if (apiClient && supabase) {
return { root: value, endpoints: value, supabase, apiClient };
}
return null;
}
function enqueueRuntimeSearchValues(queue, value, depth) {
if (!value) return;
if (value instanceof Map) {
for (const item of value.values()) {
queue.push([item, depth + 1]);
}
return;
}
if (Array.isArray(value)) {
for (const item of value) {
queue.push([item, depth + 1]);
}
return;
}
if (!isObjectLike(value)) return;
try {
if (typeof value.getState === "function") {
const state2 = value.getState();
if (isObjectLike(state2)) {
queue.push([state2, depth + 1]);
}
}
} catch {
}
for (const child of Object.values(value)) {
if (isObjectLike(child)) {
queue.push([child, depth + 1]);
}
}
}
function findPageRuntimeBridgeCandidate(roots, maxDepth = 4) {
const queue = roots.map((root) => [root, 0]);
const seen = /* @__PURE__ */ new Set();
let connectionCandidate = null;
let storeCandidate = null;
while (queue.length > 0) {
const [value, depth] = queue.shift();
if (!isObjectLike(value) || seen.has(value)) continue;
seen.add(value);
if (!connectionCandidate) {
connectionCandidate = extractPageRuntimeBridgeCandidate(value);
}
if (!storeCandidate) {
storeCandidate = extractPageRuntimeStoreContainerCandidate(value);
}
if (connectionCandidate && storeCandidate) break;
if (depth >= maxDepth) continue;
enqueueRuntimeSearchValues(queue, value, depth);
}
return composePageRuntimeBridge(connectionCandidate, storeCandidate);
}
function collectRuntimeModuleUrls(documentRef, baseUrl, { includeFallbackCandidates = false } = {}) {
const uniqueUrls = /* @__PURE__ */ new Set();
const addUrl = (rawUrl) => {
if (!rawUrl) return;
try {
const resolvedUrl = new URL(rawUrl, baseUrl).toString();
if (/\.js(?:$|\?)/i.test(resolvedUrl)) {
uniqueUrls.add(resolvedUrl);
}
} catch {
}
};
for (const script of getDocumentScripts(documentRef)) {
const rawUrl = typeof script?.src === "string" && script.src ? script.src : script?.getAttribute?.("src");
const type = asTrimmedString(
script?.type ?? script?.getAttribute?.("type")
).toLowerCase();
if (!rawUrl) continue;
if (type === "module" || rawUrl.includes("index-")) {
addUrl(rawUrl);
}
}
if (includeFallbackCandidates) {
for (const candidate of collectCandidateScriptUrls(documentRef, baseUrl)) {
addUrl(candidate);
}
}
return Array.from(uniqueUrls).sort((left, right) => {
const score = (url) => {
let value = 0;
if (url.includes("index-")) value += 5;
if (url.includes("assets.")) value += 3;
if (url.includes("main")) value += 2;
if (url.includes("polyfills")) value -= 4;
if (url.includes("turnstile")) value -= 3;
return value;
};
return score(right) - score(left);
});
}
async function defaultPageRuntimeImporter(moduleUrl) {
return import(
/* @vite-ignore */
moduleUrl
);
}
function getStoreManagerFromModuleExport(exportedValue) {
if (typeof exportedValue !== "function") return null;
return isStoreManagerInstance(exportedValue.instance) ? exportedValue.instance : null;
}
async function discoverPageRuntimeBridge({
documentRef,
baseUrl,
importModule = defaultPageRuntimeImporter
}) {
if (!getDocumentWindow(documentRef) || typeof importModule !== "function") {
return null;
}
const attemptedModuleUrls = /* @__PURE__ */ new Set();
for (const moduleUrls of [
collectRuntimeModuleUrls(documentRef, baseUrl),
collectRuntimeModuleUrls(documentRef, baseUrl, {
includeFallbackCandidates: true
})
]) {
for (const moduleUrl of moduleUrls) {
if (attemptedModuleUrls.has(moduleUrl)) continue;
attemptedModuleUrls.add(moduleUrl);
try {
const moduleNamespace = await importModule(moduleUrl);
if (!isObjectLike(moduleNamespace)) continue;
for (const exportedValue of Object.values(moduleNamespace)) {
const manager = getStoreManagerFromModuleExport(exportedValue);
if (!manager) continue;
const bridge = findPageRuntimeBridgeCandidate(getStoreManagerRoots(manager));
if (bridge) {
return { moduleUrl, manager, ...bridge };
}
}
} catch {
}
}
}
return null;
}
function createPageRuntimeBridgeResolver({
documentRef,
baseUrl,
importModule
}) {
let cachedRuntimeBridge = null;
let runtimeBridgePromise = null;
return async function ensurePageRuntimeBridge() {
if (cachedRuntimeBridge) return cachedRuntimeBridge;
if (runtimeBridgePromise) return runtimeBridgePromise;
runtimeBridgePromise = discoverPageRuntimeBridge({
documentRef,
baseUrl,
importModule
});
try {
const runtimeBridge = await runtimeBridgePromise;
if (runtimeBridge) {
cachedRuntimeBridge = runtimeBridge;
}
return runtimeBridge;
} finally {
runtimeBridgePromise = null;
}
};
}
const UUID_LIKE_REGEX = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
function getDefaultFetch(documentRef = globalThis.document) {
const documentWindow = getDocumentWindow(documentRef);
try {
if (typeof documentWindow?.fetch === "function") {
return documentWindow.fetch.bind(documentWindow);
}
} catch {
}
try {
return typeof globalThis.fetch === "function" ? globalThis.fetch.bind(globalThis) : null;
} catch {
return null;
}
}
function resolveChatIdFromPayload(payload) {
const direct = normalizeChatIdValue(payload);
if (direct !== null) return direct;
if (!payload || typeof payload !== "object") return null;
const candidateValues = [
payload?.chat?.id,
payload?.id,
payload?.chat_id,
payload?.chatId,
payload?.data?.chat?.id,
payload?.data?.id,
payload?.data?.chat_id,
payload?.data?.chatId
];
for (const candidate of candidateValues) {
const normalized = normalizeChatIdValue(candidate);
if (normalized !== null) return normalized;
}
return null;
}
function resolveChatBootstrapResultOrThrow(payload, errorMessage) {
const chatId = resolveChatIdFromPayload(payload);
if (chatId === null) {
throw new Error(errorMessage);
}
return { chatId, payload };
}
function extractCharacterIdFromInput(value) {
const source = asTrimmedString(value);
if (!source) return null;
const directMatch = source.match(UUID_LIKE_REGEX);
if (directMatch && directMatch[0].length === source.length) {
return directMatch[0];
}
let searchTarget = source;
try {
const maybeUrl = new URL(source, "https://janitorai.com");
searchTarget = decodeURIComponent(maybeUrl.pathname);
} catch {
}
const routeMatch = searchTarget.match(
/\/characters\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:[_/?#-]|$)/i
);
if (routeMatch?.[1]) return routeMatch[1];
const fallbackMatch = searchTarget.match(UUID_LIKE_REGEX);
if (fallbackMatch?.[0]) return fallbackMatch[0];
return null;
}
function resolveCharacterIdOrThrow(value) {
const characterId = extractCharacterIdFromInput(value);
if (!characterId) {
throw new Error("Unable to resolve character ID from input.");
}
return characterId;
}
function createJanitorClient({
baseUrl = "https://janitorai.com",
fetchImpl = null,
defaultHeaders = {},
defaultRetries = 1,
documentRef = globalThis.document,
pageRuntimeImporter = null
} = {}) {
const resolvedFetchImpl = fetchImpl ?? getDefaultFetch(documentRef);
const resolvedPageRuntimeImporter = pageRuntimeImporter ?? defaultPageRuntimeImporter;
let serverTimeOffsetMs = 0;
if (typeof resolvedFetchImpl !== "function") {
throw new Error("createJanitorClient requires a valid fetch implementation.");
}
const wrappedFetch = async (...args) => {
const response = await resolvedFetchImpl(...args);
try {
const dateHeader = response?.headers?.get?.("date");
const serverTime = Date.parse(dateHeader);
if (Number.isFinite(serverTime)) {
serverTimeOffsetMs = serverTime - Date.now();
}
} catch {
}
return response;
};
const ensurePageRuntimeBridge = createPageRuntimeBridgeResolver({
documentRef,
baseUrl,
importModule: resolvedPageRuntimeImporter
});
const resolveAccessToken = createAccessTokenResolver({
documentRef,
ensurePageRuntimeBridge
});
const request = createJanitorRequestTransport({
baseUrl,
fetchImpl: wrappedFetch,
defaultHeaders,
defaultRetries,
resolveAccessToken
});
async function createChat(characterId) {
const resolvedCharacterId = resolveCharacterIdOrThrow(characterId);
const body = { character_id: resolvedCharacterId };
const payload = await request("/hampter/chats", {
method: "POST",
body
});
return resolveChatBootstrapResultOrThrow(
payload,
"Unable to resolve chat ID from create-chat response."
);
}
async function startTemporaryChat(characterId) {
return createChat(characterId);
}
async function sendMessage(chatId, messageOrPayload, options = {}) {
const payload = isObjectLike(messageOrPayload) && !Array.isArray(messageOrPayload) ? { ...messageOrPayload, ...options } : { ...options, message: messageOrPayload };
return request(`/hampter/chats/${encodeURIComponent(chatId)}/messages`, {
method: "POST",
body: buildSendMessageRequestBody(chatId, payload)
});
}
return {
request,
getServerNowMs() {
return Date.now() + serverTimeOffsetMs;
},
async getGenerateAlphaRuntimeContext() {
return readGenerateAlphaRuntimeContext({ documentRef });
},
extractCharacterIdFromInput,
async getCharacter(characterId) {
const resolvedCharacterId = resolveCharacterIdOrThrow(characterId);
return request(`/hampter/characters/${encodeURIComponent(resolvedCharacterId)}`, {
method: "GET"
});
},
async getChat(chatId) {
return request(`/hampter/chats/${encodeURIComponent(chatId)}`, {
method: "GET"
});
},
async deleteChat(chatId) {
return request(`/hampter/chats/${encodeURIComponent(chatId)}`, {
method: "DELETE"
});
},
createChat,
startTemporaryChat,
sendMessage,
async generateAlpha(payload) {
return request("/generateAlpha", {
method: "POST",
body: payload
});
}
};
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function normalizeTagIdentifier(value) {
return asString(value).toLowerCase().replace(/[^a-z0-9]+/g, "");
}
function extractTaggedBlocks(content) {
const source = asString(content);
const regex = /<([^>\n/][^>\n]*)>([\s\S]*?)<\/\1>/gi;
const blocks = [];
let match = regex.exec(source);
while (match) {
blocks.push({
tagName: asString(match[1]).trim(),
content: asString(match[2]).trim(),
start: match.index,
end: regex.lastIndex
});
match = regex.exec(source);
}
return blocks;
}
function removeTaggedBlocks(content, predicate) {
const source = asString(content);
const matchingBlocks = extractTaggedBlocks(source).filter(
(block) => predicate(block.tagName)
);
if (matchingBlocks.length === 0) return source;
let output = "";
let cursor = 0;
for (const block of matchingBlocks) {
output += source.slice(cursor, block.start);
cursor = block.end;
}
output += source.slice(cursor);
return output;
}
function findTagBlock(content, tagName) {
const expectedTagName = normalizeTagIdentifier(tagName);
return extractTaggedBlocks(content).find(
(block) => normalizeTagIdentifier(block.tagName) === expectedTagName
) ?? null;
}
function isPersonaTag(tagName) {
return normalizeTagIdentifier(tagName).includes("persona");
}
function isUserPersonaTag(tagName) {
const normalizedTagName = normalizeTagIdentifier(tagName);
return normalizedTagName === "userpersona" || normalizedTagName === "userspersona";
}
function stripLeadingSystemNote(content) {
const source = asString(content);
return source.replace(/^\s*\[System note:[^\]]*]\s*(?:\r?\n)*/i, "");
}
function stripUserPersonaBlocks(content) {
return removeTaggedBlocks(content, isUserPersonaTag);
}
function findFallbackDescriptionBoundary(content) {
const source = asString(content);
const boundaryIndexes = [
source.search(/<\s*scenario\b/i),
source.search(/<\s*example_dialogs\b/i)
].filter((index) => index >= 0);
if (boundaryIndexes.length === 0) return -1;
return Math.min(...boundaryIndexes);
}
function extractSystemContentFromMessages(messages = []) {
for (const message of messages) {
if (message?.role !== "system") continue;
const content = asString(message?.content);
if (content.trim()) return content;
}
return "";
}
function extractTagBlockContent(content, tagName) {
const block = findTagBlock(stripLeadingSystemNote(content), tagName);
return block?.content ?? "";
}
function extractPersonaBlock(content) {
const blocks = extractTaggedBlocks(stripLeadingSystemNote(content));
for (const block of blocks) {
if (!isPersonaTag(block.tagName)) continue;
if (isUserPersonaTag(block.tagName)) continue;
return block.content;
}
return "";
}
function extractFallbackDescription(content) {
const contentWithoutSystemNote = stripLeadingSystemNote(content);
const contentWithoutUserPersona = stripUserPersonaBlocks(contentWithoutSystemNote);
const boundaryIndex = findFallbackDescriptionBoundary(contentWithoutUserPersona);
if (boundaryIndex < 0) return contentWithoutUserPersona.trim();
return contentWithoutUserPersona.slice(0, boundaryIndex).trim();
}
function normalizePersonaMacro(text, personaLabel) {
const source = asString(text);
const anchor = asString(personaLabel).trim();
if (!source || !anchor) return source;
const escapedAnchor = escapeRegExp(anchor);
const possessiveRegex = new RegExp(`\\b${escapedAnchor}(?:'|’)s\\b`, "g");
const plainRegex = new RegExp(`\\b${escapedAnchor}\\b`, "g");
return source.replace(possessiveRegex, "{{user}}'s").replace(plainRegex, "{{user}}");
}
function normalizePersonaMacroInFields(fields, personaLabel) {
const normalized = {};
for (const [key, value] of Object.entries(fields)) {
if (typeof value === "string") {
normalized[key] = normalizePersonaMacro(value, personaLabel);
continue;
}
if (Array.isArray(value)) {
normalized[key] = value.map(
(item) => typeof item === "string" ? normalizePersonaMacro(item, personaLabel) : item
);
continue;
}
normalized[key] = value;
}
return normalized;
}
function parseGenerateAlphaFields(messages = []) {
const systemContent = stripLeadingSystemNote(extractSystemContentFromMessages(messages));
const description = extractPersonaBlock(systemContent) || extractFallbackDescription(systemContent);
const scenario = extractTagBlockContent(systemContent, "Scenario");
const mesExample = extractTagBlockContent(systemContent, "example_dialogs");
return {
personaAnchor: null,
systemContent,
description,
scenario,
mesExample
};
}
function resolveCharacterTitle(normalizedCharacter, { useChatName = false } = {}) {
const name = normalizedCharacter?.name ?? "";
const chatName = normalizedCharacter?.chatName ?? "";
return useChatName ? chatName || name : name || chatName;
}
const DEFAULT_FILENAME = "character-card";
const DEFAULT_CREATOR = "unknown";
const JANITORAI_BASE_URL = "https://janitorai.com";
const JANITORAI_AVATAR_BASE_URL = "https://ella.janitorai.com";
const DEFAULT_FUTURE_SKEW_MS = 12e4;
const DEFAULT_PAST_SKEW_MS = 12e4;
function buildCharacterUrl(characterId) {
const id = asTrimmedString(characterId);
return id ? `${JANITORAI_BASE_URL}/characters/${id}` : null;
}
function buildProfileUrl(profileId) {
const id = asTrimmedString(profileId);
return id ? `${JANITORAI_BASE_URL}/profiles/${id}` : null;
}
function buildAvatarUrl(filename, { width = null } = {}) {
const name = asTrimmedString(filename);
if (!name) return null;
const baseUrl = `${JANITORAI_AVATAR_BASE_URL}/bot-avatars/${name}`;
return width ? `${baseUrl}?width=${width}` : baseUrl;
}
function stripJanitorAttribution(text) {
const source = asString(text);
if (!source) return "";
return source.replace(/\n{0,3}\s*created by[^\n]*janitorai\.com\s*$/i, "").trim();
}
function buildAvatar1200Url(avatarFilename) {
return buildAvatarUrl(avatarFilename, { width: 1200 });
}
function findChatOpeningBotMessage(chatPayload) {
for (const message of asArrayStrict(chatPayload?.chatMessages)) {
if (message?.is_bot !== true) continue;
if (message?.is_main === false) continue;
const text = asString(message?.message).trim();
if (text) return text;
}
return null;
}
function collectFirstMessages(character, chatPayload) {
const candidates = [];
const normalizeCandidate = (value) => stripJanitorAttribution(value).replace(/\r\n?/g, "\n").trim();
const pushCandidate = (value) => {
if (typeof value !== "string") return;
candidates.push(normalizeCandidate(value));
};
pushCandidate(chatPayload?.character?.first_message);
pushCandidate(findChatOpeningBotMessage(chatPayload));
pushCandidate(character?.first_message);
asArrayStrict(chatPayload?.character?.first_messages).forEach(pushCandidate);
asArrayStrict(character?.first_messages).forEach(pushCandidate);
return uniqueStrings(candidates, { allowEmpty: true });
}
const buildFirstMessages = collectFirstMessages;
function normalizeTagNames(tags) {
return uniqueStrings(
asArrayStrict(tags).map((tag) => {
if (typeof tag === "string") return tag;
return asString(tag?.name);
})
);
}
function pickCardTitle(normalized, { useChatName = false } = {}) {
return resolveCharacterTitle(normalized, { useChatName }) || DEFAULT_FILENAME;
}
function normalizeCharacterFields({
characterPayload,
chatPayload = null,
generateAlphaFields = {}
}) {
const character = characterPayload ?? {};
const chat = chatPayload?.chat ?? {};
const personaAnchor = asString(generateAlphaFields?.personaAnchor).trim() || null;
const showDefinition = Boolean(character?.showdefinition);
const descriptionRaw = showDefinition ? asString(character?.personality) || asString(generateAlphaFields?.description) : asString(generateAlphaFields?.description) || asString(character?.personality);
const scenarioRaw = showDefinition ? asString(character?.scenario) || asString(generateAlphaFields?.scenario) : asString(generateAlphaFields?.scenario) || asString(character?.scenario);
const mesExampleRaw = showDefinition ? asString(character?.example_dialogs) || asString(generateAlphaFields?.mesExample) : asString(generateAlphaFields?.mesExample) || asString(character?.example_dialogs);
const firstMessages = buildFirstMessages(character, chatPayload);
const firstMesRaw = firstMessages[0] ?? "";
const alternateGreetingsRaw = firstMessages.slice(1);
const cleanedFields = {
description: stripJanitorAttribution(descriptionRaw),
scenario: stripJanitorAttribution(scenarioRaw),
firstMes: stripJanitorAttribution(firstMesRaw),
mesExample: stripJanitorAttribution(mesExampleRaw),
alternateGreetings: alternateGreetingsRaw.map(stripJanitorAttribution)
};
const normalizedFields = normalizePersonaMacroInFields(cleanedFields, personaAnchor);
const characterId = asString(character?.id) || asString(chat?.character_id) || null;
const creatorId = asString(character?.creator_id) || null;
return {
characterId,
characterUrl: buildCharacterUrl(characterId),
name: asString(character?.name) || "",
chatName: asString(character?.chat_name) || null,
creator: {
id: creatorId,
name: asString(character?.creator_name) || null,
verified: Boolean(character?.creator_verified),
profileUrl: buildProfileUrl(creatorId)
},
avatar: {
filename: asString(character?.avatar) || null,
url1200: buildAvatar1200Url(character?.avatar)
},
tags: normalizeTagNames(character?.tags),
customTags: normalizeTagNames(character?.custom_tags),
tokenCounts: character?.token_counts ?? null,
stats: character?.stats ?? null,
description: normalizedFields.description,
scenario: normalizedFields.scenario,
firstMes: normalizedFields.firstMes,
mesExample: normalizedFields.mesExample,
alternateGreetings: uniqueStrings(normalizedFields.alternateGreetings, { allowEmpty: true }),
creatorNotesHtml: asString(character?.description) || "",
source: {
chatId: chat?.id ?? null,
chatCreatedAt: chat?.created_at ?? null,
personaAnchor
},
raw: {
characterPayload: character,
chatPayload,
generateAlphaFields
}
};
}
async function fetchAvatarBytes(imageUrl, fetchImpl, onProgress) {
if (typeof fetchImpl !== "function") {
throw new Error("PNG export requires a valid fetch implementation.");
}
const response = await fetchImpl(imageUrl);
if (!response.ok) {
throw new Error(`Avatar request failed with status ${response.status}.`);
}
const total = parseInt(response.headers.get("Content-Length") ?? "0", 10);
if (typeof onProgress !== "function" || total <= 0 || !response.body) {
return new Uint8Array(await response.arrayBuffer());
}
const reader = response.body.getReader();
const chunks = [];
let received = 0;
for (; ; ) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
received += value.length;
onProgress(received / total);
}
const result = new Uint8Array(received);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.length;
}
return result;
}
async function webpToPngBytes(arrayBuffer) {
const bitmap = await createImageBitmap(
new Blob([arrayBuffer], { type: "image/webp" })
);
const canvas = document.createElement("canvas");
canvas.width = bitmap.width;
canvas.height = bitmap.height;
canvas.getContext("2d").drawImage(bitmap, 0, 0);
bitmap.close();
return new Promise((resolve, reject) => {
canvas.toBlob(async (blob) => {
if (!blob) {
reject(new Error("Failed to convert avatar to PNG."));
return;
}
try {
const buf = await blob.arrayBuffer();
resolve(new Uint8Array(buf));
} catch (error) {
reject(error);
}
}, "image/png");
});
}
function replaceTokens(text, replacements) {
if (!replacements) return asString(text);
let output = asString(text);
for (const [token, value] of Object.entries(replacements)) {
if (!value) continue;
output = output.split(token).join(value);
}
return output;
}
function normalizePronounValue$1(value) {
return asTrimmedString(value);
}
function buildPronounReplacementMap(pronounMacros) {
if (!pronounMacros || typeof pronounMacros !== "object") return null;
return {
"{{sub}}": normalizePronounValue$1(pronounMacros.sub),
"{{obj}}": normalizePronounValue$1(pronounMacros.obj),
"{{poss}}": normalizePronounValue$1(pronounMacros.poss),
"{{poss_p}}": normalizePronounValue$1(pronounMacros.poss_p),
"{{ref}}": normalizePronounValue$1(pronounMacros.ref)
};
}
function applyPronounReplacements(value, replacements) {
return replaceTokens(value, replacements);
}
function applyPronounReplacementsToArray(values, replacements) {
if (!Array.isArray(values)) return [];
return values.map(
(item) => typeof item === "string" ? replaceTokens(item, replacements) : item
);
}
function getDefaultExportFromCjs(x2) {
return x2 && x2.__esModule && Object.prototype.hasOwnProperty.call(x2, "default") ? x2["default"] : x2;
}
var crc32 = {};
var hasRequiredCrc32;
function requireCrc32() {
if (hasRequiredCrc32) return crc32;
hasRequiredCrc32 = 1;
(function(exports$1) {
(function(factory) {
if (typeof DO_NOT_EXPORT_CRC === "undefined") {
{
factory(exports$1);
}
} else {
factory({});
}
})(function(CRC32) {
CRC32.version = "0.3.0";
function signed_crc_table() {
var c = 0, table2 = new Array(256);
for (var n = 0; n != 256; ++n) {
c = n;
c = c & 1 ? -306674912 ^ c >>> 1 : c >>> 1;
c = c & 1 ? -306674912 ^ c >>> 1 : c >>> 1;
c = c & 1 ? -306674912 ^ c >>> 1 : c >>> 1;
c = c & 1 ? -306674912 ^ c >>> 1 : c >>> 1;
c = c & 1 ? -306674912 ^ c >>> 1 : c >>> 1;
c = c & 1 ? -306674912 ^ c >>> 1 : c >>> 1;
c = c & 1 ? -306674912 ^ c >>> 1 : c >>> 1;
c = c & 1 ? -306674912 ^ c >>> 1 : c >>> 1;
table2[n] = c;
}
return typeof Int32Array !== "undefined" ? new Int32Array(table2) : table2;
}
var table = signed_crc_table();
var use_buffer = typeof Buffer !== "undefined";
function crc32_bstr(bstr) {
if (bstr.length > 32768) {
if (use_buffer) return crc32_buf_8(new Buffer(bstr));
}
var crc2 = -1, L = bstr.length - 1;
for (var i2 = 0; i2 < L; ) {
crc2 = table[(crc2 ^ bstr.charCodeAt(i2++)) & 255] ^ crc2 >>> 8;
crc2 = table[(crc2 ^ bstr.charCodeAt(i2++)) & 255] ^ crc2 >>> 8;
}
if (i2 === L) crc2 = crc2 >>> 8 ^ table[(crc2 ^ bstr.charCodeAt(i2)) & 255];
return crc2 ^ -1;
}
function crc32_buf(buf) {
if (buf.length > 1e4) return crc32_buf_8(buf);
for (var crc2 = -1, i2 = 0, L = buf.length - 3; i2 < L; ) {
crc2 = crc2 >>> 8 ^ table[(crc2 ^ buf[i2++]) & 255];
crc2 = crc2 >>> 8 ^ table[(crc2 ^ buf[i2++]) & 255];
crc2 = crc2 >>> 8 ^ table[(crc2 ^ buf[i2++]) & 255];
crc2 = crc2 >>> 8 ^ table[(crc2 ^ buf[i2++]) & 255];
}
while (i2 < L + 3) crc2 = crc2 >>> 8 ^ table[(crc2 ^ buf[i2++]) & 255];
return crc2 ^ -1;
}
function crc32_buf_8(buf) {
for (var crc2 = -1, i2 = 0, L = buf.length - 7; i2 < L; ) {
crc2 = crc2 >>> 8 ^ table[(crc2 ^ buf[i2++]) & 255];
crc2 = crc2 >>> 8 ^ table[(crc2 ^ buf[i2++]) & 255];
crc2 = crc2 >>> 8 ^ table[(crc2 ^ buf[i2++]) & 255];
crc2 = crc2 >>> 8 ^ table[(crc2 ^ buf[i2++]) & 255];
crc2 = crc2 >>> 8 ^ table[(crc2 ^ buf[i2++]) & 255];
crc2 = crc2 >>> 8 ^ table[(crc2 ^ buf[i2++]) & 255];
crc2 = crc2 >>> 8 ^ table[(crc2 ^ buf[i2++]) & 255];
crc2 = crc2 >>> 8 ^ table[(crc2 ^ buf[i2++]) & 255];
}
while (i2 < L + 7) crc2 = crc2 >>> 8 ^ table[(crc2 ^ buf[i2++]) & 255];
return crc2 ^ -1;
}
function crc32_str(str) {
for (var crc2 = -1, i2 = 0, L = str.length, c, d; i2 < L; ) {
c = str.charCodeAt(i2++);
if (c < 128) {
crc2 = crc2 >>> 8 ^ table[(crc2 ^ c) & 255];
} else if (c < 2048) {
crc2 = crc2 >>> 8 ^ table[(crc2 ^ (192 | c >> 6 & 31)) & 255];
crc2 = crc2 >>> 8 ^ table[(crc2 ^ (128 | c & 63)) & 255];
} else if (c >= 55296 && c < 57344) {
c = (c & 1023) + 64;
d = str.charCodeAt(i2++) & 1023;
crc2 = crc2 >>> 8 ^ table[(crc2 ^ (240 | c >> 8 & 7)) & 255];
crc2 = crc2 >>> 8 ^ table[(crc2 ^ (128 | c >> 2 & 63)) & 255];
crc2 = crc2 >>> 8 ^ table[(crc2 ^ (128 | d >> 6 & 15 | c & 3)) & 255];
crc2 = crc2 >>> 8 ^ table[(crc2 ^ (128 | d & 63)) & 255];
} else {
crc2 = crc2 >>> 8 ^ table[(crc2 ^ (224 | c >> 12 & 15)) & 255];
crc2 = crc2 >>> 8 ^ table[(crc2 ^ (128 | c >> 6 & 63)) & 255];
crc2 = crc2 >>> 8 ^ table[(crc2 ^ (128 | c & 63)) & 255];
}
}
return crc2 ^ -1;
}
CRC32.table = table;
CRC32.bstr = crc32_bstr;
CRC32.buf = crc32_buf;
CRC32.str = crc32_str;
});
})(crc32);
return crc32;
}
var pngChunksExtract;
var hasRequiredPngChunksExtract;
function requirePngChunksExtract() {
if (hasRequiredPngChunksExtract) return pngChunksExtract;
hasRequiredPngChunksExtract = 1;
var crc322 = requireCrc32();
pngChunksExtract = extractChunks;
var uint8 = new Uint8Array(4);
var int32 = new Int32Array(uint8.buffer);
var uint32 = new Uint32Array(uint8.buffer);
function extractChunks(data) {
if (data[0] !== 137) throw new Error("Invalid .png file header");
if (data[1] !== 80) throw new Error("Invalid .png file header");
if (data[2] !== 78) throw new Error("Invalid .png file header");
if (data[3] !== 71) throw new Error("Invalid .png file header");
if (data[4] !== 13) throw new Error("Invalid .png file header: possibly caused by DOS-Unix line ending conversion?");
if (data[5] !== 10) throw new Error("Invalid .png file header: possibly caused by DOS-Unix line ending conversion?");
if (data[6] !== 26) throw new Error("Invalid .png file header");
if (data[7] !== 10) throw new Error("Invalid .png file header: possibly caused by DOS-Unix line ending conversion?");
var ended = false;
var chunks = [];
var idx = 8;
while (idx < data.length) {
uint8[3] = data[idx++];
uint8[2] = data[idx++];
uint8[1] = data[idx++];
uint8[0] = data[idx++];
var length = uint32[0] + 4;
var chunk = new Uint8Array(length);
chunk[0] = data[idx++];
chunk[1] = data[idx++];
chunk[2] = data[idx++];
chunk[3] = data[idx++];
var name = String.fromCharCode(chunk[0]) + String.fromCharCode(chunk[1]) + String.fromCharCode(chunk[2]) + String.fromCharCode(chunk[3]);
if (!chunks.length && name !== "IHDR") {
throw new Error("IHDR header missing");
}
if (name === "IEND") {
ended = true;
chunks.push({
name,
data: new Uint8Array(0)
});
break;
}
for (var i2 = 4; i2 < length; i2++) {
chunk[i2] = data[idx++];
}
uint8[3] = data[idx++];
uint8[2] = data[idx++];
uint8[1] = data[idx++];
uint8[0] = data[idx++];
var crcActual = int32[0];
var crcExpect = crc322.buf(chunk);
if (crcExpect !== crcActual) {
throw new Error(
"CRC values for " + name + " header do not match, PNG file is likely corrupted"
);
}
var chunkData = new Uint8Array(chunk.buffer.slice(4));
chunks.push({
name,
data: chunkData
});
}
if (!ended) {
throw new Error(".png file ended prematurely: no IEND header was found");
}
return chunks;
}
return pngChunksExtract;
}
var pngChunksExtractExports = requirePngChunksExtract();
const extractPngChunks = /* @__PURE__ */ getDefaultExportFromCjs(pngChunksExtractExports);
var sliced;
var hasRequiredSliced;
function requireSliced() {
if (hasRequiredSliced) return sliced;
hasRequiredSliced = 1;
sliced = function(args, slice, sliceEnd) {
var ret = [];
var len = args.length;
if (0 === len) return ret;
var start = slice < 0 ? Math.max(0, slice + len) : slice || 0;
if (sliceEnd !== void 0) {
len = sliceEnd < 0 ? sliceEnd + len : sliceEnd;
}
while (len-- > start) {
ret[len - start] = args[len];
}
return ret;
};
return sliced;
}
var pngChunksEncode;
var hasRequiredPngChunksEncode;
function requirePngChunksEncode() {
if (hasRequiredPngChunksEncode) return pngChunksEncode;
hasRequiredPngChunksEncode = 1;
var sliced2 = requireSliced();
var crc322 = requireCrc32();
pngChunksEncode = encodeChunks;
var uint8 = new Uint8Array(4);
var int32 = new Int32Array(uint8.buffer);
var uint32 = new Uint32Array(uint8.buffer);
function encodeChunks(chunks) {
var totalSize = 8;
var idx = totalSize;
var i2;
for (i2 = 0; i2 < chunks.length; i2++) {
totalSize += chunks[i2].data.length;
totalSize += 12;
}
var output = new Uint8Array(totalSize);
output[0] = 137;
output[1] = 80;
output[2] = 78;
output[3] = 71;
output[4] = 13;
output[5] = 10;
output[6] = 26;
output[7] = 10;
for (i2 = 0; i2 < chunks.length; i2++) {
var chunk = chunks[i2];
var name = chunk.name;
var data = chunk.data;
var size = data.length;
var nameChars = [
name.charCodeAt(0),
name.charCodeAt(1),
name.charCodeAt(2),
name.charCodeAt(3)
];
uint32[0] = size;
output[idx++] = uint8[3];
output[idx++] = uint8[2];
output[idx++] = uint8[1];
output[idx++] = uint8[0];
output[idx++] = nameChars[0];
output[idx++] = nameChars[1];
output[idx++] = nameChars[2];
output[idx++] = nameChars[3];
for (var j = 0; j < size; ) {
output[idx++] = data[j++];
}
var crcCheck = nameChars.concat(sliced2(data));
var crc2 = crc322.buf(crcCheck);
int32[0] = crc2;
output[idx++] = uint8[3];
output[idx++] = uint8[2];
output[idx++] = uint8[1];
output[idx++] = uint8[0];
}
return output;
}
return pngChunksEncode;
}
var pngChunksEncodeExports = requirePngChunksEncode();
const encodePngChunks = /* @__PURE__ */ getDefaultExportFromCjs(pngChunksEncodeExports);
var pngChunkText = {};
var encode_1;
var hasRequiredEncode;
function requireEncode() {
if (hasRequiredEncode) return encode_1;
hasRequiredEncode = 1;
encode_1 = encode;
function encode(keyword, content) {
keyword = String(keyword);
content = String(content);
if (!/^[\x00-\xFF]+$/.test(keyword) || !/^[\x00-\xFF]+$/.test(content)) {
throw new Error("Only Latin-1 characters are permitted in PNG tEXt chunks. You might want to consider base64 encoding and/or zEXt compression");
}
if (keyword.length >= 80) {
throw new Error('Keyword "' + keyword + '" is longer than the 79-character limit imposed by the PNG specification');
}
var totalSize = keyword.length + content.length + 1;
var output = new Uint8Array(totalSize);
var idx = 0;
var code;
for (var i2 = 0; i2 < keyword.length; i2++) {
if (!(code = keyword.charCodeAt(i2))) {
throw new Error("0x00 character is not permitted in tEXt keywords");
}
output[idx++] = code;
}
output[idx++] = 0;
for (var j = 0; j < content.length; j++) {
if (!(code = content.charCodeAt(j))) {
throw new Error("0x00 character is not permitted in tEXt content");
}
output[idx++] = code;
}
return {
name: "tEXt",
data: output
};
}
return encode_1;
}
var decode_1;
var hasRequiredDecode;
function requireDecode() {
if (hasRequiredDecode) return decode_1;
hasRequiredDecode = 1;
decode_1 = decode;
function decode(data) {
if (data.data && data.name) {
data = data.data;
}
var naming = true;
var text = "";
var name = "";
for (var i2 = 0; i2 < data.length; i2++) {
var code = data[i2];
if (naming) {
if (code) {
name += String.fromCharCode(code);
} else {
naming = false;
}
} else {
if (code) {
text += String.fromCharCode(code);
} else {
throw new Error("Invalid NULL character found. 0x00 character is not permitted in tEXt content");
}
}
}
return {
keyword: name,
text
};
}
return decode_1;
}
var hasRequiredPngChunkText;
function requirePngChunkText() {
if (hasRequiredPngChunkText) return pngChunkText;
hasRequiredPngChunkText = 1;
pngChunkText.encode = requireEncode();
pngChunkText.decode = requireDecode();
return pngChunkText;
}
var pngChunkTextExports = requirePngChunkText();
const PngChunkText = /* @__PURE__ */ getDefaultExportFromCjs(pngChunkTextExports);
function toBase64Utf8(value) {
const text = typeof value === "string" ? value : JSON.stringify(value);
if (typeof globalThis.Buffer !== "undefined") {
return globalThis.Buffer.from(text, "utf8").toString("base64");
}
const bytes = new TextEncoder().encode(text);
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary);
}
function toUint8Array(value) {
if (value instanceof Uint8Array) return value;
if (value instanceof ArrayBuffer) return new Uint8Array(value);
if (ArrayBuffer.isView?.(value)) {
const buffer = value.buffer;
if (buffer && typeof value.byteLength === "number") {
return new Uint8Array(buffer, value.byteOffset ?? 0, value.byteLength);
}
if (buffer) {
return new Uint8Array(buffer);
}
}
if (Object.prototype.toString.call(value) === "[object ArrayBuffer]") {
return new Uint8Array(value);
}
throw new Error("PNG payload must be a Uint8Array or ArrayBuffer.");
}
function removeExistingCharacterTextChunks(chunks) {
for (let index = chunks.length - 1; index >= 0; index -= 1) {
const chunk = chunks[index];
if (chunk?.name !== "tEXt") continue;
try {
const decoded = PngChunkText.decode(chunk.data);
const keyword = String(decoded?.keyword ?? "").toLowerCase();
if (keyword === "chara" || keyword === "ccv3") {
chunks.splice(index, 1);
}
} catch {
}
}
}
function embedCardDataInPngBytes(pngBytes, cardJson, { keyword = "chara" } = {}) {
const baseImageBytes = toUint8Array(pngBytes);
const chunks = extractPngChunks(baseImageBytes);
removeExistingCharacterTextChunks(chunks);
const payload = toBase64Utf8(cardJson);
chunks.splice(-1, 0, PngChunkText.encode(keyword, payload));
const encoded = encodePngChunks(chunks);
return encoded instanceof Uint8Array ? encoded : new Uint8Array(encoded);
}
function generateId(nowMs = Date.now()) {
if (typeof globalThis.crypto?.randomUUID === "function") {
return globalThis.crypto.randomUUID();
}
return `${nowMs.toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
const EXPORT_FORMAT = Object.freeze({
TXT: "txt",
JSON: "json",
PNG: "png"
});
function generateRunNonce(nowMs = Date.now()) {
return generateId(nowMs);
}
function createRunContext({ characterId, nowMs = Date.now() }) {
return {
runId: generateRunNonce(nowMs),
targetCharacterId: characterId,
runStartMs: nowMs,
createdChatId: null,
runMarker: null
};
}
function joinTags(...groups) {
const values = groups.flat().filter(Boolean);
return [...new Set(values)];
}
function buildCreatorValue(normalizedCharacter) {
const name = normalizedCharacter?.creator?.name ?? "";
const profileUrl = normalizedCharacter?.creator?.profileUrl ?? "";
if (name && profileUrl) return `${name}
link: ${profileUrl}`;
if (name) return name;
if (profileUrl) return `link: ${profileUrl}`;
return "";
}
function normalizeCardExportOptions(options = {}) {
const source = options && typeof options === "object" ? options : {};
return {
useChatName: source.useChatName ?? false,
includeTags: source.includeTags ?? true,
includeCreatorNotes: source.includeCreatorNotes ?? true,
pronounMacros: source.pronounMacros ?? null,
fetchImpl: source.fetchImpl ?? globalThis.fetch,
onProgress: source.onProgress
};
}
function buildNormalizedCardFields(normalizedCharacter, options) {
const {
useChatName,
includeTags,
includeCreatorNotes,
pronounMacros
} = options;
const cardName = pickCardTitle(normalizedCharacter, { useChatName });
const replacements = buildPronounReplacementMap(pronounMacros);
const description = applyPronounReplacements(
normalizedCharacter.description ?? "",
replacements
);
const personality = "";
const creatorValue = buildCreatorValue(normalizedCharacter);
const systemPrompt = normalizedCharacter.systemPrompt ?? "";
const postHistoryInstructions = normalizedCharacter.postHistoryInstructions ?? "";
const scenario = applyPronounReplacements(
normalizedCharacter.scenario ?? "",
replacements
);
const firstMes = applyPronounReplacements(
normalizedCharacter.firstMes ?? "",
replacements
);
const mesExample = applyPronounReplacements(
normalizedCharacter.mesExample ?? "",
replacements
);
const alternateGreetingsRaw = applyPronounReplacementsToArray(
normalizedCharacter.alternateGreetings ?? [],
replacements
);
const alternateGreetings = uniqueStrings(alternateGreetingsRaw, {
allowEmpty: true
}).filter((greeting) => greeting !== firstMes);
const creatorNotesHtml = includeCreatorNotes ? applyPronounReplacements(
normalizedCharacter.creatorNotesHtml ?? "",
replacements
) : "";
const tags = includeTags ? joinTags(normalizedCharacter.tags, normalizedCharacter.customTags) : [];
return {
alternateGreetings,
cardName,
creatorNotesHtml,
creatorValue,
description,
firstMes,
mesExample,
personality,
postHistoryInstructions,
scenario,
systemPrompt,
tags
};
}
function buildJsonExport(normalizedCharacter, options) {
return {
format: EXPORT_FORMAT.JSON,
content: JSON.stringify(buildCardJson(normalizedCharacter, options), null, 2),
mimeType: "application/json",
extension: "json"
};
}
function buildTxtExport(normalizedCharacter, options) {
return {
format: EXPORT_FORMAT.TXT,
content: buildCardTxt(normalizedCharacter, options),
mimeType: "text/plain;charset=utf-8",
extension: "txt"
};
}
const EXPORT_BUILDERS = {
[EXPORT_FORMAT.TXT]: buildTxtExport,
[EXPORT_FORMAT.PNG]: buildPngExportArtifact,
[EXPORT_FORMAT.JSON]: buildJsonExport
};
function buildCardJson(normalizedCharacter, { useChatName = false, includeTags = true, includeCreatorNotes = true, pronounMacros = null } = {}) {
const normalizedOptions = normalizeCardExportOptions({
useChatName,
includeTags,
includeCreatorNotes,
pronounMacros
});
const fields = buildNormalizedCardFields(normalizedCharacter, normalizedOptions);
const data = {
name: fields.cardName,
description: fields.description,
personality: fields.personality,
scenario: fields.scenario,
first_mes: fields.firstMes,
mes_example: fields.mesExample,
creator_notes: fields.creatorNotesHtml,
system_prompt: fields.systemPrompt,
post_history_instructions: fields.postHistoryInstructions,
alternate_greetings: fields.alternateGreetings,
tags: fields.tags,
creator: fields.creatorValue,
character_version: normalizedCharacter.characterUrl ?? "",
extensions: {}
};
if (normalizedCharacter.characterBook) {
data.character_book = normalizedCharacter.characterBook;
}
return {
spec: "chara_card_v2",
spec_version: "2.0",
data
};
}
function buildCardTxt(normalizedCharacter, { useChatName = false, includeTags = true, pronounMacros = null } = {}) {
const normalizedOptions = normalizeCardExportOptions({
useChatName,
includeTags,
pronounMacros
});
const fields = buildNormalizedCardFields(normalizedCharacter, normalizedOptions);
const lines = [
`name: ${fields.cardName}`,
`creator: ${fields.creatorValue}`,
`character_version: ${normalizedCharacter.characterUrl ?? ""}`,
`tags: ${fields.tags.join(", ")}`,
"",
"[description]",
fields.description,
"",
"[scenario]",
fields.scenario,
"",
"[first_mes]",
fields.firstMes,
"",
"[mes_example]",
fields.mesExample,
"",
"[alternate_greetings]",
fields.alternateGreetings.join("\n---\n")
];
return lines.join("\n").trim();
}
async function buildPngExportArtifact(normalizedCharacter, {
useChatName = false,
includeTags = true,
includeCreatorNotes = true,
pronounMacros = null,
fetchImpl = globalThis.fetch,
onProgress
} = {}) {
const imageUrl = normalizedCharacter.avatar?.url1200 ?? null;
if (!imageUrl) {
throw new Error("PNG export requires an avatar URL.");
}
const imageBuffer = await fetchAvatarBytes(imageUrl, fetchImpl, onProgress);
const pngBytes = await webpToPngBytes(imageBuffer);
const cardJson = buildCardJson(normalizedCharacter, {
useChatName,
includeTags,
includeCreatorNotes,
pronounMacros
});
const pngWithMetadata = embedCardDataInPngBytes(pngBytes, cardJson, {
keyword: "chara"
});
return {
format: EXPORT_FORMAT.PNG,
content: pngWithMetadata,
mimeType: "image/png",
extension: "png"
};
}
async function buildExportContent(normalizedCharacter, {
format = EXPORT_FORMAT.JSON,
useChatName = false,
includeTags = true,
includeCreatorNotes = true,
pronounMacros = null,
fetchImpl,
onProgress
} = {}) {
const normalizedOptions = normalizeCardExportOptions({
useChatName,
includeTags,
includeCreatorNotes,
pronounMacros,
fetchImpl,
onProgress
});
const builder = EXPORT_BUILDERS[format] ?? buildJsonExport;
return builder(normalizedCharacter, normalizedOptions);
}
function buildDefaultGenerateAlphaPayload({
chatPayload = null,
messageResponse = null,
generateAlphaContext = null
} = {}) {
return buildGenerateAlphaPayload({
chatPayload,
messageResponse,
profile: generateAlphaContext?.profile,
suggestionMode: generateAlphaContext?.suggestionMode,
suggestionPerspective: generateAlphaContext?.suggestionPerspective,
userConfig: generateAlphaContext?.userConfig,
clientPlatform: generateAlphaContext?.clientPlatform,
generateMode: generateAlphaContext?.generateMode,
generateType: generateAlphaContext?.generateType,
forcedPromptGenerationCacheRefetch: generateAlphaContext?.forcedPromptGenerationCacheRefetch
});
}
const DELETE_CHAT_GUARD_REASON_CODES = Object.freeze({
CHAT_ID_MISMATCH: "CHAT_ID_MISMATCH",
CHARACTER_MISMATCH: "CHARACTER_MISMATCH",
TIMESTAMP_OUT_OF_WINDOW: "TIMESTAMP_OUT_OF_WINDOW",
OWNER_MISMATCH: "OWNER_MISMATCH",
RUN_MARKER_MISSING: "RUN_MARKER_MISSING",
NO_CREATED_CHAT_ID: "NO_CREATED_CHAT_ID"
});
const DELETE_CHAT_GUARD_REASON_MESSAGES = Object.freeze({
[DELETE_CHAT_GUARD_REASON_CODES.CHAT_ID_MISMATCH]: "Chat ID mismatch (target chat is not the run-created chat).",
[DELETE_CHAT_GUARD_REASON_CODES.CHARACTER_MISMATCH]: "Character mismatch for deletion target chat.",
[DELETE_CHAT_GUARD_REASON_CODES.TIMESTAMP_OUT_OF_WINDOW]: "Chat creation timestamp is outside the current run window.",
[DELETE_CHAT_GUARD_REASON_CODES.OWNER_MISMATCH]: "Ownership mismatch for deletion target chat.",
[DELETE_CHAT_GUARD_REASON_CODES.RUN_MARKER_MISSING]: "Run marker not found in chat messages.",
[DELETE_CHAT_GUARD_REASON_CODES.NO_CREATED_CHAT_ID]: "No run-created chat ID available for deletion."
});
function toTimestamp(value) {
const time = Date.parse(asString(value));
return Number.isFinite(time) ? time : null;
}
function findRunMarkerInChatMessages(chatPayload, runMarker) {
const messages = Array.isArray(chatPayload?.chatMessages) ? chatPayload.chatMessages : [];
const needle = asString(runMarker);
if (!needle) return false;
return messages.some((item) => {
if (item?.is_bot) return false;
const content = asString(item?.message);
return content.includes(needle);
});
}
function buildRunMarker({ runId = generateRunNonce() }) {
return asString(runId).trim() || generateRunNonce();
}
function appendRunMarkerToProbeMessage(message, runMarker) {
const base = asString(message).trim();
const marker = asString(runMarker).trim();
if (!marker) return base;
if (!base) return marker;
return `${base}
${marker}`;
}
function checkChatIdMatch({ runContext, chat }) {
const expectedChatId = runContext?.createdChatId;
const actualChatId = chat?.id ?? null;
const sameChatId = actualChatId !== null && String(actualChatId) === String(expectedChatId);
return {
ok: sameChatId,
reasonCode: DELETE_CHAT_GUARD_REASON_CODES.CHAT_ID_MISMATCH,
reason: DELETE_CHAT_GUARD_REASON_MESSAGES[DELETE_CHAT_GUARD_REASON_CODES.CHAT_ID_MISMATCH],
value: sameChatId
};
}
function checkCharacterMatch({ expectedCharacterId, chat }) {
const actualCharacterId = asString(chat?.character_id);
const expectedId = asString(expectedCharacterId);
const matchesCharacter = expectedId && actualCharacterId === expectedId;
return {
ok: Boolean(matchesCharacter),
reasonCode: DELETE_CHAT_GUARD_REASON_CODES.CHARACTER_MISMATCH,
reason: DELETE_CHAT_GUARD_REASON_MESSAGES[DELETE_CHAT_GUARD_REASON_CODES.CHARACTER_MISMATCH],
value: Boolean(matchesCharacter)
};
}
function checkTimestampWindow({
runContext,
chat,
nowMs,
maxFutureSkewMs,
maxPastSkewMs
}) {
const runStartMs = Number(runContext?.runStartMs);
const chatCreatedAtMs = toTimestamp(chat?.created_at);
const earliestAllowedMs = Number.isFinite(runStartMs) ? runStartMs - maxPastSkewMs : null;
const latestAllowedMs = Number.isFinite(nowMs) ? nowMs + maxFutureSkewMs : null;
const validTimestamp = Number.isFinite(chatCreatedAtMs) && (earliestAllowedMs === null || chatCreatedAtMs >= earliestAllowedMs) && (latestAllowedMs === null || chatCreatedAtMs <= latestAllowedMs);
return {
ok: validTimestamp,
reasonCode: DELETE_CHAT_GUARD_REASON_CODES.TIMESTAMP_OUT_OF_WINDOW,
reason: DELETE_CHAT_GUARD_REASON_MESSAGES[DELETE_CHAT_GUARD_REASON_CODES.TIMESTAMP_OUT_OF_WINDOW],
value: validTimestamp
};
}
function checkOwnership({ chat, sessionUserId }) {
if (!sessionUserId) {
return {
ok: true,
reasonCode: DELETE_CHAT_GUARD_REASON_CODES.OWNER_MISMATCH,
reason: DELETE_CHAT_GUARD_REASON_MESSAGES[DELETE_CHAT_GUARD_REASON_CODES.OWNER_MISMATCH],
value: true
};
}
const chatUserId = asString(chat?.user_id);
const matchesOwner = Boolean(chatUserId && chatUserId === asString(sessionUserId));
return {
ok: matchesOwner,
reasonCode: DELETE_CHAT_GUARD_REASON_CODES.OWNER_MISMATCH,
reason: DELETE_CHAT_GUARD_REASON_MESSAGES[DELETE_CHAT_GUARD_REASON_CODES.OWNER_MISMATCH],
value: matchesOwner
};
}
function checkRunMarkerPresent({ chatPayload, runContext }) {
const runMarker = runContext?.runMarker;
const hasRunMarker = findRunMarkerInChatMessages(chatPayload, runMarker);
return {
ok: hasRunMarker,
reasonCode: DELETE_CHAT_GUARD_REASON_CODES.RUN_MARKER_MISSING,
reason: DELETE_CHAT_GUARD_REASON_MESSAGES[DELETE_CHAT_GUARD_REASON_CODES.RUN_MARKER_MISSING],
value: hasRunMarker
};
}
function evaluateDeletionSafety({
runContext,
chatPayload,
expectedCharacterId,
sessionUserId = null,
nowMs = Date.now(),
maxFutureSkewMs = DEFAULT_FUTURE_SKEW_MS,
maxPastSkewMs = DEFAULT_PAST_SKEW_MS
}) {
const reasons = [];
const reasonCodes = [];
const chat = chatPayload?.chat ?? {};
const chatIdCheck = checkChatIdMatch({ runContext, chat });
const characterCheck = checkCharacterMatch({ expectedCharacterId, chat });
const timestampCheck = checkTimestampWindow({
runContext,
chat,
nowMs,
maxFutureSkewMs,
maxPastSkewMs
});
const ownershipCheck = checkOwnership({ chat, sessionUserId });
const runMarkerCheck = checkRunMarkerPresent({ chatPayload, runContext });
const checks = {
sameChatId: chatIdCheck.value,
matchesCharacter: characterCheck.value,
validTimestamp: timestampCheck.value,
hasRunMarker: runMarkerCheck.value,
matchesOwner: ownershipCheck.value
};
const allChecks = [
chatIdCheck,
characterCheck,
timestampCheck,
ownershipCheck,
runMarkerCheck
];
for (const check of allChecks) {
if (check.ok) continue;
reasons.push(check.reason);
reasonCodes.push(check.reasonCode);
}
return {
ok: reasons.length === 0,
reasons,
reasonCodes,
checks
};
}
async function guardedDeleteTemporaryChat({
client,
runContext,
chatPayload = null,
expectedCharacterId,
sessionUserId = null,
nowMs = Date.now(),
maxFutureSkewMs = DEFAULT_FUTURE_SKEW_MS,
maxPastSkewMs = DEFAULT_PAST_SKEW_MS,
logger = console,
force = false
}) {
if (!runContext?.createdChatId) {
return {
deleted: false,
safe: false,
reasons: [
DELETE_CHAT_GUARD_REASON_MESSAGES[DELETE_CHAT_GUARD_REASON_CODES.NO_CREATED_CHAT_ID]
],
reasonCodes: [DELETE_CHAT_GUARD_REASON_CODES.NO_CREATED_CHAT_ID]
};
}
let resolvedChatPayload;
try {
resolvedChatPayload = chatPayload ?? await client.getChat(runContext.createdChatId);
} catch (fetchError) {
if (!force) throw fetchError;
try {
logger?.warn?.("[jan] delete guard: chat fetch failed, force-deleting", {
chatId: runContext.createdChatId,
error: fetchError?.message
});
} catch {
}
await client.deleteChat(runContext.createdChatId);
return {
deleted: true,
safe: false,
forced: true,
reasons: [`Safety eval skipped: ${fetchError?.message ?? "Chat fetch failed"}`],
reasonCodes: [],
checks: {},
chatId: runContext.createdChatId
};
}
const safety = evaluateDeletionSafety({
runContext,
chatPayload: resolvedChatPayload,
expectedCharacterId,
sessionUserId,
nowMs,
maxFutureSkewMs,
maxPastSkewMs
});
if (!safety.ok) {
try {
logger?.warn?.(
force ? "[jan] delete guard overridden (forced)" : "[jan] delete guard failed",
{
reasons: safety.reasons,
reasonCodes: safety.reasonCodes,
checks: safety.checks,
chatId: runContext?.createdChatId ?? null,
chat: resolvedChatPayload?.chat ?? null,
chatMessages: resolvedChatPayload?.chatMessages ?? null
}
);
} catch {
}
if (!force) {
return {
deleted: false,
safe: false,
reasons: safety.reasons,
reasonCodes: safety.reasonCodes,
checks: safety.checks,
chatId: runContext.createdChatId
};
}
}
await client.deleteChat(runContext.createdChatId);
return {
deleted: true,
safe: safety.ok,
forced: force && !safety.ok,
reasons: safety.ok ? [] : safety.reasons,
reasonCodes: safety.ok ? [] : safety.reasonCodes,
checks: safety.checks,
chatId: runContext.createdChatId
};
}
const PROGRESS_STAGES = Object.freeze({
characterLoaded: 0.08,
chatBootstrapped: 0.18,
probeSent: 0.35,
chatFetched: 0.45,
generateAlphaDone: 0.62,
cleanupDone: 0.72,
exportDone: 0.88
});
function createProgressReporter(onProgress, stages = PROGRESS_STAGES) {
const report = (stage) => {
if (typeof onProgress !== "function") return;
const value = stages?.[stage];
if (typeof value === "number") {
onProgress(value);
}
};
const mapRange = (start, end) => {
if (typeof onProgress !== "function") return null;
return (raw) => {
onProgress(start + raw * (end - start));
};
};
return { report, mapRange };
}
function createProxyOffError() {
const proxyError = new Error("PROXY OFF");
proxyError.code = "PROXY_OFF";
return proxyError;
}
function resolveCharacterId({ characterId, characterLink }) {
const resolvedCharacterId = extractCharacterIdFromInput(characterId || characterLink);
if (!resolvedCharacterId) {
throw new Error("A valid characterId or characterLink is required.");
}
return resolvedCharacterId;
}
function createNowResolver(client, nowMs) {
return typeof client?.getServerNowMs === "function" ? () => client.getServerNowMs() : () => nowMs;
}
function createRunContextWithMarker({ characterId, nowMs }) {
const runContext = createRunContext({
characterId,
nowMs
});
runContext.runMarker = buildRunMarker({ runId: runContext.runId });
return runContext;
}
async function resolveGenerateAlphaContext({
client,
generateAlphaContext,
generateAlphaProxyConfig,
generateAlphaPayloadBuilder
}) {
const explicitGenerateAlphaContext = isPlainObject(generateAlphaContext) ? generateAlphaContext : null;
const extractedGenerateAlphaContext = !explicitGenerateAlphaContext && typeof client.getGenerateAlphaRuntimeContext === "function" ? await client.getGenerateAlphaRuntimeContext({}) : null;
const finalGenerateAlphaContext = explicitGenerateAlphaContext ?? extractedGenerateAlphaContext;
const mergedGenerateAlphaContext = isPlainObject(finalGenerateAlphaContext) ? { ...finalGenerateAlphaContext } : {};
const mergedGenerateAlphaUserConfig = mergeGenerateAlphaUserConfigWithProxyConfig(
mergedGenerateAlphaContext.userConfig,
generateAlphaProxyConfig
);
if (mergedGenerateAlphaUserConfig) {
mergedGenerateAlphaContext.userConfig = mergedGenerateAlphaUserConfig;
} else {
delete mergedGenerateAlphaContext.userConfig;
}
const resolvedGenerateAlphaContext = Object.keys(mergedGenerateAlphaContext).length > 0 ? mergedGenerateAlphaContext : null;
if (!resolvedGenerateAlphaContext && typeof generateAlphaPayloadBuilder !== "function") {
throw new Error("GenerateAlpha context is required for chat extraction.");
}
return resolvedGenerateAlphaContext;
}
function resolveProbeScriptIds(probeScriptIds) {
return Array.isArray(probeScriptIds) && probeScriptIds.length > 0 ? probeScriptIds : [];
}
async function bootstrapTemporaryChat({ client, characterId }) {
return client.startTemporaryChat(characterId);
}
async function sendProbe({
client,
chatId,
characterId,
probeMessage,
probeScriptIds,
runMarker
}) {
const probeWithRunMarker = appendRunMarkerToProbeMessage(
probeMessage,
runMarker
);
const messageResponse = await client.sendMessage(chatId, probeWithRunMarker, {
characterId,
scriptIds: probeScriptIds
});
return { probeWithRunMarker, messageResponse };
}
function resolveGenerateAlphaPayloadBuilder(generateAlphaPayloadBuilder) {
return typeof generateAlphaPayloadBuilder === "function" ? generateAlphaPayloadBuilder : buildDefaultGenerateAlphaPayload;
}
async function runGenerateAlpha({
client,
builder,
generateAlphaContext,
payloadInputs
}) {
if (typeof builder !== "function") return null;
const payload = await builder({
...payloadInputs,
generateAlphaContext
});
if (!payload) return null;
const response = await client.generateAlpha(payload);
const personaAnchor = typeof payload?.profile?.name === "string" ? payload.profile.name.trim() : "";
return {
response,
personaAnchor: personaAnchor || null
};
}
async function normalizeAndExport({
characterPayload,
chatPayload,
generateAlphaResponse,
personaAnchor,
exportOptions
}) {
const generateAlphaFields = parseGenerateAlphaFields(
Array.isArray(generateAlphaResponse?.messages) ? generateAlphaResponse.messages : []
);
if (typeof personaAnchor === "string" && personaAnchor.trim()) {
generateAlphaFields.personaAnchor = personaAnchor.trim();
}
const normalizedCharacter = normalizeCharacterFields({
characterPayload,
chatPayload,
generateAlphaFields
});
const exportPayload = await buildExportContent(normalizedCharacter, exportOptions);
return { normalizedCharacter, exportPayload };
}
async function executeChatScrapeFlow({
client,
characterId = null,
characterLink = null,
probeMessage = "",
probeScriptIds = [],
generateAlphaContext = null,
generateAlphaProxyConfig = null,
generateAlphaPayloadBuilder = null,
exportFormat = "json",
useChatName = false,
includeTags = true,
includeCreatorNotes = true,
pronounMacros = null,
sessionUserId = null,
nowMs = Date.now(),
onProgress = null
}) {
if (!client) throw new Error("executeChatScrapeFlow requires a client.");
const resolveNowMs = createNowResolver(client, nowMs);
const progress = createProgressReporter(onProgress);
const resolvedCharacterId = resolveCharacterId({
characterId,
characterLink
});
const runContext = createRunContextWithMarker({
characterId: resolvedCharacterId,
nowMs: resolveNowMs()
});
let normalizedCharacter = null;
let exportPayload = null;
let generateAlphaResponse = null;
let personaAnchor = null;
let characterPayload = null;
let chatPayload = null;
let resolvedGenerateAlphaContext = null;
let runtimeError = null;
let deleteResult = null;
async function cleanupTemporaryChat(expectedCharacterId, { force = false } = {}) {
if (deleteResult?.deleted) return deleteResult;
if (deleteResult && !force) return deleteResult;
deleteResult = await guardedDeleteTemporaryChat({
client,
runContext,
chatPayload,
expectedCharacterId,
sessionUserId,
nowMs: resolveNowMs(),
force
}).catch((error) => ({
deleted: false,
safe: false,
reasons: [`Cleanup failed: ${error?.message ?? "Unknown cleanup error."}`]
}));
return deleteResult;
}
try {
characterPayload = await client.getCharacter(resolvedCharacterId);
runContext.runStartMs = resolveNowMs();
progress.report("characterLoaded");
if (characterPayload?.allow_proxy === false) {
throw createProxyOffError();
}
const chatBootstrap = await bootstrapTemporaryChat({
client,
characterId: resolvedCharacterId
});
runContext.createdChatId = chatBootstrap.chatId;
progress.report("chatBootstrapped");
resolvedGenerateAlphaContext = await resolveGenerateAlphaContext({
client,
generateAlphaContext,
generateAlphaProxyConfig,
generateAlphaPayloadBuilder
});
const resolvedProbeScriptIds = resolveProbeScriptIds(probeScriptIds);
const { probeWithRunMarker, messageResponse } = await sendProbe({
client,
chatId: runContext.createdChatId,
characterId: resolvedCharacterId,
probeMessage,
probeScriptIds: resolvedProbeScriptIds,
runMarker: runContext.runMarker
});
progress.report("probeSent");
chatPayload = await client.getChat(runContext.createdChatId);
progress.report("chatFetched");
const resolvedGenerateAlphaPayloadBuilder = resolveGenerateAlphaPayloadBuilder(
generateAlphaPayloadBuilder
);
if (resolvedGenerateAlphaPayloadBuilder) {
const generateAlphaResult = await runGenerateAlpha({
client,
builder: resolvedGenerateAlphaPayloadBuilder,
generateAlphaContext: resolvedGenerateAlphaContext,
payloadInputs: {
chatId: runContext.createdChatId,
characterId: resolvedCharacterId,
probeMessage: probeWithRunMarker,
probeScriptIds: resolvedProbeScriptIds,
chatBootstrap,
messageResponse,
chatPayload,
characterPayload
}
});
generateAlphaResponse = generateAlphaResult?.response ?? null;
personaAnchor = generateAlphaResult?.personaAnchor ?? null;
}
progress.report("generateAlphaDone");
await cleanupTemporaryChat(chatPayload?.chat?.character_id || resolvedCharacterId);
progress.report("cleanupDone");
const exportOnProgress = progress.mapRange(
PROGRESS_STAGES.cleanupDone,
PROGRESS_STAGES.exportDone
);
const exportResult = await normalizeAndExport({
characterPayload,
chatPayload,
generateAlphaResponse,
personaAnchor,
exportOptions: {
format: exportFormat,
useChatName,
includeTags,
includeCreatorNotes,
pronounMacros,
onProgress: exportOnProgress
}
});
normalizedCharacter = exportResult.normalizedCharacter;
exportPayload = exportResult.exportPayload;
progress.report("exportDone");
} catch (error) {
runtimeError = error;
}
const forceCleanup = !!runtimeError && !!runContext.createdChatId;
await cleanupTemporaryChat(
normalizedCharacter?.characterId || chatPayload?.chat?.character_id || resolvedCharacterId,
{ force: forceCleanup }
);
if (runtimeError) {
runtimeError.deleteResult = deleteResult;
throw runtimeError;
}
return {
runContext,
normalizedCharacter,
exportPayload,
deleteResult
};
}
function sanitizeFilename(value, fallback = DEFAULT_FILENAME, { replacement = "_", normalizeWhitespace = true } = {}) {
const raw = typeof value === "string" ? value.trim() : "";
const safeReplacement = typeof replacement === "string" && replacement.length > 0 ? replacement : "_";
let cleaned = raw.replace(/[\\/:*?"<>|]/g, safeReplacement);
if (normalizeWhitespace) {
cleaned = cleaned.replace(/\s+/g, " ").trim();
} else {
cleaned = cleaned.trim();
}
return cleaned || fallback;
}
function buildBaseFilename(normalizedCharacter, { useChatName = false } = {}) {
const title = resolveCharacterTitle(normalizedCharacter, { useChatName });
const creator = normalizedCharacter?.creator?.name || DEFAULT_CREATOR;
const resolvedTitle = title || DEFAULT_FILENAME;
return sanitizeFilename(`${resolvedTitle} - ${creator}`);
}
function buildDownloadArtifact({
normalizedCharacter,
exportPayload,
useChatName = false
}) {
const filenameBase = buildBaseFilename(normalizedCharacter, { useChatName });
const filename = `${filenameBase}.${exportPayload.extension}`;
return {
filename,
mimeType: exportPayload.mimeType,
content: exportPayload.content
};
}
function getLocalStorage(documentRef) {
try {
return documentRef?.defaultView?.localStorage ?? globalThis.localStorage ?? null;
} catch {
return null;
}
}
function getSessionStorage(documentRef) {
try {
return documentRef?.defaultView?.sessionStorage ?? globalThis.sessionStorage ?? null;
} catch {
return null;
}
}
function readString(storage, key, fallback = null) {
if (!storage || !key) return fallback;
try {
const raw = storage.getItem(key);
if (raw === null || raw === void 0 || raw === "") return fallback;
return raw;
} catch {
return fallback;
}
}
function writeString(storage, key, value) {
if (!storage || !key) return false;
try {
storage.setItem(key, value);
return true;
} catch {
return false;
}
}
function readJson(storage, key, fallback = null) {
const raw = readString(storage, key, null);
if (raw === null) return fallback;
try {
return JSON.parse(raw);
} catch {
return fallback;
}
}
function writeJson(storage, key, value) {
if (!storage || !key) return false;
try {
storage.setItem(key, JSON.stringify(value));
return true;
} catch {
return false;
}
}
function removeStorageItem(storage, key) {
if (!storage || !key) return false;
try {
storage.removeItem(key);
return true;
} catch {
return false;
}
}
const SETTINGS_STORAGE_PREFIX = "jan_setting_";
const PRONOUN_STORAGE_KEY = "jan_pronoun_macros";
const PRONOUN_PRESET_STORAGE_KEY = "jan_pronoun_preset";
const DEFAULT_PRONOUN_PRESET = "they";
const SETTINGS_DEFAULTS = Object.freeze({
vis_name_display: "chat",
vis_char_avatar: "none",
exp_gen_name: "chat",
exp_gen_tags: "on",
exp_gen_notes: "on",
exp_batch_zip: "store"
});
const PRONOUN_PRESETS = Object.freeze({
she: { sub: "she", obj: "her", poss: "her", poss_p: "hers", ref: "herself" },
he: { sub: "he", obj: "him", poss: "his", poss_p: "his", ref: "himself" },
they: { sub: "they", obj: "them", poss: "their", poss_p: "theirs", ref: "themselves" }
});
const DEFAULT_PRONOUNS = Object.freeze({
...PRONOUN_PRESETS[DEFAULT_PRONOUN_PRESET]
});
function readSettingValue(key, fallback, documentRef) {
const storage = getLocalStorage(documentRef);
return readString(storage, `${SETTINGS_STORAGE_PREFIX}${key}`, fallback);
}
function normalizePronounValue(value, fallback) {
const text = typeof value === "string" ? value.trim() : "";
return text || fallback;
}
function readPronounMacros(documentRef) {
const storage = getLocalStorage(documentRef);
const parsed = readJson(storage, PRONOUN_STORAGE_KEY, null);
if (!parsed) return { ...DEFAULT_PRONOUNS };
return {
sub: normalizePronounValue(parsed?.sub, DEFAULT_PRONOUNS.sub),
obj: normalizePronounValue(parsed?.obj, DEFAULT_PRONOUNS.obj),
poss: normalizePronounValue(parsed?.poss, DEFAULT_PRONOUNS.poss),
poss_p: normalizePronounValue(parsed?.poss_p, DEFAULT_PRONOUNS.poss_p),
ref: normalizePronounValue(parsed?.ref, DEFAULT_PRONOUNS.ref)
};
}
function readViewerSettings(documentRef) {
return {
nameDisplay: readSettingValue(
"vis_name_display",
SETTINGS_DEFAULTS.vis_name_display,
documentRef
),
avatarMode: readSettingValue(
"vis_char_avatar",
SETTINGS_DEFAULTS.vis_char_avatar,
documentRef
)
};
}
function readExportSettings(documentRef) {
const nameMode = readSettingValue(
"exp_gen_name",
SETTINGS_DEFAULTS.exp_gen_name,
documentRef
);
const tagsMode = readSettingValue(
"exp_gen_tags",
SETTINGS_DEFAULTS.exp_gen_tags,
documentRef
);
const notesMode = readSettingValue(
"exp_gen_notes",
SETTINGS_DEFAULTS.exp_gen_notes,
documentRef
);
return {
useChatName: nameMode === "chat",
includeTags: tagsMode !== "off",
includeCreatorNotes: notesMode !== "off",
pronounMacros: readPronounMacros(documentRef)
};
}
function resolveExportOptions(options = {}, exportSettings = readExportSettings()) {
const settings = exportSettings ?? {};
return {
useChatName: options?.useChatName ?? settings.useChatName,
includeTags: options?.includeTags ?? settings.includeTags,
includeCreatorNotes: options?.includeCreatorNotes ?? settings.includeCreatorNotes,
pronounMacros: options?.pronounMacros ?? settings.pronounMacros
};
}
function readBatchZipSetting(documentRef) {
return readSettingValue(
"exp_batch_zip",
SETTINGS_DEFAULTS.exp_batch_zip,
documentRef
);
}
function normalizeScrapeRequest(rawRequest, exportSettings, now) {
const {
characterId = null,
characterLink = null,
probeMessage = "",
probeScriptIds = [],
generateAlphaContext = null,
generateAlphaProxyConfig = null,
exportFormat = "json",
useChatName = null,
includeTags = null,
includeCreatorNotes = null,
pronounMacros = null,
sessionUserId = null,
nowMs = null,
onProgress = null
} = rawRequest ?? {};
const resolvedExportOptions = resolveExportOptions(
{ useChatName, includeTags, includeCreatorNotes, pronounMacros },
exportSettings
);
const resolvedNowMs = nowMs ?? (typeof now === "function" ? now() : Date.now());
return {
characterId,
characterLink,
probeMessage,
probeScriptIds,
generateAlphaContext,
generateAlphaProxyConfig,
exportFormat,
...resolvedExportOptions,
sessionUserId,
nowMs: resolvedNowMs,
onProgress
};
}
async function runChatScrapeFlow({ client, generateAlphaPayloadBuilder, request }) {
return executeChatScrapeFlow({
client,
characterId: request.characterId,
characterLink: request.characterLink,
probeMessage: request.probeMessage,
probeScriptIds: request.probeScriptIds,
generateAlphaContext: request.generateAlphaContext,
generateAlphaProxyConfig: request.generateAlphaProxyConfig,
generateAlphaPayloadBuilder,
exportFormat: request.exportFormat,
useChatName: request.useChatName,
includeTags: request.includeTags,
includeCreatorNotes: request.includeCreatorNotes,
pronounMacros: request.pronounMacros,
sessionUserId: request.sessionUserId,
nowMs: request.nowMs,
onProgress: request.onProgress
});
}
function buildDownloadArtifactForResult(result, useChatName) {
return buildDownloadArtifact({
normalizedCharacter: result.normalizedCharacter,
exportPayload: result.exportPayload,
useChatName
});
}
function createInternalOrchestrator({
client,
generateAlphaPayloadBuilder = null,
now = () => Date.now()
}) {
if (!client) throw new Error("createInternalOrchestrator requires a client.");
return {
/**
* @param {ScrapeCharacterCardRequest} rawRequest
* @returns {Promise<ScrapeCharacterCardResult>}
*/
async scrapeCharacterCard(rawRequest) {
const exportSettings = readExportSettings();
const request = normalizeScrapeRequest(rawRequest, exportSettings, now);
const result = await runChatScrapeFlow({
client,
generateAlphaPayloadBuilder,
request
});
const downloadArtifact = buildDownloadArtifactForResult(
result,
request.useChatName
);
return {
...result,
downloadArtifact
};
}
};
}
const DARK_MODE_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path stroke-dasharray="65" stroke-dashoffset="65" d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"><animate fill="freeze" attributeName="stroke-dashoffset" dur="0.5s" values="65;0"/></path></svg>`;
const LIGHT_MODE_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle stroke-dasharray="26" stroke-dashoffset="26" cx="12" cy="12" r="4"><animate fill="freeze" attributeName="stroke-dashoffset" dur="0.3s" values="26;0"/></circle><path stroke-dasharray="3" stroke-dashoffset="3" d="M12 2v2"><animate fill="freeze" attributeName="stroke-dashoffset" begin="0.35s" dur="0.15s" to="0"/></path><path stroke-dasharray="3" stroke-dashoffset="3" d="M12 20v2"><animate fill="freeze" attributeName="stroke-dashoffset" begin="0.35s" dur="0.15s" to="0"/></path><path stroke-dasharray="3" stroke-dashoffset="3" d="m4.93 4.93 1.41 1.41"><animate fill="freeze" attributeName="stroke-dashoffset" begin="0.35s" dur="0.15s" to="0"/></path><path stroke-dasharray="3" stroke-dashoffset="3" d="m17.66 17.66 1.41 1.41"><animate fill="freeze" attributeName="stroke-dashoffset" begin="0.35s" dur="0.15s" to="0"/></path><path stroke-dasharray="3" stroke-dashoffset="3" d="M2 12h2"><animate fill="freeze" attributeName="stroke-dashoffset" begin="0.35s" dur="0.15s" to="0"/></path><path stroke-dasharray="3" stroke-dashoffset="3" d="M20 12h2"><animate fill="freeze" attributeName="stroke-dashoffset" begin="0.35s" dur="0.15s" to="0"/></path><path stroke-dasharray="3" stroke-dashoffset="3" d="m6.34 17.66-1.41 1.41"><animate fill="freeze" attributeName="stroke-dashoffset" begin="0.35s" dur="0.15s" to="0"/></path><path stroke-dasharray="3" stroke-dashoffset="3" d="m19.07 4.93-1.41 1.41"><animate fill="freeze" attributeName="stroke-dashoffset" begin="0.35s" dur="0.15s" to="0"/></path></svg>`;
const PANEL_CLOSE_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><path stroke-dasharray="15" stroke-dashoffset="15" d="M1 1l10 10"><animate fill="freeze" attributeName="stroke-dashoffset" dur="0.2s" values="15;0"/></path><path stroke-dasharray="15" stroke-dashoffset="15" d="M1 11l10-10"><animate fill="freeze" attributeName="stroke-dashoffset" begin="0.1s" dur="0.2s" values="15;0"/></path></svg>`;
const HUD_CLOSE_ICON_SVG = PANEL_CLOSE_ICON_SVG;
const TAB_CHARACTER_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle stroke-dasharray="32" stroke-dashoffset="32" cx="12" cy="8" r="5"><animate fill="freeze" attributeName="stroke-dashoffset" dur="0.3s" values="32;0"/></circle><path stroke-dasharray="26" stroke-dashoffset="26" d="M20 21a8 8 0 0 0-16 0"><animate fill="freeze" attributeName="stroke-dashoffset" begin="0.25s" dur="0.3s" values="26;0"/></path></svg>`;
const TAB_DOWNLOAD_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="1.5" aria-hidden="true" focusable="false"><path stroke-dasharray="75" stroke-dashoffset="75" stroke-linejoin="round" d="M20.935 11.009V8.793a2.98 2.98 0 0 0-1.529-2.61l-5.957-3.307a2.98 2.98 0 0 0-2.898 0L4.594 6.182a2.98 2.98 0 0 0-1.529 2.611v6.414a2.98 2.98 0 0 0 1.529 2.61l5.957 3.307a2.98 2.98 0 0 0 2.898 0l2.522-1.4"><animate fill="freeze" attributeName="stroke-dashoffset" dur="0.45s" values="75;0"/></path><path stroke-dasharray="20" stroke-dashoffset="20" stroke-linejoin="round" d="M20.33 6.996L12 12L3.67 6.996"><animate fill="freeze" attributeName="stroke-dashoffset" begin="0.3s" dur="0.2s" values="20;0"/></path><path stroke-dasharray="10" stroke-dashoffset="10" stroke-linejoin="round" d="M12 21.49V12"><animate fill="freeze" attributeName="stroke-dashoffset" begin="0.3s" dur="0.2s" values="10;0"/></path><path stroke-dasharray="6" stroke-dashoffset="6" stroke-miterlimit="10" d="M19.97 19.245v-5"><animate fill="freeze" attributeName="stroke-dashoffset" begin="0.45s" dur="0.15s" values="6;0"/></path><path stroke-dasharray="7" stroke-dashoffset="7" stroke-linejoin="round" d="m17.676 17.14l1.968 1.968a.46.46 0 0 0 .65 0l1.968-1.968"><animate fill="freeze" attributeName="stroke-dashoffset" begin="0.5s" dur="0.15s" values="7;0"/></path></svg>`;
const TAB_SETTINGS_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true" focusable="false"><circle stroke-dasharray="19" stroke-dashoffset="19" cx="12" cy="12" r="3"><animate fill="freeze" attributeName="stroke-dashoffset" dur="0.3s" values="19;0"/></circle><path stroke-dasharray="120" stroke-dashoffset="120" d="M13.765 2.152C13.398 2 12.932 2 12 2s-1.398 0-1.765.152a2 2 0 0 0-1.083 1.083c-.092.223-.129.484-.143.863a1.62 1.62 0 0 1-.79 1.353a1.62 1.62 0 0 1-1.567.008c-.336-.178-.579-.276-.82-.308a2 2 0 0 0-1.478.396C4.04 5.79 3.806 6.193 3.34 7s-.7 1.21-.751 1.605a2 2 0 0 0 .396 1.479c.148.192.355.353.676.555c.473.297.777.803.777 1.361s-.304 1.064-.777 1.36c-.321.203-.529.364-.676.556a2 2 0 0 0-.396 1.479c.052.394.285.798.75 1.605c.467.807.7 1.21 1.015 1.453a2 2 0 0 0 1.479.396c.24-.032.483-.13.819-.308a1.62 1.62 0 0 1 1.567.008c.483.28.77.795.79 1.353c.014.38.05.64.143.863a2 2 0 0 0 1.083 1.083C10.602 22 11.068 22 12 22s1.398 0 1.765-.152a2 2 0 0 0 1.083-1.083c.092-.223.129-.483.143-.863c.02-.558.307-1.074.79-1.353a1.62 1.62 0 0 1 1.567-.008c.336.178.579.276.819.308a2 2 0 0 0 1.479-.396c.315-.242.548-.646 1.014-1.453s.7-1.21.751-1.605a2 2 0 0 0-.396-1.479c-.148-.192-.355-.353-.676-.555A1.62 1.62 0 0 1 19.562 12c0-.558.304-1.064.777-1.36c.321-.203.529-.364.676-.556a2 2 0 0 0 .396-1.479c-.052-.394-.285-.798-.75-1.605c-.467-.807-.7-1.21-1.015-1.453a2 2 0 0 0-1.479-.396c-.24.032-.483.13-.82.308a1.62 1.62 0 0 1-1.566-.008a1.62 1.62 0 0 1-.79-1.353c-.014-.38-.05-.64-.143-.863a2 2 0 0 0-1.083-1.083Z"><animate fill="freeze" attributeName="stroke-dashoffset" begin="0.15s" dur="0.6s" values="120;0"/></path></svg>`;
const SETTINGS_NAV_GENERAL_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-settings-icon lucide-settings"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/><circle cx="12" cy="12" r="3"/></svg>`;
const SETTINGS_NAV_CHARACTER_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="5" /><path d="M20 21a8 8 0 0 0-16 0" /></svg>`;
const SETTINGS_NAV_BATCH_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20.935 11.009V8.793a2.98 2.98 0 0 0-1.529-2.61l-5.957-3.307a2.98 2.98 0 0 0-2.898 0L4.594 6.182a2.98 2.98 0 0 0-1.529 2.611v6.414a2.98 2.98 0 0 0 1.529 2.61l5.957 3.307a2.98 2.98 0 0 0 2.898 0l2.522-1.4" /><path d="M20.33 6.996L12 12L3.67 6.996" /><path d="M12 21.49V12" /><path d="M19.97 19.245v-5" /><path d="m17.676 17.14l1.968 1.968a.46.46 0 0 0 .65 0l1.968-1.968" /></svg>`;
const SETTINGS_NAV_STORAGE_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19A9 3 0 0 0 21 19V5"/><path d="M3 12A9 3 0 0 0 21 12"/></svg>`;
const SETTINGS_AVATAR_NONE_ICON_SVG = `<svg width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect width="18" height="18" x="3" y="3" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>`;
const SETTINGS_AVATAR_BLUR_ICON_SVG = `<svg width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/><line x1="3" y1="3" x2="21" y2="21" style="opacity:0.5;"/></svg>`;
const SETTINGS_AVATAR_OFF_ICON_SVG = `<svg width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49"/><path d="M14.084 14.158a3 3 0 0 1-4.242-4.242"/><path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143"/><path d="m2 2 20 20"/></svg>`;
const VERIFIED_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/><path d="m9 12 2 2 4-4"/></svg>`;
const DEFINITION_HIDDEN_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49"/><path d="M14.084 14.158a3 3 0 0 1-4.242-4.242"/><path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143"/><path d="m2 2 20 20"/></svg>`;
const DEFINITION_PUBLIC_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></svg>`;
const DEFINITION_HIDDEN_EMPTY_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="44" height="44" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49"/><path d="M14.084 14.158a3 3 0 0 1-4.242-4.242"/><path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143"/><path d="m2 2 20 20"/></svg>`;
const MAXIMIZE_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m15 15 6 6"/><path d="m15 9 6-6"/><path d="M21 16v5h-5"/><path d="M21 8V3h-5"/><path d="M3 16v5h5"/><path d="m3 21 6-6"/><path d="M3 8V3h5"/><path d="M9 9 3 3"/></svg>`;
const LIGHTBOX_CLOSE_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m14 10 7-7"/><path d="M20 10h-6V4"/><path d="m3 21 7-7"/><path d="M4 14h6v6"/></svg>`;
const EXPAND_DOWN_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M19 3H5"/><path d="M12 21V7"/><path d="m6 15 6 6 6-6"/></svg>`;
const EXPAND_UP_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m18 9-6-6-6 6"/><path d="M12 3v14"/><path d="M5 21h14"/></svg>`;
const FORMAT_PNG_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>`;
const FORMAT_JSON_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><path d="M10 12a1 1 0 0 0-1 1v1a1 1 0 0 1-1 1 1 1 0 0 1 1 1v1a1 1 0 0 0 1 1"/><path d="M14 18a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1 1 1 0 0 1-1-1v-1a1 1 0 0 0-1-1"/></svg>`;
const FORMAT_TXT_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/></svg>`;
const DOWNLOAD_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`;
const COPY_IDLE_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>`;
const COPY_SUCCESS_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M20 6 9 17l-5-5"/></svg>`;
const DESCRIPTION_EMPTY_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="44" height="44" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/></svg>`;
const FIRST_MESSAGE_EMPTY_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="44" height="44" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M13 21h8"/><path d="m15 5 4 4"/><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/></svg>`;
const SCENARIO_EMPTY_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="44" height="44" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M2 6h4"/><path d="M2 10h4"/><path d="M2 14h4"/><path d="M2 18h4"/><rect width="16" height="20" x="4" y="2" rx="2"/><path d="M9.5 8h5"/><path d="M9.5 12H16"/><path d="M9.5 16H14"/></svg>`;
const EXAMPLE_DIALOGS_EMPTY_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="44" height="44" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M16 10a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 14.286V4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/><path d="M20 9a2 2 0 0 1 2 2v10.286a.71.71 0 0 1-1.212.502l-2.202-2.202A2 2 0 0 0 17.172 19H10a2 2 0 0 1-2-2v-1"/></svg>`;
const CREATOR_NOTES_EMPTY_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="44" height="44" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M14 2a2 2 0 0 0-2 2v2.53"/><path d="M12 6.53a5.98 5.98 0 0 0-8.5.5 4 4 0 0 1 4.02 5.86 4 4 0 0 1-1.76 7.04C6.82 21.17 7.97 22 9 22c1.5 0 1.5-1 3-1s1.5 1 3 1c1.03 0 2.18-.83 3.24-2.07a4 4 0 0 1-1.76-7.03 4 4 0 0 1 4.02-5.87 5.99 5.99 0 0 0-8.5-.5Z"/></svg>`;
const NO_CHARACTER_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="42" height="42" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="12" cy="8" r="5"/><path d="M20 21a8 8 0 1 0-16 0"/></svg>`;
const INFO_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>`;
const CORNER_DOWN_LEFT_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M20 4v7a4 4 0 0 1-4 4H4"/><path d="m9 10-5 5 5 5"/></svg>`;
const CHEVRON_LEFT_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><polyline points="15 18 9 12 15 6"/></svg>`;
const CHEVRON_RIGHT_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><polyline points="9 18 15 12 9 6"/></svg>`;
const LOG_OUT_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m16 17 5-5-5-5"/><path d="M21 12H9"/><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/></svg>`;
const REFRESH_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>`;
const SHIELD_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/></svg>`;
const PICK_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M2 21a8 8 0 0 1 13.292-6"/><circle cx="10" cy="8" r="5"/><path d="M19 16v6"/><path d="M22 19h-6"/></svg>`;
const PLUS_CIRCLE_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="10"/><path d="M8 12h8"/><path d="M12 8v8"/></svg>`;
const SETTINGS_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M14 17H5M19 7h-9"/><circle cx="17" cy="17" r="3"/><circle cx="7" cy="7" r="3"/></g></svg>`;
const BATCH_SECTION_DOWNLOAD_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/></svg>`;
const EXPORT_FILES_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M15 2h-4a2 2 0 0 0-2 2v11a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V8"/><path d="M16.706 2.706A2.4 2.4 0 0 0 15 2v5a1 1 0 0 0 1 1h5a2.4 2.4 0 0 0-.706-1.706z"/><path d="M5 7a2 2 0 0 0-2 2v11a2 2 0 0 0 2 2h8a2 2 0 0 0 1.732-1"/></svg>`;
const EXPORT_ZIP_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M13.659 22H18a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v11.5"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><path d="M8 12v-1"/><path d="M8 18v-2"/><path d="M8 7V6"/><circle cx="8" cy="20" r="2"/></svg>`;
const FILTER_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>`;
const BATCH_FORMAT_PNG_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>`;
const BATCH_FORMAT_JSON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><path d="M10 12a1 1 0 0 0-1 1v1a1 1 0 0 1-1 1 1 1 0 0 1 1 1v1a1 1 0 0 0 1 1"/><path d="M14 18a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1 1 1 0 0 1-1-1v-1a1 1 0 0 0-1-1"/></svg>`;
const BATCH_FORMAT_TXT_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/></svg>`;
const BULK_FETCH_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>`;
const BULK_DOWNLOAD_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/></svg>`;
const TRASH_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M10 11v6"/><path d="M14 11v6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>`;
const VIEWER_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="12" cy="8" r="5"/><path d="M20 21a8 8 0 0 0-16 0"/></svg>`;
const FETCH_ITEM_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M12 17V3"/><path d="m6 11 6 6 6-6"/><path d="M19 21H5"/></svg>`;
const QUEUE_PENDING_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><path d="M9 15h6"/></svg>`;
const QUEUE_FETCHING_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M16 22h2a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v2.85"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><path d="M8 14v2.2l1.6 1"/><circle cx="8" cy="16" r="6"/></svg>`;
const QUEUE_ERROR_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><path d="m14.5 12.5-5 5"/><path d="m9.5 12.5 5 5"/></svg>`;
const QUEUE_READY_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><path d="m9 15 2 2 4-4"/></svg>`;
const HUD_SELECT_ALL_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><rect width="18" height="18" x="3" y="3" rx="2"/></svg>`;
const HUD_SELECT_PARTIAL_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M8 12h8"/></svg>`;
const HUD_SELECT_NONE_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="m9 12 2 2 4-4"/></svg>`;
function createRafScheduler$1(fn, windowRef = globalThis) {
const raf = typeof windowRef?.requestAnimationFrame === "function" ? windowRef.requestAnimationFrame.bind(windowRef) : (cb) => (windowRef?.setTimeout ?? globalThis.setTimeout)(cb, 16);
const cancel = typeof windowRef?.cancelAnimationFrame === "function" ? windowRef.cancelAnimationFrame.bind(windowRef) : (id) => (windowRef?.clearTimeout ?? globalThis.clearTimeout)(id);
let rafId = null;
let lastArgs = null;
const schedule = (...args) => {
lastArgs = args;
if (rafId !== null) return;
rafId = raf(() => {
rafId = null;
fn(...lastArgs ?? []);
});
};
schedule.cancel = () => {
if (rafId === null) return;
cancel(rafId);
rafId = null;
};
return schedule;
}
function createDebounced(fn, delayMs = 0, windowRef = globalThis) {
const setTimer = typeof windowRef?.setTimeout === "function" ? windowRef.setTimeout.bind(windowRef) : globalThis.setTimeout;
const clearTimer = typeof windowRef?.clearTimeout === "function" ? windowRef.clearTimeout.bind(windowRef) : globalThis.clearTimeout;
let timerId = null;
let lastArgs = null;
const schedule = (...args) => {
lastArgs = args;
if (timerId !== null) clearTimer(timerId);
timerId = setTimer(() => {
timerId = null;
fn(...lastArgs ?? []);
}, delayMs);
};
schedule.flush = () => {
if (timerId === null) return;
clearTimer(timerId);
timerId = null;
fn(...lastArgs ?? []);
};
schedule.cancel = () => {
if (timerId === null) return;
clearTimer(timerId);
timerId = null;
};
return schedule;
}
function ensureStyle(documentRef, { id, cssText }) {
if (!documentRef) return null;
const existing = documentRef.getElementById(id);
if (existing) return existing;
const style = documentRef.createElement("style");
style.id = id;
style.textContent = cssText;
(documentRef.head ?? documentRef.documentElement)?.appendChild(style);
return style;
}
function removeStyle(documentRef, id) {
documentRef?.getElementById?.(id)?.remove();
}
async function withBusyButton(button, task) {
if (!button || typeof task !== "function") return null;
const wasDisabled = button.disabled;
const hadBusy = button.hasAttribute("aria-busy");
if (!wasDisabled) {
button.disabled = true;
}
button.setAttribute("aria-busy", "true");
try {
return await task();
} finally {
if (!wasDisabled) {
button.disabled = false;
}
if (!hadBusy) {
button.removeAttribute("aria-busy");
}
}
}
function isElementNode$1(node) {
return !!node && node.nodeType === 1;
}
function createMutationObserver(documentRef, onMutations, { root, options } = {}) {
const Observer = documentRef?.defaultView?.MutationObserver ?? globalThis.MutationObserver;
if (typeof Observer !== "function") return null;
const observer = new Observer(onMutations);
const target = root ?? documentRef?.documentElement ?? documentRef?.body;
if (target) {
observer.observe(target, {
childList: true,
subtree: true,
...options ?? {}
});
}
return observer;
}
function createRafScheduler(documentRef, callback) {
const windowRef = documentRef?.defaultView ?? globalThis;
return createRafScheduler$1(callback, windowRef);
}
function scheduleRetries(delays, callback, { isActive } = {}) {
const shouldRun = typeof isActive === "function" ? isActive : () => true;
const timers = delays.map(
(delay) => setTimeout(() => {
if (!shouldRun()) return;
callback();
}, delay)
);
return () => {
timers.forEach((timer) => clearTimeout(timer));
};
}
const GOOGLE_FONTS_PRECONNECT_ID = "jan-font-preconnect-google";
const GSTATIC_FONTS_PRECONNECT_ID = "jan-font-preconnect-gstatic";
function ensureLink(documentRef, { id, rel, href, crossOrigin = null }) {
if (!documentRef?.head || !id || !rel || !href) return;
if (documentRef.getElementById(id)) return;
const link = documentRef.createElement("link");
link.id = id;
link.rel = rel;
link.href = href;
if (crossOrigin) link.crossOrigin = crossOrigin;
documentRef.head.appendChild(link);
}
function ensureGoogleFontStylesheet(documentRef, { id, href }) {
if (!documentRef?.head) return;
ensureLink(documentRef, {
id: GOOGLE_FONTS_PRECONNECT_ID,
rel: "preconnect",
href: "https://fonts.googleapis.com"
});
ensureLink(documentRef, {
id: GSTATIC_FONTS_PRECONNECT_ID,
rel: "preconnect",
href: "https://fonts.gstatic.com",
crossOrigin: "anonymous"
});
ensureLink(documentRef, {
id,
rel: "stylesheet",
href
});
}
const CHARACTER_VIEWER_TAB_STYLE_ID = "jan-character-viewer-tab-style";
const CHARACTER_VIEWER_FONT_STYLESHEET_ID = "jan-character-viewer-font-stylesheet";
const CHARACTER_VIEWER_FONT_STYLESHEET_URL = "https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600&family=JetBrains+Mono:wght@400&display=swap";
const CHARACTER_VIEWER_AVATAR_WIDTH = 272;
const CHARACTER_VIEWER_AVATAR_COLLAPSED_HEIGHT = 248;
const CHARACTER_VIEWER_AVATAR_MAX_HEIGHT = 500;
function ensureCharacterViewerTabStyle(documentRef) {
if (!documentRef?.head) return;
ensureGoogleFontStylesheet(documentRef, {
id: CHARACTER_VIEWER_FONT_STYLESHEET_ID,
href: CHARACTER_VIEWER_FONT_STYLESHEET_URL
});
ensureStyle(documentRef, {
id: CHARACTER_VIEWER_TAB_STYLE_ID,
cssText: `
.jan-character-viewer-view {
--bg-color: #050505;
--panel-bg: linear-gradient(180deg, #0a0a0a 0%, #000000 100%);
--border-color: rgba(255, 255, 255, 0.07);
--text-color: #e8e8ec;
--text-muted: rgba(255, 255, 255, 0.5);
--accent: #fff;
--accent-glow: rgba(255, 255, 255, 0.2);
--success: #4CAF50;
--error: #f44336;
--warning: #ffb74d;
--hover-bg: rgba(255, 255, 255, 0.05);
--pod-bg: linear-gradient(180deg, #181818 0%, #000000 100%);
--font-family: 'Outfit', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--tok-personality: #7ba5e8;
--tok-firstmsg: #6ec9a5;
--tok-scenario: #e0a86e;
--tok-dialogs: #c47ede;
color: var(--text-color);
display: none;
flex-direction: column;
font-family: var(--font-family);
height: 100%;
min-height: 0;
overflow: hidden;
position: relative;
width: 100%;
}
.jan-character-viewer-view .avatar-section:hover .avatar-gradient { opacity: 1; }
.jan-character-viewer-view.is-active {
display: flex;
}
.jan-character-viewer-view::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: rgba(255, 255, 255, 0.08);
pointer-events: none;
z-index: 5;
}
.jan-character-viewer-view .main-content {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
position: relative;
width: 100%;
z-index: 1;
}
.jan-character-viewer-view .meta-sidebar {
width: 272px;
flex-shrink: 0;
box-shadow: 1px 0 0 rgba(255, 255, 255, 0.05);
display: flex;
flex-direction: column;
overflow: hidden;
overscroll-behavior: contain;
background: rgba(0, 0, 0, 0.15);
}
.jan-character-viewer-view .avatar-section {
position: relative;
width: 272px;
flex-shrink: 0;
overflow: hidden;
cursor: pointer;
transition: height 0.38s cubic-bezier(0.4, 0, 0.2, 1);
}
.jan-character-viewer-view .avatar-section::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 12px;
background: linear-gradient(180deg, rgba(10, 10, 10, 0.58) 0%, rgba(10, 10, 10, 0.22) 42%, rgba(10, 10, 10, 0) 100%);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
opacity: 0;
pointer-events: none;
transition: opacity 0.22s ease;
z-index: 2;
}
.jan-character-viewer-view .avatar-section:hover::before { opacity: 1; }
.jan-character-viewer-view .avatar-maximize-btn {
position: absolute;
top: 8px;
right: 8px;
opacity: 0;
transition: opacity 0.22s ease;
cursor: pointer;
color: rgba(255, 255, 255, 0.9);
background: rgba(0, 0, 0, 0.5);
border-radius: 5px;
padding: 4px;
border: none;
display: flex;
align-items: center;
justify-content: center;
z-index: 4;
line-height: 0;
}
.jan-character-viewer-view .avatar-section:hover .avatar-maximize-btn { opacity: 1; }
.jan-character-viewer-view .avatar-expand-toggle {
position: absolute;
bottom: 0;
left: 0;
right: 0;
display: flex;
justify-content: center;
align-items: center;
gap: 5px;
padding: 7px 0 10px;
cursor: pointer;
opacity: 0;
transition: opacity 0.22s ease;
font-size: 0.73rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.85);
letter-spacing: 0.04em;
z-index: 3;
user-select: none;
}
.jan-character-viewer-view .avatar-section:hover .avatar-expand-toggle { opacity: 1; }
.jan-character-viewer-view .character-avatar {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center top;
display: block;
}
.jan-character-viewer-view .avatar-gradient {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 96px;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.72));
opacity: 0;
pointer-events: none;
transition: opacity 0.22s ease;
}
.jan-character-viewer-view .meta-body {
flex: 1;
overflow-y: auto;
overscroll-behavior: contain;
padding: 14px 15px;
display: flex;
flex-direction: column;
gap: 14px;
}
.jan-character-viewer-view .meta-body::-webkit-scrollbar { width: 4px; }
.jan-character-viewer-view .meta-body::-webkit-scrollbar-track { background: transparent; }
.jan-character-viewer-view .meta-body::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.1); border-radius: 0; }
.jan-character-viewer-view .meta-body::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.2); }
.jan-character-viewer-view .meta-section {
display: flex;
flex-direction: column;
gap: 7px;
}
.jan-character-viewer-view .section-title {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-muted);
font-weight: 600;
margin-bottom: 1px;
}
.jan-character-viewer-view .section-title-right {
font-size: 0.82rem;
color: var(--text-color);
font-weight: 500;
letter-spacing: 0;
text-transform: none;
}
.jan-character-viewer-view .meta-kv {
display: flex;
justify-content: space-between;
align-items: center;
}
.jan-character-viewer-view .meta-key {
font-size: 0.8rem;
color: var(--text-muted);
flex-shrink: 0;
}
.jan-character-viewer-view .meta-value {
font-size: 0.82rem;
color: var(--text-color);
text-align: right;
}
.jan-character-viewer-view .meta-key-toggle {
cursor: pointer;
user-select: none;
text-decoration-line: underline;
text-decoration-style: dashed;
text-decoration-color: rgba(255, 255, 255, 0.2);
transition: color 0.15s, text-decoration-color 0.15s;
}
.jan-character-viewer-view .meta-key-toggle:hover {
color: rgba(255, 255, 255, 0.75);
text-decoration-color: rgba(255, 255, 255, 0.45);
}
.jan-character-viewer-view .meta-key-toggle.is-alt {
color: rgba(255, 255, 255, 0.65);
text-decoration-color: rgba(255, 255, 255, 0.4);
}
.jan-character-viewer-view .meta-link {
font-size: 0.82rem;
color: rgba(255, 255, 255, 0.72);
text-decoration: none;
text-align: right;
display: flex;
align-items: center;
gap: 2px;
transition: color 0.15s;
}
.jan-character-viewer-view .meta-link:hover { color: var(--text-color); }
.jan-character-viewer-view .meta-link.id-link {
font-family: 'JetBrains Mono', monospace;
font-size: 0.73rem;
color: rgba(255, 255, 255, 0.38);
letter-spacing: 0;
}
.jan-character-viewer-view .meta-link.id-link:hover { color: rgba(255, 255, 255, 0.65); }
.jan-character-viewer-view .verified-icon { color: #5a9cf5; flex-shrink: 0; }
.jan-character-viewer-view .tag-list {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.jan-character-viewer-view .tag-pill {
font-size: 0.7rem;
font-weight: 500;
padding: 3px 9px;
border-radius: 100px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.6);
white-space: nowrap;
}
.jan-character-viewer-view .tag-nsfw {
background: rgba(220, 50, 47, 0.12);
border-color: rgba(220, 50, 47, 0.28);
color: #e05450;
}
.jan-character-viewer-view .tag-custom {
background: rgba(195, 119, 224, 0.1);
border-color: rgba(195, 119, 224, 0.22);
color: rgba(195, 119, 224, 0.88);
}
.jan-character-viewer-view .stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 7px;
}
.jan-character-viewer-view .stat-block {
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 8px;
padding: 9px 11px;
display: flex;
flex-direction: column;
gap: 3px;
}
.jan-character-viewer-view .stat-val {
font-size: 1.05rem;
font-weight: 500;
color: var(--text-color);
line-height: 1;
}
.jan-character-viewer-view .stat-lbl {
font-size: 0.7rem;
color: var(--text-muted);
}
.jan-character-viewer-view .token-bar {
height: 6px;
border-radius: 6px;
overflow: hidden;
background: rgba(255, 255, 255, 0.05);
display: flex;
}
.jan-character-viewer-view .token-seg { height: 100%; }
.jan-character-viewer-view .seg-personality { background: var(--tok-personality); }
.jan-character-viewer-view .seg-firstmsg { background: var(--tok-firstmsg); }
.jan-character-viewer-view .seg-scenario { background: var(--tok-scenario); }
.jan-character-viewer-view .seg-dialogs { background: var(--tok-dialogs); }
.jan-character-viewer-view .token-legend {
display: flex;
flex-direction: column;
gap: 5px;
margin-top: 9px;
}
.jan-character-viewer-view .legend-row {
display: flex;
align-items: center;
gap: 7px;
}
.jan-character-viewer-view .legend-dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.jan-character-viewer-view .legend-key {
font-size: 0.77rem;
color: var(--text-muted);
flex: 1;
}
.jan-character-viewer-view .legend-val {
font-size: 0.77rem;
color: var(--text-color);
font-weight: 500;
}
.jan-character-viewer-view .legend-row.zero .legend-dot { background: rgba(255, 255, 255, 0.1) !important; }
.jan-character-viewer-view .legend-row.zero .legend-key { color: rgba(255, 255, 255, 0.22); }
.jan-character-viewer-view .legend-row.zero .legend-val { color: rgba(255, 255, 255, 0.22); }
.jan-character-viewer-view .sidebar-footer {
padding: 11px 14px;
box-shadow: 0 -1px 0 rgba(255, 255, 255, 0.05);
flex-shrink: 0;
}
.jan-character-viewer-view .action-block {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
position: relative;
}
.jan-character-viewer-view .format-picker-group {
display: flex;
align-items: center;
background: rgba(0, 0, 0, 0.5);
border-radius: 8px;
padding: 2px;
transition: opacity 0.3s ease, transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
.jan-character-viewer-view .format-picker-group.dl-hidden {
opacity: 0;
pointer-events: none;
transform: scale(0.9);
}
.jan-character-viewer-view .format-option { position: relative; min-width: 0; }
.jan-character-viewer-view .format-option input[type="radio"] { position: absolute; opacity: 0; pointer-events: none; }
.jan-character-viewer-view .format-label {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 6px;
border-radius: 6px;
cursor: pointer;
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.03em;
color: rgba(255, 255, 255, 0.3);
transition: background 0.2s ease, color 0.2s ease, padding 0.3s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.2s ease;
user-select: none;
}
.jan-character-viewer-view .format-label svg {
width: 12px;
height: 12px;
opacity: 0.7;
flex-shrink: 0;
transition: opacity 0.2s;
}
.jan-character-viewer-view .format-label .fmt-text {
display: inline-block;
max-width: 0;
overflow: hidden;
opacity: 0;
white-space: nowrap;
transition: max-width 0.3s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.25s ease;
}
.jan-character-viewer-view .format-option:has(input[value="png"]):hover .format-label { color: #5a9cf5; }
.jan-character-viewer-view .format-option:has(input[value="json"]):hover .format-label { color: #d4893a; }
.jan-character-viewer-view .format-option:has(input[value="txt"]):hover .format-label { color: #8fa8be; }
.jan-character-viewer-view .format-option:hover .format-label svg { opacity: 1; }
.jan-character-viewer-view .format-option input[value="png"]:checked ~ .format-label {
padding: 6px 8px;
background: rgba(90, 156, 245, 0.1);
color: #5a9cf5;
box-shadow: inset 0 0 0 2px rgba(90, 156, 245, 0.55);
}
.jan-character-viewer-view .format-option input[value="json"]:checked ~ .format-label {
padding: 6px 8px;
background: rgba(212, 137, 58, 0.1);
color: #d4893a;
box-shadow: inset 0 0 0 2px rgba(212, 137, 58, 0.55);
}
.jan-character-viewer-view .format-option input[value="txt"]:checked ~ .format-label {
padding: 6px 8px;
background: rgba(143, 168, 190, 0.1);
color: #8fa8be;
box-shadow: inset 0 0 0 2px rgba(143, 168, 190, 0.55);
}
.jan-character-viewer-view .format-option input:checked ~ .format-label svg { opacity: 1; }
.jan-character-viewer-view .format-option input:checked ~ .format-label .fmt-text {
max-width: 40px;
opacity: 1;
}
.jan-character-viewer-view .btn-download {
flex: 0 0 auto;
padding: 7px 12px;
background: transparent;
border: none;
border-radius: 50px;
color: rgba(255, 255, 255, 0.45);
cursor: pointer;
font-size: 0.65rem;
font-weight: 600;
font-family: inherit;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: all 0.2s ease;
white-space: nowrap;
}
.jan-character-viewer-view .btn-download:hover {
color: #fff;
background: rgba(255, 255, 255, 0.06);
}
.jan-character-viewer-view .btn-download:active { transform: scale(0.97); }
.jan-character-viewer-view .btn-download.dl-hidden {
opacity: 0;
pointer-events: none;
transform: scale(0.9);
}
.jan-character-viewer-view .dl-core {
position: relative;
}
.jan-character-viewer-view .physics-container {
position: absolute;
top: 50%;
left: 0;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
height: 30px;
width: 100%;
pointer-events: none;
overflow: hidden;
}
.jan-character-viewer-view .physics-mask {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
display: flex;
align-items: center;
justify-content: center;
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 15%, black 85%, transparent 100%);
mask-image: linear-gradient(to bottom, transparent 0%, black 15%, black 85%, transparent 100%);
}
.jan-character-viewer-view .physics-text {
display: inline-flex;
font-family: 'JetBrains Mono', monospace;
font-size: 0.72rem;
font-weight: 400;
color: #c5d6e8;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.jan-character-viewer-view .char-slot {
display: inline-block;
width: 1ch;
text-align: center;
position: relative;
overflow: hidden;
height: 1.5em;
transition: width 0.35s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.3s ease;
opacity: 1;
}
.jan-character-viewer-view .char-slot.collapsed {
width: 0px;
opacity: 0;
}
.jan-character-viewer-view .char-column {
display: flex;
flex-direction: column;
position: absolute;
top: 0;
left: 0;
width: 100%;
will-change: transform;
backface-visibility: hidden;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.jan-character-viewer-view .char-column span {
display: flex;
justify-content: center;
align-items: center;
height: 1.5em;
line-height: 1;
}
.jan-character-viewer-view .content-section {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.jan-character-viewer-view .tab-bar {
display: flex;
align-items: stretch;
padding: 0 22px;
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.05);
flex-shrink: 0;
gap: 0;
}
.jan-character-viewer-view .tab-btn {
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: var(--text-muted);
font-family: inherit;
font-size: 0.84rem;
font-weight: 400;
padding: 14px 13px 12px;
cursor: pointer;
transition: all 0.15s ease;
display: flex;
align-items: center;
gap: 5px;
white-space: nowrap;
margin-bottom: -1px;
}
.jan-character-viewer-view .tab-btn:hover { color: rgba(255, 255, 255, 0.8); }
.jan-character-viewer-view .tab-btn:hover .tab-count { color: rgba(255, 255, 255, 0.30); }
.jan-character-viewer-view .tab-btn:hover .tab-dash { color: rgba(255, 255, 255, 0.26); }
.jan-character-viewer-view .tab-btn.active {
color: var(--text-color);
border-bottom-color: var(--accent);
font-weight: 500;
}
.jan-character-viewer-view .tab-count {
font-size: 0.7rem;
color: rgba(255, 255, 255, 0.22);
font-weight: 400;
transition: color 0.15s ease;
}
.jan-character-viewer-view .tab-btn.active .tab-count { color: rgba(255, 255, 255, 0.38); }
.jan-character-viewer-view .tab-dash {
font-size: 0.7rem;
color: rgba(255, 255, 255, 0.2);
font-weight: 400;
transition: color 0.15s ease;
}
.jan-character-viewer-view .tab-content-wrap {
flex: 1;
overflow: hidden;
position: relative;
}
.jan-character-viewer-view .tab-pane {
display: none;
position: absolute;
inset: 0;
overflow-y: auto;
overscroll-behavior: contain;
flex-direction: column;
}
.jan-character-viewer-view .tab-pane.active { display: flex; }
.jan-character-viewer-view .tab-pane::-webkit-scrollbar { width: 4px; }
.jan-character-viewer-view .tab-pane::-webkit-scrollbar-track { background: transparent; margin: 0; }
.jan-character-viewer-view .tab-pane::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.1); border-radius: 0; }
.jan-character-viewer-view .tab-pane::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.2); }
.jan-character-viewer-view .field-wrapper {
flex: 1;
display: flex;
flex-direction: column;
padding: 18px 22px 22px;
}
.jan-character-viewer-view .field-header {
display: flex;
justify-content: flex-end;
margin-bottom: 10px;
}
.jan-character-viewer-view .field-text {
margin: 0;
font-family: var(--font-family);
font-size: 0.82rem;
line-height: 1.75;
color: rgba(255, 255, 255, 0.75);
white-space: pre-wrap;
word-break: break-word;
background: rgba(0, 0, 0, 0.22);
border: 1px solid rgba(255, 255, 255, 0.04);
border-radius: 8px;
padding: 15px 17px;
}
.jan-character-viewer-view .copy-btn {
background: none;
border: none;
color: rgba(255, 255, 255, 0.3);
font-family: inherit;
font-size: 0.72rem;
font-weight: 500;
cursor: pointer;
padding: 2px 0;
display: flex;
align-items: center;
gap: 5px;
position: relative;
transition: color 0.18s;
}
.jan-character-viewer-view .copy-btn::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 1px;
background: currentColor;
transform: scaleX(0);
transform-origin: left;
transition: transform 0.22s cubic-bezier(0.16, 1, 0.3, 1);
}
.jan-character-viewer-view .copy-btn:hover {
color: rgba(255, 255, 255, 0.6);
}
.jan-character-viewer-view .copy-btn:hover::after {
transform: scaleX(1);
}
.jan-character-viewer-view .copy-btn.copied {
color: var(--success);
}
.jan-character-viewer-view .copy-btn.copied::after {
transform: scaleX(1);
background: var(--success);
}
.jan-character-viewer-view .empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 13px;
color: rgba(255, 255, 255, 0.18);
padding: 40px;
}
.jan-character-viewer-view .empty-state-text {
font-size: 0.85rem;
text-align: center;
}
.jan-character-viewer-view .html-display {
padding: 22px 26px 26px;
font-size: 0.86rem;
line-height: 1.72;
color: rgba(255, 255, 255, 0.72);
}
.jan-character-viewer-view .html-display h1,
.jan-character-viewer-view .html-display h2,
.jan-character-viewer-view .html-display h3 {
color: var(--text-color);
margin: 18px 0 6px;
font-weight: 600;
font-size: 0.96rem;
line-height: 1.4;
}
.jan-character-viewer-view .html-display h1:first-child,
.jan-character-viewer-view .html-display h2:first-child { margin-top: 0; }
.jan-character-viewer-view .html-display p { margin: 5px 0; }
.jan-character-viewer-view .html-display p:empty { display: none; }
.jan-character-viewer-view .html-display a {
color: #7ba5e8;
text-decoration: none;
}
.jan-character-viewer-view .html-display a:hover { text-decoration: underline; }
.jan-character-viewer-view .cv-no-character {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 11px;
padding: 40px;
text-align: center;
color: rgba(255, 255, 255, 0.16);
}
.jan-character-viewer-view .cv-no-character-title {
font-size: 0.88rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.22);
margin-top: 2px;
}
.jan-character-viewer-view .cv-no-character-sub {
font-size: 0.77rem;
color: rgba(255, 255, 255, 0.13);
max-width: 220px;
line-height: 1.65;
}
.jan-character-viewer-view .cv-load-form {
width: 280px;
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 6px;
}
.jan-character-viewer-view .cv-id-input {
width: 100%;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 6px;
color: var(--text-color);
font-family: 'JetBrains Mono', monospace;
font-size: 0.72rem;
padding: 8px 11px;
outline: none;
transition: border-color 0.15s;
}
.jan-character-viewer-view .cv-id-input::placeholder { color: rgba(255, 255, 255, 0.16); }
.jan-character-viewer-view .cv-id-input:focus {
border-color: rgba(255, 255, 255, 0.24);
}
.jan-character-viewer-view .cv-enter-btn {
width: 100%;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 6px;
color: rgba(255, 255, 255, 0.38);
cursor: pointer;
font-family: inherit;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.03em;
padding: 8px 12px;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: color 0.18s, background 0.18s, border-color 0.15s;
}
.jan-character-viewer-view .cv-enter-btn:hover {
color: rgba(255, 255, 255, 0.72);
background: rgba(255, 255, 255, 0.04);
}
.jan-character-viewer-view .cv-enter-btn:focus { outline: none; }
.jan-character-viewer-view .cv-enter-btn:active { transform: scale(0.98); }
.jan-character-viewer-view .cv-load-error {
font-size: 0.72rem;
color: rgba(220, 50, 47, 0.88);
margin-top: 6px;
text-align: center;
min-height: 1.4em;
}
.jan-character-viewer-view .cv-info-row {
display: flex;
align-items: center;
gap: 5px;
color: rgba(255, 255, 255, 0.18);
font-size: 0.71rem;
margin-top: 6px;
}
.jan-character-viewer-view .cv-info-row span {
margin-top: 1px;
}
.jan-character-viewer-view .cv-load-form.is-loading .cv-id-input,
.jan-character-viewer-view .cv-load-form.is-loading .cv-enter-btn {
opacity: 0.45;
pointer-events: none;
}
.jan-character-viewer-view .cv-load-form.is-error .cv-id-input {
border-color: rgba(220, 50, 47, 0.45);
}
/* --- Greeting Carousel --- */
@keyframes jan-greet-out-left {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(-28px); opacity: 0; }
}
@keyframes jan-greet-out-right {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(28px); opacity: 0; }
}
@keyframes jan-greet-in-right {
from { transform: translateX(28px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes jan-greet-in-left {
from { transform: translateX(-28px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
.jan-character-viewer-view .greeting-field-header {
justify-content: space-between;
align-items: center;
}
.jan-character-viewer-view .greeting-nav {
display: flex;
align-items: center;
gap: 5px;
}
.jan-character-viewer-view .greeting-nav-btn {
background: none;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 5px;
color: rgba(255, 255, 255, 0.35);
cursor: pointer;
width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
line-height: 0;
transition: color 0.15s, border-color 0.15s, background 0.15s;
flex-shrink: 0;
}
.jan-character-viewer-view .greeting-nav-btn:hover:not(:disabled) {
color: rgba(255, 255, 255, 0.8);
border-color: rgba(255, 255, 255, 0.22);
background: rgba(255, 255, 255, 0.05);
}
.jan-character-viewer-view .greeting-nav-btn:disabled {
opacity: 0.22;
cursor: default;
}
.jan-character-viewer-view .greeting-counter {
font-family: 'JetBrains Mono', monospace;
font-size: 0.7rem;
color: rgba(255, 255, 255, 0.28);
min-width: 36px;
text-align: center;
user-select: none;
}
.jan-character-viewer-view .greeting-text-wrap {
position: relative;
overflow: hidden;
width: 100%;
}
.jan-character-viewer-view .greeting-dots {
display: flex;
justify-content: center;
align-items: center;
gap: 6px;
padding: 10px 0 8px;
}
.jan-character-viewer-view .greeting-dot {
position: relative;
width: 7px;
height: 7px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.14);
cursor: pointer;
transition: background 0.2s, transform 0.2s;
flex-shrink: 0;
}
.jan-character-viewer-view .greeting-dot::after {
content: '';
position: absolute;
inset: -7px;
}
.jan-character-viewer-view .greeting-dot:hover {
background: rgba(255, 255, 255, 0.32);
}
.jan-character-viewer-view .greeting-dot.active {
background: rgba(255, 255, 255, 0.55);
transform: scale(1.35);
cursor: default;
}
.jan-character-viewer-lightbox.lightbox-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.82);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
z-index: 100000;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
pointer-events: none;
transition: opacity 0.22s ease;
}
.jan-character-viewer-lightbox.lightbox-overlay.active {
opacity: 1;
pointer-events: all;
}
.jan-character-viewer-lightbox.lightbox-overlay .lightbox-img {
max-width: 88vw;
max-height: 88vh;
object-fit: contain;
border-radius: 10px;
box-shadow: 0 32px 100px rgba(0, 0, 0, 0.9);
}
.jan-character-viewer-lightbox.lightbox-overlay .lightbox-close {
position: fixed;
top: 18px;
right: 18px;
background: rgba(220, 50, 47, 0.88);
border: none;
border-radius: 7px;
color: #fff;
width: 34px;
height: 34px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 3;
transition: background 0.15s;
line-height: 0;
}
.jan-character-viewer-lightbox.lightbox-overlay .lightbox-close:hover { background: rgba(220, 50, 47, 1); }
.jan-character-viewer-view .unload-btn {
background: none;
border: none;
color: rgba(255, 255, 255, 0.2);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
align-self: center;
padding: 0;
margin-left: auto;
margin-right: 10px;
line-height: 0;
flex-shrink: 0;
transition: color 0.15s;
}
.jan-character-viewer-view .unload-btn:hover {
color: rgba(255, 255, 255, 0.7);
}
.jan-character-viewer-view .cv-fetched-key {
display: flex;
align-items: center;
gap: 5px;
}
.jan-character-viewer-view .cv-refresh-btn {
background: none;
border: none;
color: rgba(255, 255, 255, 0.25);
cursor: pointer;
padding: 2px;
line-height: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
flex-shrink: 0;
transition: color 0.15s;
}
.jan-character-viewer-view .cv-refresh-btn:hover {
color: rgba(255, 255, 255, 0.7);
}
/* --- Proxy Status Overlay (Glowing Shield) --- */
.jan-character-viewer-view .action-block .proxy-status-overlay {
display: none;
}
.jan-character-viewer-view .action-block.is-proxy-off .format-picker-group,
.jan-character-viewer-view .action-block.is-proxy-off .dl-core {
filter: blur(1.5px) opacity(60%);
pointer-events: none;
}
.jan-character-viewer-view .action-block.is-proxy-off .proxy-status-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
background: radial-gradient(circle, rgba(0,0,0,0.6) 0%, rgba(0,0,0,0.2) 100%);
border-radius: 8px;
z-index: 10;
pointer-events: auto;
user-select: none;
-webkit-user-select: none;
}
.jan-character-viewer-view .action-block.is-proxy-off .shield-icon-container {
display: flex;
align-items: center;
justify-content: center;
color: #ff5252;
filter: drop-shadow(0 0 6px rgba(255, 82, 82, 0.6));
animation: jan-proxy-pulse-glow 2s infinite alternate ease-in-out;
position: relative;
top: -1.2px;
}
@keyframes jan-proxy-pulse-glow {
0% { filter: drop-shadow(0 0 2px rgba(255, 82, 82, 0.4)); opacity: 0.8; }
100% { filter: drop-shadow(0 0 8px rgba(255, 82, 82, 0.8)); opacity: 1; transform: scale(1.05); }
}
.jan-character-viewer-view .action-block.is-proxy-off .shield-icon-container svg {
width: 15px;
height: 15px;
}
.jan-character-viewer-view .action-block.is-proxy-off .proxy-text {
color: #fff;
font-size: 0.75rem;
font-weight: 500;
letter-spacing: 0.06em;
text-shadow: 0 1px 2px rgba(0,0,0,1);
}
`
});
}
function escapeHtmlText(value) {
if (value == null) return "";
return String(value).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
}
function escapeHtmlAttr(value) {
if (value == null) return "";
return String(value).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
}
function formatRelativeTime(timestampMs, { emptyLabel = null, prefix = "", justNowLabel = "just now" } = {}) {
if (!timestampMs) return emptyLabel;
const elapsed = Date.now() - timestampMs;
if (!Number.isFinite(elapsed)) return emptyLabel;
const prefixText = prefix ? `${prefix} ` : "";
const seconds = Math.floor(elapsed / 1e3);
if (seconds < 10) return `${prefixText}${justNowLabel}`;
if (seconds < 60) return `${prefixText}${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${prefixText}${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${prefixText}${hours}h ago`;
const days = Math.floor(hours / 24);
return `${prefixText}${days}d ago`;
}
const VIEW_TABS = [
{ key: "description", label: "Description" },
{ key: "first-message", label: "First Message" },
{ key: "scenario", label: "Scenario" },
{ key: "example-dialogs", label: "Example Dialogs" },
{ key: "creator-notes", label: "Creator Notes" }
];
function formatStatNumber(n) {
if (n == null) return "—";
if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
if (n >= 1e3) return `${(n / 1e3).toFixed(1)}K`;
return String(n);
}
function formatDate(isoString) {
if (!isoString) return "—";
try {
return new Date(isoString).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric"
});
} catch {
return "—";
}
}
function formatFetchedTime(fetchedAt) {
return formatRelativeTime(fetchedAt);
}
function calcTokenWidth(tokens, total) {
if (!total || !tokens) return "0%";
return `${(tokens / total * 100).toFixed(2)}%`;
}
function hasMeaningfulText(text) {
return typeof text === "string" && stripJanitorAttribution(text).trim().length > 0;
}
function normalizeCreatorNotesText(html) {
if (typeof html !== "string") return "";
return html.replace(/<!--[\s\S]*?-->/g, "").replace(/<br\s*\/?>/gi, "\n").replace(/<\/(p|div|li|h[1-6])>/gi, "\n").replace(/<[^>]+>/g, "").replace(/ | /gi, " ").replace(/&/gi, "&").replace(/</gi, "<").replace(/>/gi, ">").replace(/"/gi, '"').replace(/'|'/gi, "'").replace(/\r\n/g, "\n").trim();
}
function getTokenRow(character, key) {
return character?.tokenRows?.find((row) => row.key === key) ?? null;
}
function getDefinitionFieldText(value) {
return typeof value === "string" ? stripJanitorAttribution(value) : null;
}
function mapApiResponseToCharacter(data) {
const characterId = data?.id != null ? String(data.id) : "";
const creatorId = data?.creator_id != null ? String(data.creator_id) : "";
const avatarFilename = typeof data?.avatar === "string" ? data.avatar : "";
const total = data?.token_counts?.total_tokens ?? 0;
const hasDefinition = data?.showdefinition === true;
const personalityTokens = data?.token_counts?.personality_tokens ?? 0;
const firstMsgTokens = data?.token_counts?.first_message_tokens ?? 0;
const scenarioTokens = data?.token_counts?.scenario_tokens ?? 0;
const dialogTokens = data?.token_counts?.example_dialog_tokens ?? 0;
const tags = [];
if (data?.is_nsfw) tags.push({ label: "NSFW", variant: "nsfw" });
for (const t of data?.custom_tags ?? []) tags.push({ label: t, variant: "custom" });
for (const t of data?.tags ?? []) tags.push({ label: t.name, variant: "default" });
const rawName = (() => {
const name = typeof data?.name === "string" ? data.name.trim() : "";
if (name) return data.name;
const chatName2 = typeof data?.chat_name === "string" ? data.chat_name.trim() : "";
if (chatName2) return data.chat_name;
return "Unknown";
})();
const chatName = typeof data?.chat_name === "string" && data.chat_name.trim().length > 0 ? data.chat_name : null;
const creatorName = typeof data?.creator_name === "string" && data.creator_name.trim().length > 0 ? data.creator_name : "Unknown";
const rawMsgArray = Array.isArray(data?.first_messages) ? data.first_messages : data?.first_message != null ? [data.first_message] : [];
const seenMsgs = /* @__PURE__ */ new Set();
const uniqueFirstMessages = [];
let sawEmptyGreeting = false;
for (const msg of rawMsgArray) {
if (typeof msg !== "string" || seenMsgs.has(msg)) continue;
seenMsgs.add(msg);
const stripped = stripJanitorAttribution(msg);
if (stripped.trim().length === 0) {
if (!sawEmptyGreeting) {
uniqueFirstMessages.push("");
sawEmptyGreeting = true;
}
continue;
}
uniqueFirstMessages.push(stripped);
}
return {
id: data?.id ?? null,
name: rawName,
chatName,
creator: creatorName,
creatorVerified: data?.creator_verified ?? false,
creatorUrl: buildProfileUrl(creatorId) ?? "",
characterUrl: buildCharacterUrl(characterId) ?? "",
avatarUrl: buildAvatarUrl(avatarFilename, { width: 1200 }) ?? "",
avatarDownloadUrl: buildAvatarUrl(avatarFilename) ?? "",
fileName: rawName.replace(/[^a-zA-Z0-9_\-]/g, "_"),
isNsfw: Boolean(data.is_nsfw),
chats: formatStatNumber(data.stats?.chat),
chatsRaw: data?.stats?.chat ?? 0,
messages: formatStatNumber(data.stats?.message),
messagesRaw: data?.stats?.message ?? 0,
totalTokens: total > 0 ? total.toLocaleString() : "—",
totalTokensRaw: total,
publishedLabel: formatDate(data.first_published_at),
publishedValue: typeof data?.first_published_at === "string" ? data.first_published_at.split("T")[0] : "",
updatedLabel: formatDate(data.updated_at),
updatedValue: typeof data?.updated_at === "string" ? data.updated_at.split("T")[0] : "",
tags,
tokenRows: [
{
key: "personality",
label: "Personality",
value: personalityTokens ? personalityTokens.toLocaleString() : "—",
width: calcTokenWidth(personalityTokens, total),
zero: !personalityTokens
},
{
key: "first-message",
label: "First Message",
value: firstMsgTokens ? firstMsgTokens.toLocaleString() : "—",
width: calcTokenWidth(firstMsgTokens, total),
zero: !firstMsgTokens
},
{
key: "scenario",
label: "Scenario",
value: scenarioTokens ? scenarioTokens.toLocaleString() : "—",
width: calcTokenWidth(scenarioTokens, total),
zero: !scenarioTokens
},
{
key: "example-dialogs",
label: "Example Dialogs",
value: dialogTokens ? dialogTokens.toLocaleString() : "—",
width: calcTokenWidth(dialogTokens, total),
zero: !dialogTokens
}
],
hasDefinition,
descriptionText: getDefinitionFieldText(data?.personality),
firstMessages: uniqueFirstMessages,
firstMessageText: uniqueFirstMessages[0] ?? null,
scenarioText: getDefinitionFieldText(data?.scenario),
exampleDialogsText: getDefinitionFieldText(data?.example_dialogs),
creatorNotesHtml: typeof data?.description === "string" ? data.description : "",
allowProxy: typeof data?.allow_proxy === "boolean" ? data.allow_proxy : null
};
}
function createTagMarkup(tag) {
const classNames = ["tag-pill"];
if (tag.variant === "nsfw") {
classNames.push("tag-nsfw");
}
if (tag.variant === "custom") {
classNames.push("tag-custom");
}
return `<span class="${classNames.join(" ")}">${escapeHtmlText(tag.label)}</span>`;
}
function createTokenSegmentsMarkup(tokenRows) {
return tokenRows.filter((row) => row.width !== "0%").map((row) => {
const className = row.key === "personality" ? "seg-personality" : row.key === "first-message" ? "seg-firstmsg" : row.key === "scenario" ? "seg-scenario" : "seg-dialogs";
return `<div class="token-seg ${className}" style="width: ${row.width}"></div>`;
}).join("");
}
function createTokenLegendMarkup(tokenRows) {
return tokenRows.map((row) => {
const dotClassName = row.key === "personality" ? "seg-personality" : row.key === "first-message" ? "seg-firstmsg" : row.key === "scenario" ? "seg-scenario" : "seg-dialogs";
return `
<div class="legend-row${row.zero ? " zero" : ""}">
<span class="legend-dot ${dotClassName}"></span>
<span class="legend-key">${row.label}</span>
<span class="legend-val">${row.value}</span>
</div>
`;
}).join("");
}
function createNoCharacterMarkup() {
return `
<div class="cv-no-character">
${NO_CHARACTER_ICON_SVG}
<div class="cv-no-character-title">No character loaded</div>
<div class="cv-load-form" id="cvLoadForm">
<input
class="cv-id-input"
id="cvIdInput"
type="text"
placeholder="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
autocomplete="off"
spellcheck="false"
/>
<button class="cv-enter-btn" id="cvEnterBtn" type="button">
<span>Enter</span>
${CORNER_DOWN_LEFT_SVG}
</button>
</div>
<div class="cv-load-error" id="cvLoadError"></div>
<div class="cv-info-row">
${INFO_ICON_SVG}
<span>Accepts a character URL or UUID</span>
</div>
</div>
`;
}
function buildDefinitionHiddenPane(paneId, isActive = false) {
return `
<div class="tab-pane${isActive ? " active" : ""}" id="${paneId}">
<div class="empty-state">
${DEFINITION_HIDDEN_EMPTY_ICON_SVG}
<div class="empty-state-text">Definition is not publicly visible for this character</div>
</div>
</div>
`;
}
function animateGreetingSlide(pre, newText, direction) {
const outName = direction > 0 ? "jan-greet-out-left" : "jan-greet-out-right";
const inName = direction > 0 ? "jan-greet-in-right" : "jan-greet-in-left";
const dur = 170;
pre.style.animation = `${outName} ${dur}ms ease forwards`;
setTimeout(() => {
pre.innerHTML = escapeHtmlText(newText);
pre.style.animation = `${inName} ${dur}ms ease forwards`;
pre.addEventListener("animationend", () => {
pre.style.animation = "";
}, { once: true });
}, dur);
}
function buildTextFieldPane(paneId, copyTargetId, text, isActive = false) {
return `
<div class="tab-pane${isActive ? " active" : ""}" id="${paneId}">
<div class="field-wrapper">
<div class="field-header">
<button class="copy-btn" type="button" data-copy-target="${copyTargetId}">
${COPY_IDLE_SVG}<span>Copy</span>
</button>
</div>
<pre class="field-text" id="${copyTargetId}">${escapeHtmlText(text)}</pre>
</div>
</div>
`;
}
function buildEmptyPane(paneId, iconSvg, message, isActive = false) {
return `
<div class="tab-pane${isActive ? " active" : ""}" id="${paneId}">
<div class="empty-state">
${iconSvg}
<div class="empty-state-text">${message}</div>
</div>
</div>
`;
}
function buildDefinitionFieldPane({
paneId,
copyTargetId,
text,
tokenRow,
emptyIcon,
emptyMessage,
isActive = false
}) {
if (text !== null) {
return hasMeaningfulText(text) ? buildTextFieldPane(paneId, copyTargetId, text, isActive) : buildEmptyPane(paneId, emptyIcon, emptyMessage, isActive);
}
if (tokenRow?.zero) {
return buildEmptyPane(paneId, emptyIcon, emptyMessage, isActive);
}
return buildDefinitionHiddenPane(paneId, isActive);
}
function buildFirstMessageCarouselPane(messages, isActive = false) {
const total = messages.length;
const showDots = total <= 8;
const dotsMarkup = showDots ? messages.map((_, i2) => `<span class="greeting-dot${i2 === 0 ? " active" : ""}" data-greeting-idx="${i2}"></span>`).join("") : "";
return `
<div class="tab-pane${isActive ? " active" : ""}" id="tab-first-message">
<div class="field-wrapper">
<div class="field-header greeting-field-header">
<button class="copy-btn greeting-copy-btn" type="button">
${COPY_IDLE_SVG}<span>Copy</span>
</button>
<div class="greeting-nav">
<button class="greeting-nav-btn" id="greetingPrev" type="button" disabled>
${CHEVRON_LEFT_SVG}
</button>
<span class="greeting-counter" id="greetingCounter">1 / ${total}</span>
<button class="greeting-nav-btn" id="greetingNext" type="button">
${CHEVRON_RIGHT_SVG}
</button>
</div>
</div>
<div class="greeting-text-wrap" id="greetingTextWrap">
<pre class="field-text" id="firstMesText">${escapeHtmlText(messages[0])}</pre>
</div>
${showDots ? `<div class="greeting-dots" id="greetingDots">${dotsMarkup}</div>` : ""}
</div>
</div>
`;
}
function createTabButtonsMarkup(character) {
const metaMap = character ? {
"description": character.tokenRows.find((r) => r.key === "personality"),
"first-message": character.tokenRows.find((r) => r.key === "first-message"),
"scenario": character.tokenRows.find((r) => r.key === "scenario"),
"example-dialogs": character.tokenRows.find((r) => r.key === "example-dialogs")
} : {};
return VIEW_TABS.map((tab, index) => {
const activeClass = index === 0 ? " active" : "";
const rowData = metaMap[tab.key];
const metaMarkup = rowData ? rowData.zero ? `<span class="tab-dash">—</span>` : `<span class="tab-count">${rowData.value}</span>` : "";
return `
<button class="tab-btn${activeClass}" data-tab="${tab.key}">
${tab.label}
${metaMarkup}
</button>
`;
}).join("");
}
function createCharacterViewerMarkup(character) {
const descriptionTokenRow = getTokenRow(character, "personality");
const firstMessageTokenRow = getTokenRow(character, "first-message");
const scenarioTokenRow = getTokenRow(character, "scenario");
const exampleDialogsTokenRow = getTokenRow(character, "example-dialogs");
const definitionRow = character.hasDefinition ? `<div class="meta-kv">
<span class="meta-key">Definition</span>
<span style="display:flex;align-items:center;gap:4px;color:rgba(255,255,255,0.45)">
${DEFINITION_PUBLIC_ICON_SVG}
<span style="font-size:0.82rem">Public</span>
</span>
</div>` : `<div class="meta-kv">
<span class="meta-key">Definition</span>
<span style="display:flex;align-items:center;gap:4px;color:rgba(255,255,255,0.28)" title="Character definition is hidden">
${DEFINITION_HIDDEN_ICON_SVG}
<span style="font-size:0.82rem">Hidden</span>
</span>
</div>`;
const descPane = buildDefinitionFieldPane({
paneId: "tab-description",
copyTargetId: "descriptionText",
text: character.descriptionText,
tokenRow: descriptionTokenRow,
emptyIcon: DESCRIPTION_EMPTY_ICON_SVG,
emptyMessage: "No description defined",
isActive: true
});
const hasExplicitEmptyFirstMessage = character.firstMessages.length === 1 && character.firstMessages[0] === "";
const firstMsgPane = character.firstMessages.length > 1 ? buildFirstMessageCarouselPane(character.firstMessages) : hasExplicitEmptyFirstMessage ? buildTextFieldPane("tab-first-message", "firstMesText", "") : buildDefinitionFieldPane({
paneId: "tab-first-message",
copyTargetId: "firstMesText",
text: character.firstMessageText,
tokenRow: firstMessageTokenRow,
emptyIcon: FIRST_MESSAGE_EMPTY_ICON_SVG,
emptyMessage: "No first message defined"
});
const scenarioPane = buildDefinitionFieldPane({
paneId: "tab-scenario",
copyTargetId: "scenarioText",
text: character.scenarioText,
tokenRow: scenarioTokenRow,
emptyIcon: SCENARIO_EMPTY_ICON_SVG,
emptyMessage: "No scenario defined for this character"
});
const exDialogsPane = buildDefinitionFieldPane({
paneId: "tab-example-dialogs",
copyTargetId: "exDialogsText",
text: character.exampleDialogsText,
tokenRow: exampleDialogsTokenRow,
emptyIcon: EXAMPLE_DIALOGS_EMPTY_ICON_SVG,
emptyMessage: "No example dialogs defined for this character"
});
const creatorNotesText = normalizeCreatorNotesText(character.creatorNotesHtml);
const creatorNotesPane = creatorNotesText ? `<div class="tab-pane" id="tab-creator-notes"><div class="html-display">${escapeHtmlText(creatorNotesText).replace(/\n/g, "<br>")}</div></div>` : buildEmptyPane("tab-creator-notes", CREATOR_NOTES_EMPTY_ICON_SVG, "No creator notes");
return `
<div class="main-content">
<div class="meta-sidebar">
<div class="avatar-section" id="avatarSection" style="height:${CHARACTER_VIEWER_AVATAR_COLLAPSED_HEIGHT}px">
<img class="character-avatar" id="avatarImg"
src="${escapeHtmlAttr(character.avatarUrl ?? "")}"
alt="${escapeHtmlAttr(character.name)}" style="height:100%;object-fit:cover;object-position:center top">
<div class="avatar-gradient"></div>
<button class="avatar-maximize-btn" id="avatarMaxBtn" title="View full image" type="button">
${MAXIMIZE_ICON_SVG}
</button>
<div class="avatar-expand-toggle" id="avatarExpandToggle">
<span id="expandIconDown" style="line-height:0">${EXPAND_DOWN_ICON_SVG}</span>
<span id="expandIconUp" style="display:none;line-height:0">${EXPAND_UP_ICON_SVG}</span>
<span id="expandToggleText">Expand</span>
</div>
</div>
<div class="meta-body">
<div class="meta-section">
<div class="meta-kv">
<span class="meta-key${character.chatName && character.chatName !== character.name ? " meta-key-toggle" : ""}" id="nameKeyLabel">Name</span>
<a href="${escapeHtmlAttr(character.characterUrl ?? "")}"
target="_blank" class="meta-link"
title="${escapeHtmlAttr(String(character.id ?? ""))}" id="nameMetaValue">${escapeHtmlText(character.name)}</a>
</div>
<div class="meta-kv">
<span class="meta-key">Creator</span>
<a href="${escapeHtmlAttr(character.creatorUrl ?? "")}"
target="_blank" class="meta-link">
${escapeHtmlText(character.creator)}
${character.creatorVerified ? `<span class="verified-icon">${VERIFIED_ICON_SVG}</span>` : ""}
</a>
</div>
${definitionRow}
</div>
<div class="meta-section">
<div class="section-title">TAGS</div>
<div class="tag-list">
${character.tags.map(createTagMarkup).join("")}
</div>
</div>
<div class="meta-section">
<div class="section-title">STATS</div>
<div class="stats-grid">
<div class="stat-block">
<span class="stat-val">${character.chats}</span>
<span class="stat-lbl">Chats</span>
</div>
<div class="stat-block">
<span class="stat-val">${character.messages}</span>
<span class="stat-lbl">Messages</span>
</div>
</div>
</div>
<div class="meta-section">
<div class="section-title">
<span>TOKENS</span>
<span class="section-title-right">${character.totalTokens}</span>
</div>
<div class="token-bar">
${createTokenSegmentsMarkup(character.tokenRows)}
</div>
<div class="token-legend">
${createTokenLegendMarkup(character.tokenRows)}
</div>
</div>
<div class="meta-section">
<div class="section-title">DATES</div>
<div class="meta-kv">
<span class="meta-key">Published</span>
<span class="meta-value">${character.publishedLabel}</span>
</div>
<div class="meta-kv">
<span class="meta-key">Updated</span>
<span class="meta-value">${character.updatedLabel}</span>
</div>
${character.fetchedAt ? `
<div class="meta-kv">
<span class="meta-key cv-fetched-key">Fetched <button class="cv-refresh-btn" id="cvRefreshBtn" type="button" title="Refetch character data">${REFRESH_ICON_SVG}</button></span>
<span class="meta-value" id="cvFetchedTime">${formatFetchedTime(character.fetchedAt)}</span>
</div>` : ""}
</div>
</div>
<div class="sidebar-footer">
<div class="action-block${character.allowProxy === false ? " is-proxy-off" : ""}">
<div class="format-picker-group" id="formatPickerGroup">
<label class="format-option">
<input type="radio" name="dlFormat" value="png" checked>
<span class="format-label">
${FORMAT_PNG_ICON_SVG}
<span class="fmt-text">PNG</span>
</span>
</label>
<label class="format-option">
<input type="radio" name="dlFormat" value="json">
<span class="format-label">
${FORMAT_JSON_ICON_SVG}
<span class="fmt-text">JSON</span>
</span>
</label>
<label class="format-option">
<input type="radio" name="dlFormat" value="txt">
<span class="format-label">
${FORMAT_TXT_ICON_SVG}
<span class="fmt-text">TXT</span>
</span>
</label>
</div>
<div class="dl-core">
<button class="btn-download" id="downloadBtn" type="button">
${DOWNLOAD_ICON_SVG}
Download
</button>
</div>
<div class="physics-container">
<div class="physics-mask">
<div class="physics-text" id="dlPhysicsText"></div>
</div>
</div>
<div class="proxy-status-overlay" aria-hidden="true">
<div class="shield-icon-container">
${SHIELD_ICON_SVG}
</div>
<span class="proxy-text">PROXY DISABLED</span>
</div>
</div>
</div>
</div>
<div class="content-section">
<div class="tab-bar" style="user-select: none; -webkit-user-select: none;">
${createTabButtonsMarkup(character)}
<button class="unload-btn" id="unloadBtn" type="button">
${LOG_OUT_ICON_SVG}
</button>
</div>
<div class="tab-content-wrap">
${descPane}
${firstMsgPane}
${scenarioPane}
${exDialogsPane}
${creatorNotesPane}
</div>
</div>
</div>
`;
}
function createLightboxMarkup(character) {
return `
<button class="lightbox-close" id="lightboxClose" title="Close" type="button">
${LIGHTBOX_CLOSE_ICON_SVG}
</button>
<img class="lightbox-img"
src="${escapeHtmlAttr(character.avatarUrl ?? "")}"
alt="${escapeHtmlAttr(character.name)}">
`;
}
class LiquidSyncOdometer {
constructor(container, documentRef = globalThis.document) {
this.container = container;
this.docRef = documentRef;
this.slots = [];
}
setText(text, options = false) {
if (!this.container || !this.docRef) return;
const isSuccess = typeof options === "object" ? options?.success : options;
const isError = typeof options === "object" ? options?.error : false;
const value = typeof text === "string" ? text : "";
this.container.style.color = isError ? "#AF4C50" : isSuccess ? "#4CAF50" : "#c5d6e8";
const isInitial = this.slots.length === 0;
while (this.slots.length < value.length) {
const slot = this.docRef.createElement("div");
slot.className = isInitial ? "char-slot" : "char-slot collapsed";
const col = this.docRef.createElement("div");
col.className = "char-column";
const blank = this.docRef.createElement("span");
blank.textContent = " ";
col.appendChild(blank);
slot.appendChild(col);
this.container.appendChild(slot);
this.slots.push({
slotEl: slot,
colEl: col,
targetChar: " ",
active: isInitial
});
}
for (let i2 = 0; i2 < this.slots.length; i2++) {
const state2 = this.slots[i2];
const isActive = i2 < value.length;
const char = isActive ? value[i2] : " ";
if (isActive && !state2.active) {
state2.slotEl.classList.remove("collapsed");
state2.active = true;
} else if (!isActive && state2.active) {
state2.slotEl.classList.add("collapsed");
state2.active = false;
}
if (state2.targetChar === char) continue;
state2.targetChar = char;
const span = this.docRef.createElement("span");
span.textContent = char === " " ? " " : char;
state2.colEl.appendChild(span);
const targetIndex = state2.colEl.children.length - 1;
void state2.colEl.offsetWidth;
state2.colEl.style.transition = "transform 0.42s cubic-bezier(0.16, 1, 0.3, 1)";
state2.colEl.style.transform = `translateY(-${targetIndex * 1.5}em)`;
}
}
async clear() {
if (!this.container) return;
const promises = [];
for (let i2 = 0; i2 < this.slots.length; i2++) {
const state2 = this.slots[i2];
if (!state2.active) continue;
state2.targetChar = " ";
state2.active = false;
promises.push(
new Promise((res) => {
const delay = Math.random() * 0.1;
state2.colEl.style.transition = `transform 0.3s cubic-bezier(0.55, 0.085, 0.68, 0.53) ${delay}s, opacity 0.3s ${delay}s`;
state2.colEl.style.transform += " translateY(1.8em)";
state2.colEl.style.opacity = "0";
globalThis.setTimeout(() => {
state2.slotEl.classList.add("collapsed");
res();
}, 300 + delay * 1e3);
})
);
}
await Promise.all(promises);
this.container.textContent = "";
this.slots = [];
}
}
function buildDownloadBlob$1(downloadArtifact) {
const content = downloadArtifact?.content;
const mimeType = typeof downloadArtifact?.mimeType === "string" && downloadArtifact.mimeType.trim() ? downloadArtifact.mimeType : "application/octet-stream";
if (typeof content !== "string" && !(content instanceof Uint8Array) && !(content instanceof ArrayBuffer) && !ArrayBuffer.isView(content)) {
throw new Error("Unsupported download artifact content.");
}
return new Blob([content], { type: mimeType });
}
function downloadBlob({
blob,
filename,
documentRef = globalThis.document,
mountEl = null
}) {
if (!blob) {
throw new Error("Download blob is required.");
}
if (!filename) {
throw new Error("Filename is required to trigger download.");
}
if (!documentRef?.createElement) {
throw new Error("Document reference is required to trigger download.");
}
const url = globalThis.URL.createObjectURL(blob);
const link = documentRef.createElement("a");
try {
link.href = url;
link.download = filename;
(documentRef.body ?? mountEl)?.appendChild(link);
link.click();
} finally {
link.remove();
globalThis.URL.revokeObjectURL(url);
}
}
function triggerArtifactDownload$1({
downloadArtifact,
documentRef = globalThis.document,
mountEl = null
}) {
if (!downloadArtifact?.filename) {
throw new Error("Download artifact is missing a filename.");
}
const blob = buildDownloadBlob$1(downloadArtifact);
downloadBlob({
blob,
filename: downloadArtifact.filename,
documentRef,
mountEl
});
}
const SVG_CACHE = /* @__PURE__ */ new Map();
function resolveDocument$1(documentRef) {
return documentRef ?? globalThis.document ?? null;
}
function createParser(documentRef) {
const win = documentRef?.defaultView ?? globalThis;
const Parser = win?.DOMParser ?? globalThis.DOMParser;
if (typeof Parser !== "function") return null;
return new Parser();
}
function parseSvg(svgString, documentRef) {
const parser = createParser(documentRef);
if (!parser) return null;
const parsed = parser.parseFromString(svgString, "image/svg+xml");
const svg = parsed?.documentElement ?? null;
if (!svg || svg.nodeName?.toLowerCase?.() !== "svg") return null;
return svg;
}
function createSvgNode(svgString, documentRef = globalThis.document) {
if (typeof svgString !== "string" || !svgString) return null;
const doc = resolveDocument$1(documentRef);
if (!doc) return null;
let cached = SVG_CACHE.get(svgString);
if (!cached) {
cached = parseSvg(svgString, doc);
if (!cached) return null;
SVG_CACHE.set(svgString, cached);
}
return doc.importNode(cached, true);
}
function appendSvg(target, svgString, documentRef) {
if (!target) return null;
const doc = resolveDocument$1(documentRef ?? target.ownerDocument);
const svgNode = createSvgNode(svgString, doc);
if (svgNode) {
target.appendChild(svgNode);
}
return svgNode;
}
function replaceSvgContent(target, svgString, documentRef) {
if (!target) return null;
const doc = resolveDocument$1(documentRef ?? target.ownerDocument);
const svgNode = createSvgNode(svgString, doc);
target.replaceChildren();
if (svgNode) {
target.appendChild(svgNode);
}
return svgNode;
}
const CHARACTER_VIEWER_CONTROLLER_KEY = "__janCharacterViewerController__";
const CV_FORMAT_STORAGE_KEY = "jan-cv-format";
const CV_CHARACTER_STORAGE_KEY = "jan-cv-loaded-character";
const CV_NAME_TOGGLE_STORAGE_KEY = "jan-cv-name-toggle";
const CV_GREETING_INDEX_STORAGE_KEY = "jan-cv-greeting-index";
const createSharedState = () => ({
scroll: {
tabScrolls: {},
activeTab: null,
metaBodyScroll: 0
},
download: {
active: false,
label: "",
success: false
},
downloadUi: null
});
const sharedStateByDocument = /* @__PURE__ */ new WeakMap();
const documentListenerControllers = /* @__PURE__ */ new WeakMap();
function resolveDocument(documentRef) {
return documentRef?.defaultView?.document ?? documentRef ?? globalThis.document ?? null;
}
function getSharedState(documentRef) {
const doc = resolveDocument(documentRef);
if (!doc || typeof doc !== "object") return createSharedState();
let state2 = sharedStateByDocument.get(doc);
if (!state2) {
state2 = createSharedState();
sharedStateByDocument.set(doc, state2);
}
return state2;
}
function createDocumentListenerController(documentRef) {
const doc = resolveDocument(documentRef);
if (!doc || typeof doc !== "object") return new AbortController();
const existing = documentListenerControllers.get(doc);
if (existing) existing.abort();
const controller = new AbortController();
documentListenerControllers.set(doc, controller);
return controller;
}
function getGreetingIndexStorageKey(characterId) {
return characterId ? `${CV_GREETING_INDEX_STORAGE_KEY}:${characterId}` : null;
}
function randomizeCustomTagColors(view) {
const tags = view.querySelectorAll(".tag-custom");
const base = Math.floor(Math.random() * 360);
tags.forEach((tag, index) => {
const hue = (base + index * 137.508) % 360;
tag.style.background = `hsla(${hue}, 52%, 54%, 0.13)`;
tag.style.borderColor = `hsla(${hue}, 55%, 65%, 0.26)`;
tag.style.color = `hsla(${hue}, 70%, 78%, 0.92)`;
});
}
function setCopyButtonState(button, copied) {
if (!button) return;
button.classList.toggle("copied", copied);
button.replaceChildren();
const doc = button.ownerDocument ?? globalThis.document;
const iconSvg = copied ? COPY_SUCCESS_SVG : COPY_IDLE_SVG;
const icon = createSvgNode(iconSvg, doc);
if (icon) {
button.appendChild(icon);
}
const label = doc?.createElement?.("span");
if (label) {
label.textContent = copied ? "Copied!" : "Copy";
button.appendChild(label);
}
}
function setupTabSwitching(view) {
view.querySelectorAll(".tab-btn").forEach((button) => {
button.addEventListener("click", () => {
view.querySelectorAll(".tab-btn").forEach((tab) => tab.classList.remove("active"));
view.querySelectorAll(".tab-pane").forEach((pane) => pane.classList.remove("active"));
button.classList.add("active");
view.querySelector(`#tab-${button.dataset.tab}`)?.classList.add("active");
});
});
}
function setupCopyButtons(view) {
view.querySelectorAll("[data-copy-target]").forEach((button) => {
button.addEventListener("click", async () => {
const targetId = button.getAttribute("data-copy-target");
const text = targetId ? view.querySelector(`#${targetId}`)?.textContent ?? "" : "";
if (button._timer) {
clearTimeout(button._timer);
}
try {
await globalThis.navigator?.clipboard?.writeText(text);
setCopyButtonState(button, true);
button._timer = globalThis.setTimeout(() => {
setCopyButtonState(button, false);
button._timer = null;
}, 2e3);
} catch {
}
});
});
}
function setupScrollContainment(view) {
const stopScrollChaining = (container) => {
if (!container) return;
container.addEventListener("wheel", (event) => {
if (event.ctrlKey) return;
if (!event.deltaY) return;
const maxScrollTop = container.scrollHeight - container.clientHeight;
if (maxScrollTop <= 0) {
event.preventDefault();
event.stopPropagation();
return;
}
const atTop = container.scrollTop <= 0;
const atBottom = container.scrollTop >= maxScrollTop - 1;
if (event.deltaY < 0 && atTop || event.deltaY > 0 && atBottom) {
event.preventDefault();
event.stopPropagation();
}
}, { passive: false });
};
view.querySelectorAll(".meta-body, .tab-pane").forEach(stopScrollChaining);
const metaSidebar = view.querySelector(".meta-sidebar");
const metaBody = view.querySelector(".meta-body");
const contentSection = view.querySelector(".content-section");
if (!metaSidebar || !metaBody) return;
metaSidebar.addEventListener("wheel", (event) => {
if (event.ctrlKey) return;
if (!event.deltaY || metaBody.contains(event.target)) return;
event.preventDefault();
event.stopPropagation();
}, { passive: false });
contentSection?.addEventListener("wheel", (event) => {
if (event.ctrlKey) return;
const activeTabPane = view.querySelector(".tab-pane.active");
if (!event.deltaY || activeTabPane?.contains(event.target)) return;
event.preventDefault();
event.stopPropagation();
}, { passive: false });
}
function setupAvatarAndLightbox({ view, lightbox }) {
const avatarSection = view.querySelector("#avatarSection");
const avatarImg = view.querySelector("#avatarImg");
const avatarMaxBtn = view.querySelector("#avatarMaxBtn");
const expandToggle = view.querySelector("#avatarExpandToggle");
const expandIconDown = view.querySelector("#expandIconDown");
const expandIconUp = view.querySelector("#expandIconUp");
const expandText = view.querySelector("#expandToggleText");
const lightboxClose = lightbox.querySelector("#lightboxClose");
if (!avatarSection || !avatarImg || !avatarMaxBtn || !expandToggle || !expandIconDown || !expandIconUp || !expandText || !lightboxClose) {
return;
}
let avatarExpanded = false;
function setExpand(expand) {
avatarExpanded = expand;
if (expand) {
const width = avatarImg.naturalWidth || CHARACTER_VIEWER_AVATAR_WIDTH;
const height = avatarImg.naturalHeight || 408;
const targetHeight = Math.min(
Math.round(CHARACTER_VIEWER_AVATAR_WIDTH * height / width),
CHARACTER_VIEWER_AVATAR_MAX_HEIGHT
);
avatarSection.style.height = `${targetHeight}px`;
avatarImg.style.height = "auto";
avatarImg.style.objectFit = "contain";
avatarImg.style.objectPosition = "center center";
expandIconDown.style.display = "none";
expandIconUp.style.display = "inline";
expandText.textContent = "Collapse";
return;
}
avatarSection.style.height = `${CHARACTER_VIEWER_AVATAR_COLLAPSED_HEIGHT}px`;
avatarImg.style.height = "100%";
avatarImg.style.objectFit = "cover";
avatarImg.style.objectPosition = "center top";
expandIconDown.style.display = "inline";
expandIconUp.style.display = "none";
expandText.textContent = "Expand";
}
function openLightbox() {
lightbox.classList.add("active");
lightbox.setAttribute("aria-hidden", "false");
lightbox.focus();
}
function closeLightbox() {
lightbox.classList.remove("active");
lightbox.setAttribute("aria-hidden", "true");
}
expandToggle.addEventListener("click", (event) => {
event.stopPropagation();
setExpand(!avatarExpanded);
});
avatarMaxBtn.addEventListener("click", (event) => {
event.stopPropagation();
openLightbox();
});
avatarSection.addEventListener("click", () => openLightbox());
lightbox.addEventListener("click", (event) => {
if (event.target === lightbox) closeLightbox();
});
lightbox.addEventListener("keydown", (event) => {
if (event.key === "Escape") closeLightbox();
});
lightboxClose.addEventListener("click", closeLightbox);
setExpand(false);
}
function setupDownload(view, documentRef, character, scrapeCharacterCard, sharedState) {
const downloadButton = view.querySelector("#downloadBtn");
const physicsText = view.querySelector("#dlPhysicsText");
const formatGroup = view.querySelector("#formatPickerGroup");
if (!downloadButton || !physicsText || typeof scrapeCharacterCard !== "function") return;
if (character.allowProxy === false) {
downloadButton.disabled = true;
downloadButton.setAttribute("tabindex", "-1");
return;
}
const MILESTONE_MAP = [
{ threshold: 0.08, label: "Loading character..." },
{ threshold: 0.18, label: "Opening session..." },
{ threshold: 0.35, label: "Fetching definition..." },
{ threshold: 0.62, label: "Extracting fields..." },
{ threshold: 0.72, label: "Closing session..." },
{ threshold: 0.88, label: "Building card..." },
{ threshold: 0.9, label: "Saving file..." }
];
const odometer = new LiquidSyncOdometer(physicsText, documentRef);
const downloadState = sharedState?.download ?? createSharedState().download;
const ui = { odometer, downloadButton, formatGroup };
if (sharedState) sharedState.downloadUi = ui;
const getUi = () => sharedState?.downloadUi ?? ui;
const setLabel = (label, success = false) => {
downloadState.label = label;
downloadState.success = success;
getUi()?.odometer?.setText(label, success);
};
const setBusy = (busy) => {
const current = getUi();
if (!current?.downloadButton) return;
current.downloadButton.disabled = busy;
if (busy) current.downloadButton.setAttribute("aria-busy", "true");
else current.downloadButton.removeAttribute("aria-busy");
current.downloadButton.classList.toggle("dl-hidden", busy);
current.formatGroup?.classList.toggle("dl-hidden", busy);
};
if (downloadState.active || downloadState.label) {
setBusy(true);
if (downloadState.label) {
getUi()?.odometer?.setText(downloadState.label, downloadState.success);
}
}
downloadButton.addEventListener("click", async () => {
if (downloadState.active) return;
const exportFormat = view.querySelector('input[name="dlFormat"]:checked')?.value ?? "png";
downloadState.active = true;
downloadState.success = false;
setBusy(true);
await new Promise((r) => globalThis.setTimeout(r, 300));
setLabel(MILESTONE_MAP[0].label, false);
let lastMilestoneIndex = 0;
const onProgress = (progress) => {
for (let i2 = MILESTONE_MAP.length - 1; i2 >= 0; i2--) {
if (progress >= MILESTONE_MAP[i2].threshold && i2 > lastMilestoneIndex) {
lastMilestoneIndex = i2;
setLabel(MILESTONE_MAP[i2].label, false);
break;
}
}
};
try {
const result = await scrapeCharacterCard({
characterId: character.id,
exportFormat,
onProgress
});
if (!result?.downloadArtifact) {
throw new Error("Scrape/export pipeline did not return a download artifact.");
}
setLabel("Saving file...", false);
triggerArtifactDownload$1({
downloadArtifact: result.downloadArtifact,
documentRef,
mountEl: view
});
setLabel("Completed!", true);
await new Promise((r) => globalThis.setTimeout(r, 1600));
await getUi()?.odometer?.clear();
} catch (error) {
globalThis.console?.error?.(
"[Jan Character Viewer] Failed to export character card.",
error
);
getUi()?.odometer?.setText("Failed", { error: true });
await new Promise((r) => globalThis.setTimeout(r, 1600));
await getUi()?.odometer?.clear();
} finally {
downloadState.active = false;
downloadState.label = "";
downloadState.success = false;
setBusy(false);
}
});
}
function setupFirstMessageCarousel(view, character, documentRef) {
const messages = character?.firstMessages;
if (!messages || messages.length <= 1) return;
const pane = view.querySelector("#tab-first-message");
if (!pane) return;
const pre = pane.querySelector("#firstMesText");
const prevBtn = pane.querySelector("#greetingPrev");
const nextBtn = pane.querySelector("#greetingNext");
const counter = pane.querySelector("#greetingCounter");
const dotsCont = pane.querySelector("#greetingDots");
const copyBtn = pane.querySelector(".greeting-copy-btn");
const textWrap = pane.querySelector("#greetingTextWrap");
if (!pre || !prevBtn || !nextBtn || !counter) return;
const total = messages.length;
let currentIndex = 0;
const storage = getSessionStorage(documentRef);
const storageKey = getGreetingIndexStorageKey(character?.id);
if (storage && storageKey) {
const stored = readString(storage, storageKey, null);
const parsed = Number.parseInt(stored ?? "", 10);
if (Number.isFinite(parsed)) {
currentIndex = Math.max(0, Math.min(parsed, total - 1));
}
}
function persistIndex() {
if (!storage || !storageKey) return;
writeString(storage, storageKey, String(currentIndex));
}
function updateNav() {
counter.textContent = `${currentIndex + 1} / ${total}`;
prevBtn.disabled = currentIndex === 0;
nextBtn.disabled = currentIndex === total - 1;
if (dotsCont) {
dotsCont.querySelectorAll(".greeting-dot").forEach((dot, i2) => {
dot.classList.toggle("active", i2 === currentIndex);
});
}
}
function applyIndex() {
pre.textContent = messages[currentIndex] ?? "";
updateNav();
persistIndex();
}
function goTo(newIndex, direction) {
if (newIndex < 0 || newIndex >= total || newIndex === currentIndex) return;
const dir = direction ?? (newIndex > currentIndex ? 1 : -1);
currentIndex = newIndex;
updateNav();
animateGreetingSlide(pre, messages[currentIndex], dir);
persistIndex();
}
applyIndex();
prevBtn.addEventListener("click", () => goTo(currentIndex - 1, -1));
nextBtn.addEventListener("click", () => goTo(currentIndex + 1, 1));
if (dotsCont) {
dotsCont.querySelectorAll(".greeting-dot").forEach((dot, i2) => {
dot.addEventListener("click", () => goTo(i2));
});
}
if (copyBtn) {
let copyTimer = null;
copyBtn.addEventListener("click", async () => {
const text = pre.textContent ?? "";
if (copyTimer) clearTimeout(copyTimer);
try {
await globalThis.navigator?.clipboard?.writeText(text);
setCopyButtonState(copyBtn, true);
copyTimer = globalThis.setTimeout(() => {
setCopyButtonState(copyBtn, false);
copyTimer = null;
}, 2e3);
} catch {
}
});
}
if (textWrap) {
let touchStartX = 0;
let touchStartY = 0;
let touchStartTime = 0;
textWrap.addEventListener("touchstart", (e) => {
touchStartX = e.touches[0].clientX;
touchStartY = e.touches[0].clientY;
touchStartTime = Date.now();
}, { passive: true });
textWrap.addEventListener("touchend", (e) => {
const dx = e.changedTouches[0].clientX - touchStartX;
const dy = e.changedTouches[0].clientY - touchStartY;
const dt = Date.now() - touchStartTime;
if (Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > 40 && dt < 400) {
if (dx < 0) goTo(currentIndex + 1, 1);
else goTo(currentIndex - 1, -1);
}
}, { passive: true });
}
pane.addEventListener("keydown", (e) => {
if (e.key === "ArrowLeft") {
e.preventDefault();
goTo(currentIndex - 1, -1);
}
if (e.key === "ArrowRight") {
e.preventDefault();
goTo(currentIndex + 1, 1);
}
});
}
function setupUnloadButton(view, onUnload) {
const btn = view.querySelector("#unloadBtn");
if (!btn || typeof onUnload !== "function") return;
btn.addEventListener("click", onUnload);
}
function setupRefreshButton(view, onRefresh) {
const btn = view.querySelector("#cvRefreshBtn");
if (!btn || typeof onRefresh !== "function") return;
btn.addEventListener("click", (event) => {
event.stopPropagation();
onRefresh();
});
}
function setupNameToggle(view, character, documentRef) {
if (!character.chatName || character.chatName === character.name) return;
const keyLabel = view.querySelector("#nameKeyLabel");
const valueEl = view.querySelector("#nameMetaValue");
if (!keyLabel || !valueEl) return;
const storage = getSessionStorage(documentRef);
const storageKey = character?.id ? `${CV_NAME_TOGGLE_STORAGE_KEY}:${character.id}` : null;
let showingChatName = false;
const storedPreference = storage && storageKey ? readString(storage, storageKey, null) : null;
if (storedPreference === "chat") {
showingChatName = true;
} else if (storedPreference === "name") {
showingChatName = false;
} else {
const { nameDisplay } = readViewerSettings(documentRef);
showingChatName = nameDisplay === "chat";
}
function applyToggleState() {
if (showingChatName) {
keyLabel.textContent = "Chat Name";
keyLabel.classList.add("is-alt");
valueEl.textContent = character.chatName;
} else {
keyLabel.textContent = "Name";
keyLabel.classList.remove("is-alt");
valueEl.textContent = character.name;
}
}
applyToggleState();
keyLabel.addEventListener("click", () => {
showingChatName = !showingChatName;
applyToggleState();
if (storage && storageKey) {
writeString(storage, storageKey, showingChatName ? "chat" : "name");
}
});
}
function applyAvatarDisplaySetting(view, lightbox, character, documentRef) {
const { avatarMode } = readViewerSettings(documentRef);
const avatarSection = view.querySelector("#avatarSection");
if (!avatarSection) return false;
if (avatarMode === "off") {
avatarSection.remove();
lightbox?.remove();
return true;
}
if (avatarMode === "blur" && character.isNsfw) {
const avatarImg = view.querySelector("#avatarImg");
if (avatarImg) avatarImg.style.filter = "blur(16px)";
const lightboxImg = lightbox?.querySelector(".lightbox-img");
if (lightboxImg) lightboxImg.style.filter = "blur(22px)";
}
return false;
}
function setupFormatPickerPersistence(view, documentRef) {
const storage = getLocalStorage(documentRef);
if (!storage) return;
const saved = readString(storage, CV_FORMAT_STORAGE_KEY, null);
if (saved) {
const radio = view.querySelector(`input[name="dlFormat"][value="${saved}"]`);
if (radio) radio.checked = true;
}
view.querySelectorAll('input[name="dlFormat"]').forEach((radio) => {
radio.addEventListener("change", () => {
writeString(storage, CV_FORMAT_STORAGE_KEY, radio.value);
});
});
}
function loadCharacterIntoView(view, character, documentRef, backdropEl, onUnload, scrapeCharacterCard, onRefresh, sharedState) {
view.innerHTML = createCharacterViewerMarkup(character);
const lightboxHost = backdropEl ?? view;
lightboxHost.querySelector(".jan-character-viewer-lightbox")?.remove();
const lightbox = documentRef.createElement("div");
lightbox.className = "jan-character-viewer-lightbox lightbox-overlay";
lightbox.id = "lightboxOverlay";
lightbox.setAttribute("aria-hidden", "true");
lightbox.tabIndex = -1;
lightbox.innerHTML = createLightboxMarkup(character);
lightboxHost.appendChild(lightbox);
randomizeCustomTagColors(view);
setupTabSwitching(view);
setupCopyButtons(view);
setupScrollContainment(view);
const avatarHidden = applyAvatarDisplaySetting(
view,
lightbox,
character,
documentRef
);
if (!avatarHidden) {
setupAvatarAndLightbox({ view, lightbox });
}
setupDownload(view, documentRef, character, scrapeCharacterCard, sharedState);
setupFormatPickerPersistence(view, documentRef);
setupFirstMessageCarousel(view, character, documentRef);
setupUnloadButton(view, onUnload);
setupRefreshButton(view, onRefresh);
setupNameToggle(view, character, documentRef);
}
function getLoadFormRefs(view) {
return {
input: view.querySelector("#cvIdInput"),
btn: view.querySelector("#cvEnterBtn"),
form: view.querySelector("#cvLoadForm"),
errorEl: view.querySelector("#cvLoadError")
};
}
function setLoadFormError(view, message) {
const { form, errorEl } = getLoadFormRefs(view);
form?.classList.add("is-error");
if (errorEl) {
errorEl.textContent = message;
}
}
function clearLoadFormError(view) {
const { form, errorEl } = getLoadFormRefs(view);
form?.classList.remove("is-error");
if (errorEl) {
errorEl.textContent = "";
}
}
function setLoadFormLoading(view, loading) {
const { input, btn, form } = getLoadFormRefs(view);
form?.classList.toggle("is-loading", loading);
if (input) {
input.disabled = loading;
}
if (btn) {
btn.disabled = loading;
}
}
function setupLoadForm(view, loadCharacterFromInput) {
const input = view.querySelector("#cvIdInput");
const btn = view.querySelector("#cvEnterBtn");
if (!input || !btn) return;
const handleSubmit = () => loadCharacterFromInput(input.value);
btn.addEventListener("click", handleSubmit);
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
handleSubmit();
}
});
}
function createCharacterViewerTab({
documentRef = globalThis.document,
backdropEl = null,
janitorClient = null,
scrapeCharacterCard = null,
generateAlphaProxyConfig = null
} = {}) {
ensureCharacterViewerTabStyle(documentRef);
const view = documentRef.createElement("section");
view.className = "jan-panel-tab-view jan-character-viewer-view";
view.innerHTML = createNoCharacterMarkup();
const viewerClient = janitorClient ?? createJanitorClient({
documentRef
});
const baseScrapeCharacterCard = typeof scrapeCharacterCard === "function" ? scrapeCharacterCard : createInternalOrchestrator({ client: viewerClient }).scrapeCharacterCard;
const scrapeCharacterCardImpl = (options = {}) => baseScrapeCharacterCard({
...options,
generateAlphaProxyConfig
});
const sharedState = getSharedState(documentRef);
const documentController = createDocumentListenerController(documentRef);
const documentSignal = documentController.signal;
let activeLoadRequestId = 0;
let _cvFetchedAtTicker = null;
let _cvCurrentFetchedAt = null;
let _cvCurrentCharacterId = null;
documentSignal?.addEventListener?.("abort", () => {
if (_cvFetchedAtTicker != null) {
globalThis.clearInterval(_cvFetchedAtTicker);
_cvFetchedAtTicker = null;
}
}, { once: true });
function clearNameToggleForCharacter(characterId) {
if (!characterId) return;
removeStorageItem(
getSessionStorage(documentRef),
`${CV_NAME_TOGGLE_STORAGE_KEY}:${characterId}`
);
}
function clearGreetingIndexForCharacter(characterId) {
if (!characterId) return;
const storageKey = getGreetingIndexStorageKey(characterId);
if (!storageKey) return;
removeStorageItem(getSessionStorage(documentRef), storageKey);
}
function saveCharacterToSession(raw, character) {
writeJson(getSessionStorage(documentRef), CV_CHARACTER_STORAGE_KEY, { raw, character });
}
function loadCharacterFromSession() {
const parsed = readJson(getSessionStorage(documentRef), CV_CHARACTER_STORAGE_KEY, null);
if (!parsed?.character?.id) return null;
return parsed;
}
function clearCharacterFromSession() {
removeStorageItem(getSessionStorage(documentRef), CV_CHARACTER_STORAGE_KEY);
}
function setupCvScrollListeners() {
view.querySelector(".meta-body")?.addEventListener("scroll", (e) => {
sharedState.scroll.metaBodyScroll = e.currentTarget.scrollTop;
}, { passive: true });
view.querySelectorAll(".tab-pane").forEach((pane) => {
const tabName = pane.id.replace(/^tab-/, "");
pane.addEventListener("scroll", () => {
sharedState.scroll.activeTab = tabName;
sharedState.scroll.tabScrolls[tabName] = pane.scrollTop;
}, { passive: true });
});
view.querySelectorAll(".tab-btn").forEach((btn) => {
btn.addEventListener("click", () => {
const tab = btn.dataset.tab;
sharedState.scroll.activeTab = tab;
const pane = view.querySelector(`#tab-${tab}`);
if (pane && sharedState.scroll.tabScrolls[tab]) {
pane.scrollTop = sharedState.scroll.tabScrolls[tab];
}
});
});
}
if (!sharedState.scroll.activeTab) {
const initialTab = view.querySelector(".tab-btn.active")?.dataset?.tab ?? view.querySelector(".tab-pane.active")?.id?.replace(/^tab-/, "") ?? null;
if (initialTab) {
sharedState.scroll.activeTab = initialTab;
}
}
function clearCvScroll() {
Object.keys(sharedState.scroll.tabScrolls).forEach((k) => {
delete sharedState.scroll.tabScrolls[k];
});
sharedState.scroll.activeTab = null;
sharedState.scroll.metaBodyScroll = 0;
}
const unloadCharacterFn = () => {
activeLoadRequestId++;
if (_cvFetchedAtTicker != null) {
globalThis.clearInterval(_cvFetchedAtTicker);
_cvFetchedAtTicker = null;
}
_cvCurrentFetchedAt = null;
clearNameToggleForCharacter(_cvCurrentCharacterId);
clearGreetingIndexForCharacter(_cvCurrentCharacterId);
_cvCurrentCharacterId = null;
const lightboxHost = backdropEl ?? view;
lightboxHost.querySelector(".jan-character-viewer-lightbox")?.remove();
view.innerHTML = createNoCharacterMarkup();
setupLoadForm(view, loadCharacterFromInput);
clearCharacterFromSession();
clearCvScroll();
pendingRestore = false;
};
const loadCharacterFromInput = async (rawInput) => {
const raw = typeof rawInput === "string" ? rawInput.trim() : "";
const { input } = getLoadFormRefs(view);
if (input) {
input.value = raw;
}
if (!raw) {
setLoadFormError(view, "Please enter a character ID or URL");
return false;
}
const charId = extractCharacterIdFromInput(raw);
if (!charId) {
setLoadFormError(view, "Please enter a valid character ID or URL");
return false;
}
clearNameToggleForCharacter(charId);
clearGreetingIndexForCharacter(charId);
const requestId = ++activeLoadRequestId;
clearLoadFormError(view);
setLoadFormLoading(view, true);
try {
const data = await viewerClient.getCharacter(charId);
if (requestId !== activeLoadRequestId || view.isConnected === false) {
return false;
}
const character = mapApiResponseToCharacter(data);
character.fetchedAt = Date.now();
_cvCurrentFetchedAt = character.fetchedAt;
_cvCurrentCharacterId = character.id;
loadCharacterIntoView(
view,
character,
documentRef,
backdropEl,
unloadCharacterFn,
scrapeCharacterCardImpl,
async () => {
unloadCharacterFn();
await loadCharacterFromInput(character.id);
},
sharedState
);
saveCharacterToSession(raw, character);
clearCvScroll();
setupCvScrollListeners();
if (_cvFetchedAtTicker != null) globalThis.clearInterval(_cvFetchedAtTicker);
_cvFetchedAtTicker = character.fetchedAt ? globalThis.setInterval(() => {
if (documentRef.hidden || view.offsetHeight === 0) return;
const el = view.querySelector("#cvFetchedTime");
if (el) el.textContent = formatFetchedTime(character.fetchedAt);
}, 1e4) : null;
return true;
} catch (error) {
if (requestId !== activeLoadRequestId || view.isConnected === false) {
return false;
}
setLoadFormLoading(view, false);
setLoadFormError(
view,
error?.data?.message ?? "Character not found or failed to load"
);
return false;
}
};
let pendingRestore = !!loadCharacterFromSession();
function restoreFromCache() {
if (!pendingRestore) return;
pendingRestore = false;
const cached = loadCharacterFromSession();
if (!cached?.character) return;
loadCharacterIntoView(
view,
cached.character,
documentRef,
backdropEl,
unloadCharacterFn,
scrapeCharacterCardImpl,
async () => {
unloadCharacterFn();
await loadCharacterFromInput(cached.character.id);
},
sharedState
);
setupCvScrollListeners();
if (_cvFetchedAtTicker != null) globalThis.clearInterval(_cvFetchedAtTicker);
_cvFetchedAtTicker = cached.character.fetchedAt ? globalThis.setInterval(() => {
if (documentRef.hidden || view.offsetHeight === 0) return;
const el = view.querySelector("#cvFetchedTime");
if (el) el.textContent = formatFetchedTime(cached.character.fetchedAt);
}, 1e4) : null;
_cvCurrentFetchedAt = cached.character.fetchedAt ?? null;
_cvCurrentCharacterId = cached.character.id ?? null;
const win = documentRef.defaultView ?? globalThis;
win.requestAnimationFrame(() => {
const metaBody = view.querySelector(".meta-body");
if (metaBody && sharedState.scroll.metaBodyScroll) {
metaBody.scrollTop = sharedState.scroll.metaBodyScroll;
}
if (sharedState.scroll.activeTab) {
const tabBtn = view.querySelector(`.tab-btn[data-tab="${sharedState.scroll.activeTab}"]`);
if (tabBtn && !tabBtn.classList.contains("active")) {
tabBtn.click();
} else {
const pane = view.querySelector(`#tab-${sharedState.scroll.activeTab}`);
const storedScroll = sharedState.scroll.tabScrolls[sharedState.scroll.activeTab];
if (pane && storedScroll) pane.scrollTop = storedScroll;
}
}
});
}
function notifyTabActive() {
restoreFromCache();
tickFetchedAtNow();
}
view[CHARACTER_VIEWER_CONTROLLER_KEY] = {
loadCharacterFromInput,
unloadCharacter: unloadCharacterFn,
notifyTabActive,
destroy: () => documentController.abort()
};
setupLoadForm(view, loadCharacterFromInput);
function tickFetchedAtNow() {
if (!_cvCurrentFetchedAt || view.offsetHeight === 0) return;
const el = view.querySelector("#cvFetchedTime");
if (el) el.textContent = formatFetchedTime(_cvCurrentFetchedAt);
}
const activeObserver = new MutationObserver(() => {
if (view.classList.contains("is-active")) tickFetchedAtNow();
});
activeObserver.observe(view, { attributeFilter: ["class"] });
documentSignal?.addEventListener?.("abort", () => activeObserver.disconnect(), { once: true });
documentRef.addEventListener("visibilitychange", () => {
if (!documentRef.hidden) tickFetchedAtNow();
}, { signal: documentSignal });
documentRef.addEventListener("jan-settings-changed", (event) => {
const key = event?.detail?.key;
if (key !== "vis_name_display" && key !== "vis_char_avatar") return;
if (sharedState.download.active) return;
const cached = loadCharacterFromSession();
if (!cached?.character || view.isConnected === false) return;
loadCharacterIntoView(
view,
cached.character,
documentRef,
backdropEl,
unloadCharacterFn,
scrapeCharacterCardImpl,
async () => {
unloadCharacterFn();
await loadCharacterFromInput(cached.character.id);
},
sharedState
);
setupCvScrollListeners();
}, { signal: documentSignal });
return view;
}
async function loadCharacterViewerInput(view, input) {
const loader = view?.[CHARACTER_VIEWER_CONTROLLER_KEY]?.loadCharacterFromInput;
if (typeof loader !== "function") return false;
return await loader(input);
}
function unloadCharacterViewer(view) {
const unloader = view?.[CHARACTER_VIEWER_CONTROLLER_KEY]?.unloadCharacter;
if (typeof unloader === "function") unloader();
}
function notifyCharacterViewerTabActive(view) {
const fn = view?.[CHARACTER_VIEWER_CONTROLLER_KEY]?.notifyTabActive;
if (typeof fn === "function") fn();
}
function createBatchQueue() {
let items = [];
const listeners = /* @__PURE__ */ new Set();
function notify() {
const snapshot = [...items];
for (const fn of listeners) {
try {
fn(snapshot);
} catch {
}
}
}
function findIndex(id) {
return items.findIndex((item) => item.id === id);
}
return {
/**
* Subscribe to queue changes. Returns an unsubscribe function.
* @param {(items: QueueItem[]) => void} listener
* @returns {() => void}
*/
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
/** @returns {QueueItem[]} */
getItems() {
return [...items];
},
/** @returns {QueueItem|null} */
getItem(id) {
return items.find((item) => item.id === id) ?? null;
},
/**
* Add a character to the queue. Skips if the same characterId is already queued.
* @returns {QueueItem|null} The created entry, or null if duplicate.
*/
addItem(characterId, title, characterUrl = null, {
autoFetch = false,
autoExport = false,
batchId = null
} = {}) {
const exists = items.some((item) => item.characterId === characterId);
if (exists) return null;
const entry = {
id: generateId(),
characterId,
title: title || characterId,
characterUrl,
status: "pending",
progress: 0,
error: null,
downloadArtifact: null,
normalizedCharacter: null,
artifactFormat: null,
autoFetch,
autoExport,
batchId,
retrying: false,
retryCount: 0
};
items = [...items, entry];
notify();
return entry;
},
/**
* Merge a partial update into an existing entry.
* @param {string} id
* @param {Partial<QueueItem>} patch
*/
updateItem(id, patch) {
const idx = findIndex(id);
if (idx === -1) return;
items = items.map((item, i2) => i2 === idx ? { ...item, ...patch } : item);
notify();
},
/** Remove a single entry by ID. */
removeItem(id) {
const before = items.length;
items = items.filter((item) => item.id !== id);
if (items.length !== before) notify();
},
/** Remove multiple entries by ID set. */
removeItems(ids) {
const idSet = new Set(ids);
const before = items.length;
items = items.filter((item) => !idSet.has(item.id));
if (items.length !== before) notify();
},
/** Remove all entries. */
clear() {
if (items.length === 0) return;
items = [];
notify();
},
/** @returns {{ total: number, pending: number, fetching: number, ready: number, error: number }} */
getStats() {
let pending = 0;
let fetching = 0;
let ready = 0;
let error = 0;
for (const item of items) {
if (item.status === "pending") pending += 1;
else if (item.status === "fetching") fetching += 1;
else if (item.status === "ready") ready += 1;
else if (item.status === "error") error += 1;
}
return { total: items.length, pending, fetching, ready, error };
}
};
}
const SELECTION_MODE_CLASS = "jan-panel-group--selection-mode";
const SELECTION_MODE_BACKDROP_CLASS = "jan-panel-backdrop--selection-mode";
const SELECTION_MODE_PAGE_CLASS = "jan-selection-mode-active";
const SELECTION_STORAGE_KEY = "jan-batch-selection";
const QUEUE_STORAGE_KEY = "jan-batch-queue";
const SETTINGS_STORAGE_KEY = "jan-batch-settings";
const CHECKED_STORAGE_KEY = "jan-batch-checked-ids";
const SELECTION_OVERLAY_CLASS = "jan-batch-pick-overlay";
const SELECTION_STYLE_ID = "jan-batch-pick-style";
const PAGE_CARD_WRAPPER_SELECTOR = [
".pp-cc-wrapper",
".profile-character-card-wrapper",
'[class*="_chatCard_"]'
].join(", ");
const BATCH_TAB_STYLE_ID = "jan-batch-tab-style";
const BATCH_TAB_FONT_STYLESHEET_ID = "jan-batch-font-stylesheet";
const BATCH_TAB_FONT_STYLESHEET_URL = "https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap";
function ensureBatchDownloadTabStyle(documentRef) {
if (!documentRef?.head) return;
ensureGoogleFontStylesheet(documentRef, {
id: BATCH_TAB_FONT_STYLESHEET_ID,
href: BATCH_TAB_FONT_STYLESHEET_URL
});
if (documentRef.getElementById(BATCH_TAB_STYLE_ID)) return;
const style = documentRef.createElement("style");
style.id = BATCH_TAB_STYLE_ID;
style.textContent = `
#jan-panel-backdrop .jan-batch-view,
#jan-panel-backdrop .jan-batch-selection-hud {
--jan-batch-bg: rgba(0, 0, 0, 0.14);
--jan-batch-bg-soft: rgba(255, 255, 255, 0.02);
--jan-batch-bg-softer: rgba(255, 255, 255, 0.015);
--jan-batch-border: rgba(255, 255, 255, 0.06);
--jan-batch-border-soft: rgba(255, 255, 255, 0.05);
--jan-batch-border-strong: rgba(255, 255, 255, 0.12);
--jan-batch-text: #e8e8ec;
--jan-batch-muted: rgba(255, 255, 255, 0.5);
--jan-batch-accent: #ffffff;
--jan-batch-success: #4caf50;
--jan-batch-error: #f44336;
--jan-batch-warning: #ffb74d;
--jan-batch-pod-bg: linear-gradient(180deg, #181818 0%, #000000 100%);
}
#jan-panel-backdrop.jan-panel--light .jan-batch-view,
#jan-panel-backdrop.jan-panel--light .jan-batch-selection-hud {
--jan-batch-bg: rgba(0, 0, 0, 0.035);
--jan-batch-bg-soft: rgba(0, 0, 0, 0.02);
--jan-batch-bg-softer: rgba(0, 0, 0, 0.03);
--jan-batch-border: rgba(0, 0, 0, 0.08);
--jan-batch-border-soft: rgba(0, 0, 0, 0.08);
--jan-batch-border-strong: rgba(0, 0, 0, 0.14);
--jan-batch-text: #1c1c1e;
--jan-batch-muted: rgba(0, 0, 0, 0.48);
--jan-batch-accent: #111111;
--jan-batch-success: #2e7d32;
--jan-batch-error: #c62828;
--jan-batch-warning: #b26a00;
--jan-batch-pod-bg: linear-gradient(180deg, #ffffff 0%, #f1f1f3 100%);
}
#jan-panel-backdrop.${SELECTION_MODE_BACKDROP_CLASS} {
background: transparent !important;
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
pointer-events: none !important;
}
.${SELECTION_MODE_CLASS} {
display: none !important;
}
.${SELECTION_MODE_PAGE_CLASS} .jan-cv-btn {
opacity: 0 !important;
pointer-events: none !important;
visibility: hidden !important;
}
.jan-batch-view.is-active {
display: flex;
}
.jan-batch-view {
color: var(--jan-batch-text);
font-family: 'Outfit', sans-serif;
height: 100%;
width: 100%;
}
.jan-batch-sidebar {
background: var(--jan-batch-bg);
box-shadow: 1px 0 0 var(--jan-batch-border-soft);
box-sizing: border-box;
display: flex;
flex: 0 0 338px;
flex-direction: column;
gap: 24px;
overflow-y: auto;
padding: 24px;
width: 338px;
}
.jan-batch-queue-section {
box-sizing: border-box;
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: 16px;
min-width: 0;
overflow: hidden;
padding: 24px 30px;
}
.jan-batch-selection-cta-card {
align-items: center;
background: var(--jan-batch-bg-soft);
border: 1.5px dashed var(--jan-batch-border-strong);
border-radius: 12px;
display: flex;
flex-direction: column;
gap: 14px;
padding: 20px;
text-align: center;
}
.jan-batch-selection-icon-ring {
align-items: center;
background: rgba(255, 255, 255, 0.04);
border-radius: 50%;
box-shadow: 0 0 0 1px var(--jan-batch-border);
display: flex;
height: 48px;
justify-content: center;
width: 48px;
}
#jan-panel-backdrop.jan-panel--light .jan-batch-selection-icon-ring {
background: rgba(0, 0, 0, 0.04);
}
.jan-batch-selection-copy {
display: flex;
flex-direction: column;
gap: 4px;
}
.jan-batch-selection-title {
font-size: 1rem;
font-weight: 500;
}
.jan-batch-selection-description {
color: var(--jan-batch-muted);
font-size: 0.8rem;
line-height: 1.4;
}
.jan-batch-select-chars-btn {
align-items: center;
background: transparent;
border: none;
border-radius: 8px;
box-shadow: inset 0 0 0 1px var(--jan-batch-border);
color: var(--jan-batch-muted);
cursor: pointer;
display: flex;
font: inherit;
font-size: 0.88rem;
font-weight: 500;
gap: 10px;
justify-content: center;
letter-spacing: 0.02em;
padding: 11px 0;
transition: background 0.2s ease, box-shadow 0.2s ease, color 0.2s ease;
-webkit-user-select: none;
user-select: none;
width: 100%;
}
.jan-batch-select-chars-btn:hover {
background: rgba(255, 255, 255, 0.04);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.15);
color: var(--jan-batch-text);
}
#jan-panel-backdrop.jan-panel--light .jan-batch-select-chars-btn:hover {
background: rgba(0, 0, 0, 0.05);
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.14);
}
.jan-batch-select-chars-btn svg {
opacity: 0.6;
transition: opacity 0.2s ease;
}
.jan-batch-select-chars-btn:hover svg {
opacity: 1;
}
.jan-batch-settings-block {
display: flex;
flex-direction: column;
gap: 12px;
}
.jan-batch-section-title {
align-items: center;
color: var(--jan-batch-muted);
display: flex;
font-size: 0.75rem;
font-weight: 600;
gap: 6px;
letter-spacing: 0.1em;
margin-bottom: 4px;
text-transform: uppercase;
}
.jan-batch-setting-row {
align-items: center;
display: flex;
gap: 12px;
justify-content: space-between;
padding: 4px 0;
transition: opacity 0.25s ease;
}
.jan-batch-setting-label {
align-items: center;
color: var(--jan-batch-text);
display: flex;
font-size: 0.85rem;
gap: 6px;
-webkit-user-select: none;
user-select: none;
}
.jan-batch-toggle-switch {
display: inline-block;
height: 22px;
position: relative;
width: 40px;
}
.jan-batch-toggle-switch input {
height: 0;
opacity: 0;
width: 0;
}
.jan-batch-toggle-slider {
background-color: rgba(255, 255, 255, 0.06);
border: 1px solid var(--jan-batch-border);
border-radius: 22px;
bottom: 0;
cursor: pointer;
left: 0;
position: absolute;
right: 0;
top: 0;
transition: 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
#jan-panel-backdrop.jan-panel--light .jan-batch-toggle-slider {
background-color: rgba(0, 0, 0, 0.06);
}
.jan-batch-toggle-slider::before {
background-color: rgba(255, 255, 255, 0.35);
border-radius: 50%;
bottom: 2px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
content: "";
height: 16px;
left: 2px;
position: absolute;
transition: 0.3s cubic-bezier(0.4, 0, 0.2, 1);
width: 16px;
}
#jan-panel-backdrop.jan-panel--light .jan-batch-toggle-slider::before {
background-color: rgba(0, 0, 0, 0.35);
}
.jan-batch-toggle-switch input:checked + .jan-batch-toggle-slider {
background-color: rgba(255, 255, 255, 0.12);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.15);
}
#jan-panel-backdrop.jan-panel--light .jan-batch-toggle-switch input:checked + .jan-batch-toggle-slider {
background-color: rgba(0, 0, 0, 0.08);
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.12);
}
.jan-batch-toggle-switch input:checked + .jan-batch-toggle-slider::before {
background-color: var(--jan-batch-accent);
box-shadow: 0 0 6px rgba(255, 255, 255, 0.3);
transform: translateX(18px);
}
#jan-panel-backdrop.jan-panel--light .jan-batch-toggle-switch input:checked + .jan-batch-toggle-slider::before {
box-shadow: 0 0 6px rgba(0, 0, 0, 0.18);
}
.jan-batch-inline-segment {
background: rgba(0, 0, 0, 0.3);
border-radius: 6px;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.2), 0 0 0 1px var(--jan-batch-border-soft);
display: flex;
padding: 3px;
}
#jan-panel-backdrop.jan-panel--light .jan-batch-inline-segment {
background: rgba(0, 0, 0, 0.06);
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(0, 0, 0, 0.06);
}
.jan-batch-inline-segment label {
cursor: pointer;
margin: 0;
}
.jan-batch-inline-segment input {
display: none;
}
.jan-batch-inline-seg-btn {
align-items: center;
border-radius: 4px;
color: var(--jan-batch-muted);
display: flex;
font-size: 0.75rem;
font-weight: 500;
gap: 4px;
justify-content: center;
padding: 4px 10px;
transition: all 0.2s ease;
user-select: none;
}
.jan-batch-inline-segment input:checked + .jan-batch-inline-seg-btn {
background: rgba(255, 255, 255, 0.1);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3), 0 0 0 1px var(--jan-batch-border-soft);
color: var(--jan-batch-text);
}
#jan-panel-backdrop.jan-panel--light .jan-batch-inline-segment input:checked + .jan-batch-inline-seg-btn {
background: rgba(0, 0, 0, 0.08);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(0, 0, 0, 0.08);
}
.jan-batch-inline-segment input[value="files"]:checked + .jan-batch-inline-seg-btn {
background: rgba(90, 156, 245, 0.1);
box-shadow: inset 0 0 0 1px rgba(90, 156, 245, 0.2);
color: #5a9cf5;
}
.jan-batch-inline-segment input[value="zip"]:checked + .jan-batch-inline-seg-btn {
background: rgba(195, 119, 224, 0.1);
box-shadow: inset 0 0 0 1px rgba(195, 119, 224, 0.2);
color: #c377e0;
}
.jan-batch-format-grid {
display: grid;
gap: 8px;
grid-template-columns: repeat(3, 1fr);
width: 100%;
}
.jan-batch-format-card {
align-items: center;
background: var(--jan-batch-bg-softer);
border: 1px solid var(--jan-batch-border);
border-radius: 10px;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 8px;
justify-content: center;
padding: 12px 4px;
transition: all 0.2s ease;
}
.jan-batch-format-card:hover {
background: rgba(255, 255, 255, 0.04);
}
#jan-panel-backdrop.jan-panel--light .jan-batch-format-card:hover {
background: rgba(0, 0, 0, 0.04);
}
.jan-batch-format-card input {
display: none;
}
.jan-batch-format-svg {
align-items: center;
color: rgba(255, 255, 255, 0.35);
display: flex;
justify-content: center;
transition: all 0.2s ease;
}
#jan-panel-backdrop.jan-panel--light .jan-batch-format-svg {
color: rgba(0, 0, 0, 0.35);
}
.jan-batch-format-badge {
background: rgba(0, 0, 0, 0.35);
border: 1px solid rgba(255, 255, 255, 0.04);
border-radius: 4px;
color: var(--jan-batch-muted);
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.05em;
padding: 2px 8px;
transition: all 0.2s ease;
}
#jan-panel-backdrop.jan-panel--light .jan-batch-format-badge {
background: rgba(0, 0, 0, 0.06);
border-color: rgba(0, 0, 0, 0.06);
}
.jan-batch-format-card.is-active {
background: rgba(255, 255, 255, 0.06);
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.2), 0 0 12px rgba(255, 255, 255, 0.02);
}
#jan-panel-backdrop.jan-panel--light .jan-batch-format-card.is-active {
background: rgba(0, 0, 0, 0.06);
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.14), 0 0 12px rgba(0, 0, 0, 0.04);
}
.jan-batch-format-card.is-active .jan-batch-format-svg {
color: var(--jan-batch-text);
}
.jan-batch-format-card.is-active .jan-batch-format-badge {
background: var(--jan-batch-accent);
border-color: var(--jan-batch-accent);
color: #000000;
}
#jan-panel-backdrop.jan-panel--light .jan-batch-format-card.is-active .jan-batch-format-badge {
color: #ffffff;
}
.jan-batch-format-card.is-png.is-active {
background: rgba(90, 156, 245, 0.06);
border-color: rgba(90, 156, 245, 0.28);
box-shadow: 0 0 0 1px rgba(90, 156, 245, 0.35);
}
.jan-batch-format-card.is-png.is-active .jan-batch-format-svg,
.jan-batch-format-card.is-png:hover .jan-batch-format-svg {
color: #5a9cf5;
}
.jan-batch-format-card.is-png.is-active .jan-batch-format-badge {
background: #5a9cf5;
border-color: #5a9cf5;
color: #ffffff;
}
.jan-batch-format-card.is-png:hover {
border-color: rgba(90, 156, 245, 0.2);
}
.jan-batch-format-card.is-json.is-active {
background: rgba(212, 137, 58, 0.06);
border-color: rgba(212, 137, 58, 0.28);
box-shadow: 0 0 0 1px rgba(212, 137, 58, 0.35);
}
.jan-batch-format-card.is-json.is-active .jan-batch-format-svg,
.jan-batch-format-card.is-json:hover .jan-batch-format-svg {
color: #d4893a;
}
.jan-batch-format-card.is-json.is-active .jan-batch-format-badge {
background: #d4893a;
border-color: #d4893a;
color: #ffffff;
}
.jan-batch-format-card.is-json:hover {
border-color: rgba(212, 137, 58, 0.2);
}
.jan-batch-format-card.is-txt.is-active {
background: rgba(143, 168, 190, 0.06);
border-color: rgba(143, 168, 190, 0.25);
box-shadow: 0 0 0 1px rgba(143, 168, 190, 0.32);
}
.jan-batch-format-card.is-txt.is-active .jan-batch-format-svg,
.jan-batch-format-card.is-txt:hover .jan-batch-format-svg {
color: #8fa8be;
}
.jan-batch-format-card.is-txt.is-active .jan-batch-format-badge {
background: #8fa8be;
border-color: #8fa8be;
color: #ffffff;
}
.jan-batch-format-card.is-txt:hover {
border-color: rgba(143, 168, 190, 0.2);
}
.jan-batch-queue-top-area {
align-items: flex-end;
display: flex;
justify-content: space-between;
margin-bottom: 8px;
}
.jan-batch-progress-info {
display: flex;
flex-direction: column;
gap: 8px;
width: 300px;
}
.jan-batch-progress-stats {
color: var(--jan-batch-muted);
display: flex;
font-size: 0.85rem;
justify-content: space-between;
}
.jan-batch-progress-stats strong {
color: var(--jan-batch-text);
font-weight: 500;
}
.jan-batch-global-progress-bar {
background: rgba(255, 255, 255, 0.06);
border-radius: 3px;
height: 3px;
overflow: hidden;
width: 100%;
}
#jan-panel-backdrop.jan-panel--light .jan-batch-global-progress-bar {
background: rgba(0, 0, 0, 0.08);
}
.jan-batch-global-progress-fill {
background: linear-gradient(90deg, rgba(255, 255, 255, 0.6), var(--jan-batch-accent));
border-radius: 3px;
box-shadow: 0 0 10px rgba(255, 255, 255, 0.1);
height: 100%;
transition: width 0.4s cubic-bezier(0.16, 1, 0.3, 1);
width: 0%;
}
#jan-panel-backdrop.jan-panel--light .jan-batch-global-progress-fill {
background: linear-gradient(90deg, rgba(0, 0, 0, 0.2), var(--jan-batch-accent));
box-shadow: none;
}
.jan-batch-bulk-actions-group {
align-items: center;
background: var(--jan-batch-bg-soft);
border-radius: 10px;
box-shadow: 0 0 0 1px var(--jan-batch-border);
display: flex;
gap: 6px;
padding: 4px 8px;
transition: all 0.2s ease;
}
.jan-batch-bulk-divider {
background: var(--jan-batch-border);
height: 14px;
margin: 0 4px;
width: 1px;
}
.jan-batch-select-all-wrapper {
align-items: center;
color: var(--jan-batch-muted);
display: flex;
font-size: 0.85rem;
gap: 8px;
margin-right: 8px;
position: relative;
}
.jan-batch-filter-toggle {
align-items: center;
background: var(--jan-batch-bg-soft);
border: 1px solid var(--jan-batch-border-soft);
border-radius: 6px;
color: var(--jan-batch-muted);
cursor: pointer;
display: flex;
font: inherit;
gap: 6px;
padding: 4px 8px;
transition: all 0.2s ease;
user-select: none;
}
.jan-batch-filter-toggle:hover {
background: rgba(255, 255, 255, 0.06);
color: var(--jan-batch-text);
border-color: var(--jan-batch-border-strong);
}
#jan-panel-backdrop.jan-panel--light .jan-batch-filter-toggle {
background: rgba(0, 0, 0, 0.05);
border-color: rgba(0, 0, 0, 0.08);
color: var(--jan-batch-muted);
}
#jan-panel-backdrop.jan-panel--light .jan-batch-filter-toggle:hover {
background: rgba(0, 0, 0, 0.08);
border-color: rgba(0, 0, 0, 0.12);
color: var(--jan-batch-text);
}
.jan-batch-filter-toggle svg {
opacity: 0.6;
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.2s ease;
}
.jan-batch-filter-toggle.open svg {
transform: rotate(180deg);
}
.jan-batch-filter-text {
display: none;
}
.jan-batch-filter-dot {
border-radius: 50%;
display: inline-block;
height: 8px;
width: 8px;
}
.jan-batch-filter-menu {
background: rgba(15, 15, 15, 0.95);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--jan-batch-border);
border-radius: 8px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.8), 0 0 0 1px rgba(255, 255, 255, 0.02) inset;
display: flex;
flex-direction: column;
gap: 2px;
left: 24px;
margin-top: 8px;
min-width: 140px;
opacity: 0;
padding: 6px;
pointer-events: none;
position: absolute;
top: 100%;
transform: translateY(-5px) scale(0.98);
transform-origin: top left;
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
z-index: 100;
}
.jan-batch-filter-menu.show {
opacity: 1;
pointer-events: auto;
transform: translateY(0) scale(1);
}
#jan-panel-backdrop.jan-panel--light .jan-batch-filter-menu {
background: rgba(255, 255, 255, 0.96);
border-color: rgba(0, 0, 0, 0.1);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.18), 0 0 0 1px rgba(0, 0, 0, 0.02) inset;
}
.jan-batch-filter-item {
align-items: center;
background: transparent;
border: none;
border-radius: 5px;
color: var(--jan-batch-muted);
cursor: pointer;
display: flex;
font-size: 0.85rem;
font-weight: 500;
justify-content: space-between;
padding: 8px 10px;
transition: all 0.15s ease;
}
.jan-batch-filter-label {
align-items: center;
display: flex;
gap: 8px;
}
.jan-batch-filter-item:hover {
background: rgba(255, 255, 255, 0.06);
color: var(--jan-batch-text);
}
#jan-panel-backdrop.jan-panel--light .jan-batch-filter-item:hover {
background: rgba(0, 0, 0, 0.06);
}
.jan-batch-filter-item.is-active {
background: rgba(255, 255, 255, 0.1);
color: var(--jan-batch-accent);
}
#jan-panel-backdrop.jan-panel--light .jan-batch-filter-item.is-active {
background: rgba(0, 0, 0, 0.08);
color: var(--jan-batch-accent);
}
.jan-batch-filter-check {
opacity: 0;
transform: scale(0.8);
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
}
.jan-batch-filter-item.is-active .jan-batch-filter-check {
opacity: 1;
transform: scale(1);
}
.jan-batch-select-all-wrapper,
.jan-batch-format-card,
.jan-batch-progress-stats,
.jan-batch-hud-confirm-btn,
.jan-batch-item-title,
.jan-batch-item-status-text {
-webkit-user-select: none;
user-select: none;
}
.jan-batch-custom-checkbox {
appearance: none;
background: rgba(0, 0, 0, 0.4);
border: none;
border-radius: 4px;
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.15);
cursor: pointer;
flex-shrink: 0;
height: 16px;
position: relative;
transition: all 0.15s ease;
width: 16px;
}
#jan-panel-backdrop.jan-panel--light .jan-batch-custom-checkbox {
background: rgba(0, 0, 0, 0.08);
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.18);
}
.jan-batch-custom-checkbox:checked,
.jan-batch-custom-checkbox:indeterminate {
background: var(--jan-batch-accent);
box-shadow: none;
}
.jan-batch-custom-checkbox:checked::after {
background: #000000;
content: "";
inset: 0;
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E");
-webkit-mask-position: center;
-webkit-mask-repeat: no-repeat;
-webkit-mask-size: 12px 12px;
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E");
mask-position: center;
mask-repeat: no-repeat;
mask-size: 12px 12px;
position: absolute;
}
.jan-batch-custom-checkbox:indeterminate::after {
background: #000000;
content: "";
inset: 0;
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M5 12h14'/%3E%3C/svg%3E");
-webkit-mask-position: center;
-webkit-mask-repeat: no-repeat;
-webkit-mask-size: 12px 12px;
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M5 12h14'/%3E%3C/svg%3E");
mask-position: center;
mask-repeat: no-repeat;
mask-size: 12px 12px;
position: absolute;
}
.jan-batch-icon-btn {
align-items: center;
background: transparent;
border: none;
border-radius: 6px;
color: var(--jan-batch-muted);
cursor: pointer;
display: flex;
justify-content: center;
padding: 6px;
transition: all 0.15s ease;
}
.jan-batch-icon-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.1);
color: var(--jan-batch-text);
}
#jan-panel-backdrop.jan-panel--light .jan-batch-icon-btn:hover:not(:disabled) {
background: rgba(0, 0, 0, 0.08);
}
.jan-batch-icon-btn.is-danger:hover:not(:disabled) {
background: rgba(244, 67, 54, 0.1);
color: var(--jan-batch-error);
}
.jan-batch-icon-btn.is-primary:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.1);
color: var(--jan-batch-accent);
}
#jan-panel-backdrop.jan-panel--light .jan-batch-icon-btn.is-primary:hover:not(:disabled) {
background: rgba(0, 0, 0, 0.08);
}
.jan-batch-icon-btn.is-success:hover:not(:disabled) {
background: rgba(76, 175, 80, 0.1);
color: var(--jan-batch-success);
}
.jan-batch-icon-btn:disabled {
background: transparent !important;
color: var(--jan-batch-muted) !important;
opacity: 0.15;
pointer-events: none;
}
.jan-batch-queue-box {
background: var(--jan-batch-bg);
border-radius: 10px;
box-shadow: 0 0 0 1px var(--jan-batch-border-soft);
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: 6px;
min-height: 0;
overflow-y: auto;
padding: 10px;
}
.jan-batch-queue-box::-webkit-scrollbar {
width: 5px;
}
.jan-batch-queue-box::-webkit-scrollbar-track {
background: transparent;
}
.jan-batch-queue-box::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 10px;
}
#jan-panel-backdrop.jan-panel--light .jan-batch-queue-box::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.15);
}
.jan-batch-queue-item {
align-items: center;
background: var(--jan-batch-bg-soft);
border: none;
border-radius: 10px;
box-shadow: inset 0 0 0 1px var(--jan-batch-border-soft);
cursor: pointer;
display: flex;
padding: 12px 14px;
transition: background 0.15s ease, box-shadow 0.2s ease;
}
.jan-batch-queue-item:hover {
background: rgba(255, 255, 255, 0.04);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.1);
}
#jan-panel-backdrop.jan-panel--light .jan-batch-queue-item:hover {
background: rgba(0, 0, 0, 0.04);
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1);
}
.jan-batch-queue-item.is-selected {
background: rgba(255, 255, 255, 0.065);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.13);
}
#jan-panel-backdrop.jan-panel--light .jan-batch-queue-item.is-selected {
background: rgba(0, 0, 0, 0.06);
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.13);
}
.jan-batch-queue-item .jan-batch-custom-checkbox {
display: none;
}
.jan-batch-item-icon {
align-items: center;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.5);
display: flex;
flex: 0 0 auto;
height: 32px;
justify-content: center;
margin-right: 14px;
position: relative;
width: 32px;
}
#jan-panel-backdrop.jan-panel--light .jan-batch-item-icon {
background: rgba(0, 0, 0, 0.05);
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.04);
color: rgba(0, 0, 0, 0.45);
}
.jan-batch-item-icon::after {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E");
background-position: center;
background-repeat: no-repeat;
background-size: contain;
bottom: -4px;
content: "";
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.9)) drop-shadow(0 0 1px black);
height: 15px;
opacity: 0;
pointer-events: none;
position: absolute;
right: -4px;
transform: translateY(2px) scale(0.8);
transition: all 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
width: 15px;
}
.jan-batch-queue-item.is-selected .jan-batch-item-icon::after {
opacity: 1;
transform: translateY(0) scale(1);
}
.jan-batch-queue-item.status-ready .jan-batch-item-icon {
background: rgba(120, 140, 185, 0.08);
box-shadow: inset 0 0 0 1px rgba(120, 140, 185, 0.1);
color: rgba(160, 178, 220, 0.65);
}
.jan-batch-queue-item.status-fetching .jan-batch-item-icon {
background: rgba(255, 183, 77, 0.1);
box-shadow: inset 0 0 0 1px rgba(255, 183, 77, 0.15);
color: var(--jan-batch-warning);
}
.jan-batch-queue-item.status-success .jan-batch-item-icon {
background: rgba(76, 175, 80, 0.1);
box-shadow: inset 0 0 0 1px rgba(76, 175, 80, 0.15);
color: var(--jan-batch-success);
}
.jan-batch-queue-item.status-error .jan-batch-item-icon {
background: rgba(244, 67, 54, 0.08);
box-shadow: inset 0 0 0 1px rgba(244, 67, 54, 0.12);
color: var(--jan-batch-error);
}
.jan-batch-item-info {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.jan-batch-item-title {
align-self: flex-start;
color: var(--jan-batch-text);
display: inline-block;
font-size: 0.95rem;
font-weight: 500;
max-width: 100%;
overflow: hidden;
text-decoration: none;
text-overflow: ellipsis;
white-space: nowrap;
}
.jan-batch-item-title:hover {
text-decoration: underline;
}
.jan-batch-selection-hud.is-active .jan-batch-hud-floating-bar {
pointer-events: auto;
}
.jan-batch-item-status-row {
align-items: center;
display: flex;
gap: 12px;
}
.jan-batch-item-status-text {
color: var(--jan-batch-muted);
flex-shrink: 0;
font-size: 0.75rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 120px;
}
.jan-batch-queue-item.status-error .jan-batch-item-status-text:not([data-original-text]) {
cursor: pointer;
}
.jan-batch-queue-item.status-error .jan-batch-item-status-text:not([data-original-text]):hover {
text-decoration: underline;
text-decoration-style: dotted;
}
.jan-batch-item-status-text[data-original-text] {
pointer-events: none;
}
.jan-batch-queue-item.status-success .jan-batch-item-status-text {
color: var(--jan-batch-success);
}
.jan-batch-queue-item.status-error .jan-batch-item-status-text {
color: var(--jan-batch-error);
}
.jan-batch-queue-item.status-fetching .jan-batch-item-status-text {
color: var(--jan-batch-warning);
}
.jan-batch-item-min-progress {
background: rgba(255, 255, 255, 0.06);
border-radius: 2px;
flex: 1 1 auto;
height: 2px;
overflow: hidden;
}
#jan-panel-backdrop.jan-panel--light .jan-batch-item-min-progress {
background: rgba(0, 0, 0, 0.08);
}
.jan-batch-item-min-progress-fill {
background: rgba(255, 255, 255, 0.25);
border-radius: 2px;
height: 100%;
width: 0%;
}
#jan-panel-backdrop.jan-panel--light .jan-batch-item-min-progress-fill {
background: rgba(0, 0, 0, 0.22);
}
.jan-batch-queue-item.status-success .jan-batch-item-min-progress-fill {
background: var(--jan-batch-success);
width: 100% !important;
}
.jan-batch-queue-item.status-error .jan-batch-item-min-progress-fill {
background: var(--jan-batch-error);
width: 100% !important;
}
.jan-batch-queue-item.status-fetching .jan-batch-item-min-progress-fill {
background: var(--jan-batch-warning);
box-shadow: 0 0 8px rgba(255, 183, 77, 0.2);
}
.jan-batch-item-actions {
align-items: center;
display: flex;
gap: 4px;
margin-left: 12px;
}
.jan-panel-group--selection-mode > #jan-panel,
.jan-panel-group--selection-mode > .jan-panel-top-tab,
.jan-panel-group--selection-mode > .jan-bar {
opacity: 0 !important;
pointer-events: none !important;
}
.jan-panel-group--selection-mode > #jan-panel {
transform: scale(0.95);
}
.jan-batch-selection-hud {
align-items: flex-end;
display: flex;
inset: 0;
justify-content: center;
opacity: 0;
padding-bottom: 40px;
pointer-events: none;
position: fixed;
transition: opacity 0.3s ease;
visibility: hidden;
z-index: 100001;
}
.jan-batch-selection-hud.is-active {
background: transparent;
backdrop-filter: none;
-webkit-backdrop-filter: none;
opacity: 1;
pointer-events: none;
visibility: visible;
}
.jan-batch-hud-floating-bar {
align-items: center;
display: flex;
gap: 12px;
pointer-events: none;
white-space: nowrap;
}
.jan-batch-hud-icon-btn,
.jan-batch-hud-close-btn {
align-items: center;
background: transparent;
border: none;
border-radius: 50%;
color: rgba(255, 255, 255, 0.4);
cursor: pointer;
display: inline-flex;
height: 42px;
justify-content: center;
padding: 0;
transition: color 150ms ease, background 150ms ease;
width: 42px;
}
.jan-batch-hud-icon-btn:hover,
.jan-batch-hud-close-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #ffffff;
}
#jan-panel-backdrop.jan-panel--light .jan-batch-hud-icon-btn,
#jan-panel-backdrop.jan-panel--light .jan-batch-hud-close-btn {
color: rgba(0, 0, 0, 0.45);
}
#jan-panel-backdrop.jan-panel--light .jan-batch-hud-icon-btn:hover,
#jan-panel-backdrop.jan-panel--light .jan-batch-hud-close-btn:hover {
background: rgba(0, 0, 0, 0.1);
color: #111111;
}
.jan-batch-hud-close-btn {
color: #ff5555;
}
.jan-batch-hud-close-btn:hover {
background: rgba(255, 60, 60, 0.2);
color: #ff8888;
}
.jan-batch-hud-confirm-btn {
background: transparent;
border: none;
border-radius: 9999px;
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
font: inherit;
font-size: 0.85rem;
font-weight: 600;
height: 30px;
margin: 0 4px;
padding: 0 20px;
transition: background 150ms ease, color 150ms ease;
}
.jan-batch-hud-confirm-btn:hover {
color: #ffffff;
}
#jan-panel-backdrop.jan-panel--light .jan-batch-selection-hud .jan-pod {
background: #ffffff;
border-color: #8e8e93;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.08);
}
#jan-panel-backdrop.jan-panel--light .jan-batch-hud-confirm-btn {
color: rgba(0, 0, 0, 0.72);
}
#jan-panel-backdrop.jan-panel--light .jan-batch-hud-confirm-btn:hover {
color: #111111;
}
.jan-batch-auto-retry-controls {
align-items: center;
display: flex;
gap: 8px;
}
.jan-batch-auto-retry-count {
-moz-appearance: textfield;
appearance: textfield;
background: rgba(0, 0, 0, 0.3);
border: none;
border-radius: 6px;
box-sizing: border-box;
color: var(--jan-batch-text);
font-family: inherit;
font-size: 0.8rem;
height: 26px;
outline: none;
padding: 0 8px;
text-align: center;
transition: background 0.25s ease, box-shadow 0.25s ease, opacity 0.25s ease;
width: 58px;
}
.jan-batch-auto-retry-count::-webkit-inner-spin-button,
.jan-batch-auto-retry-count::-webkit-outer-spin-button {
opacity: 0.35;
}
.jan-batch-auto-retry-count:focus {
background: rgba(0, 0, 0, 0.4);
box-shadow: 0 0 0 1px var(--jan-batch-border-strong);
}
.jan-batch-auto-retry-count::placeholder {
color: rgba(255, 255, 255, 0.3);
}
.jan-batch-auto-retry-count:disabled {
opacity: 0.3;
pointer-events: none;
}
#jan-panel-backdrop.jan-panel--light .jan-batch-auto-retry-count {
background: rgba(0, 0, 0, 0.05);
}
#jan-panel-backdrop.jan-panel--light .jan-batch-auto-retry-count:focus {
background: rgba(0, 0, 0, 0.07);
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2);
}
#jan-panel-backdrop.jan-panel--light .jan-batch-auto-retry-count::placeholder {
color: rgba(0, 0, 0, 0.3);
}
.jan-batch-setting-row.is-dimmed {
opacity: 0.35;
pointer-events: none;
user-select: none;
}
.jan-batch-setting-row.is-dimmed .jan-batch-toggle-switch {
cursor: default;
}
`;
documentRef.head.appendChild(style);
}
function ensureSelectionOverlayStyle(documentRef) {
if (documentRef.getElementById(SELECTION_STYLE_ID)) return;
const style = documentRef.createElement("style");
style.id = SELECTION_STYLE_ID;
style.textContent = `
.${SELECTION_OVERLAY_CLASS} {
position: absolute;
inset: 0;
background: rgba(76, 175, 80, 0.18);
border: 2px solid rgba(76, 175, 80, 0.6);
border-radius: inherit;
pointer-events: none;
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
}
.${SELECTION_OVERLAY_CLASS}::after {
content: '';
width: 24px;
height: 24px;
background: rgba(76, 175, 80, 0.9);
border-radius: 50%;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E");
background-size: 14px 14px;
background-repeat: no-repeat;
background-position: center;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
`;
(documentRef.head ?? documentRef.documentElement).appendChild(style);
}
const CHARACTER_LINK_SELECTORS = [
'a[href*="/characters/"]',
'a[href*="/character/"]'
];
function matchesSelector(node, selector) {
return typeof node?.matches === "function" && node.matches(selector);
}
function findCharacterLinkElement(wrapper) {
if (!wrapper) return null;
for (const selector of CHARACTER_LINK_SELECTORS) {
const found = wrapper.querySelector?.(selector);
if (found) return found;
if (matchesSelector(wrapper, selector)) return wrapper;
}
return null;
}
function hasCharacterLink(wrapper) {
return Boolean(findCharacterLinkElement(wrapper));
}
function resolveCharacterLinkHref(linkEl, documentRef = globalThis.document) {
if (!linkEl) return "";
const directHref = typeof linkEl?.href === "string" ? linkEl.href.trim() : "";
if (directHref) return directHref;
const rawHref = typeof linkEl?.getAttribute === "function" ? (linkEl.getAttribute("href") ?? "").trim() : "";
if (!rawHref) return "";
try {
return new URL(rawHref, documentRef?.baseURI ?? "https://janitorai.com").href;
} catch {
return rawHref;
}
}
function getCharacterInputFromWrapper(wrapper, documentRef = globalThis.document) {
const link = findCharacterLinkElement(wrapper);
if (!link) return null;
const href = resolveCharacterLinkHref(link, documentRef);
return href || null;
}
function getCharacterIdFromPath(pathname) {
const path = typeof pathname === "string" ? pathname : "";
const match = path.match(/\/characters?\/([^/?#]+)/);
return match?.[1] ?? null;
}
function buildCharacterInput(characterId, documentRef = globalThis.document) {
try {
return new URL(
`/characters/${characterId}`,
documentRef?.baseURI ?? "https://janitorai.com"
).href;
} catch {
return `https://janitorai.com/characters/${characterId}`;
}
}
function truncateStatusText(text, maxLen = 23) {
const safeText = typeof text === "string" ? text : String(text ?? "");
if (safeText.length <= maxLen) return safeText;
return safeText.slice(0, maxLen) + "...";
}
function resolveQueueTitle(entry) {
return entry?.title?.trim() ?? "";
}
function resolveQueueCharacterUrl(entry) {
return entry?.characterUrl ?? buildCharacterInput(entry?.characterId) ?? "";
}
function formatFetchedAt(fetchedAt) {
return formatRelativeTime(fetchedAt, {
emptyLabel: "Ready to Export",
prefix: "Fetched",
justNowLabel: "just now"
});
}
const STATUS_MAP = {
pending: {
visualStatus: "status-ready",
iconSvg: QUEUE_PENDING_ICON_SVG,
primaryButtonClass: "is-primary",
primaryButtonTitle: "Fetch definition",
primaryButtonIcon: FETCH_ITEM_ICON_SVG,
primaryAction: "fetch",
getStatusText: () => "Pending"
},
fetching: {
visualStatus: "status-fetching",
iconSvg: QUEUE_FETCHING_ICON_SVG,
primaryButtonClass: "",
primaryButtonTitle: "Fetching...",
primaryButtonIcon: VIEWER_ICON_SVG,
primaryAction: "none",
getStatusText: (entry) => `Fetching... ${entry.progress}%`
},
ready: {
visualStatus: "status-success",
iconSvg: QUEUE_READY_ICON_SVG,
primaryButtonClass: "is-primary",
primaryButtonTitle: "Open in Character Viewer",
primaryButtonIcon: VIEWER_ICON_SVG,
primaryAction: "view",
getStatusText: (entry) => formatFetchedAt(entry.fetchedAt)
},
error: {
visualStatus: "status-error",
iconSvg: QUEUE_ERROR_ICON_SVG,
primaryButtonClass: "is-primary",
primaryButtonTitle: "Retry Fetch",
primaryButtonIcon: BULK_FETCH_ICON_SVG,
primaryAction: "fetch",
getStatusText: (entry) => entry.retrying ? "Auto-retry..." : truncateStatusText(entry.error ? `Failed: ${entry.error}` : "Failed")
}
};
function mapQueueEntryToMarkupItem(entry) {
const mapped = STATUS_MAP[entry.status] ?? STATUS_MAP.pending;
const statusText = mapped.getStatusText ? mapped.getStatusText(entry) : "";
const fullError = entry.status === "error" && entry.error ? `Failed: ${entry.error}` : "";
return {
queueId: entry.id,
characterId: entry.characterId,
title: resolveQueueTitle(entry),
characterUrl: resolveQueueCharacterUrl(entry),
visualStatus: mapped.visualStatus,
actionStatus: entry.status,
statusText,
fullError,
progress: entry.progress,
checked: false,
primaryButtonClass: mapped.primaryButtonClass,
primaryButtonTitle: mapped.primaryButtonTitle,
primaryButtonIcon: mapped.primaryButtonIcon,
iconSvg: mapped.iconSvg,
primaryAction: mapped.primaryAction
};
}
function buildQueueItemMarkupWithId(item) {
const primaryButtonClassName = item.primaryButtonClass ? ` jan-batch-icon-btn ${item.primaryButtonClass}` : " jan-batch-icon-btn";
const checkedAttr = item.checked ? " checked" : "";
const selectedClass = item.checked ? " is-selected" : "";
const safeQueueId = escapeHtmlAttr(item.queueId);
const safeActionStatus = escapeHtmlAttr(item.actionStatus);
const safeTitle = escapeHtmlText(item.title);
const safeCharacterUrl = escapeHtmlAttr(item.characterUrl ?? "");
const safeStatusText = escapeHtmlText(item.statusText);
const safeFullError = escapeHtmlAttr(item.fullError ?? "");
const safePrimaryTitle = escapeHtmlAttr(item.primaryButtonTitle);
const safePrimaryAction = escapeHtmlAttr(item.primaryAction);
const progressValue = Number.isFinite(item.progress) ? item.progress : 0;
const titleMarkup = safeCharacterUrl ? `<a class="jan-batch-item-title" href="${safeCharacterUrl}" target="_blank" rel="noopener noreferrer">${safeTitle}</a>` : `<div class="jan-batch-item-title">${safeTitle}</div>`;
return `
<label class="jan-batch-queue-item ${item.visualStatus}${selectedClass}" data-queue-id="${safeQueueId}">
<input type="checkbox" class="jan-batch-custom-checkbox jan-batch-cb-row" data-status="${safeActionStatus}" data-queue-id="${safeQueueId}"${checkedAttr}>
<div class="jan-batch-item-icon">
${item.iconSvg}
</div>
<div class="jan-batch-item-info">
${titleMarkup}
<div class="jan-batch-item-status-row">
<span class="jan-batch-item-status-text"${safeFullError ? ` title="${safeFullError}" data-full-error="${safeFullError}"` : ""}>${safeStatusText}</span>
<div class="jan-batch-item-min-progress">
<div class="jan-batch-item-min-progress-fill" style="width: ${progressValue}%"></div>
</div>
</div>
</div>
<div class="jan-batch-item-actions">
<button type="button" class="${primaryButtonClassName.trim()}" title="${safePrimaryTitle}" data-action="${safePrimaryAction}" data-queue-id="${safeQueueId}">
${item.primaryButtonIcon}
</button>
<button type="button" class="jan-batch-icon-btn is-danger" title="Remove" data-action="remove" data-queue-id="${safeQueueId}">
${TRASH_ICON_SVG}
</button>
</div>
</label>
`;
}
function createBatchViewMarkup() {
return `
<div class="jan-batch-sidebar">
<div class="jan-batch-selection-cta-card">
<div class="jan-batch-selection-icon-ring">
${PICK_ICON_SVG}
</div>
<div class="jan-batch-selection-copy">
<div class="jan-batch-selection-title">Pick from Page</div>
<div class="jan-batch-selection-description">Enter selection mode to click on character cards around the website.</div>
</div>
<button type="button" class="jan-batch-select-chars-btn">
${PLUS_CIRCLE_SVG}
<span>Select Characters</span>
</button>
</div>
<div class="jan-batch-settings-block">
<div class="jan-batch-section-title">
${SETTINGS_ICON_SVG}
<span>Settings</span>
</div>
<div class="jan-batch-setting-row">
<div class="jan-batch-setting-label">Auto-fetch</div>
<label class="jan-batch-toggle-switch">
<input type="checkbox" class="jan-batch-auto-fetch" name="jan-batch-auto-fetch" checked>
<span class="jan-batch-toggle-slider"></span>
</label>
</div>
<div class="jan-batch-setting-row">
<div class="jan-batch-setting-label">Auto-export</div>
<label class="jan-batch-toggle-switch">
<input type="checkbox" class="jan-batch-auto-export" name="jan-batch-auto-export">
<span class="jan-batch-toggle-slider"></span>
</label>
</div>
<div class="jan-batch-setting-row">
<div class="jan-batch-setting-label">Auto-retry</div>
<div class="jan-batch-auto-retry-controls">
<input type="number" class="jan-batch-auto-retry-count" name="jan-batch-auto-retry-count" min="1" max="20" placeholder="1-20">
<label class="jan-batch-toggle-switch">
<input type="checkbox" class="jan-batch-auto-retry" name="jan-batch-auto-retry">
<span class="jan-batch-toggle-slider"></span>
</label>
</div>
</div>
<div class="jan-batch-setting-row">
<div class="jan-batch-setting-label">Export As</div>
<div class="jan-batch-inline-segment">
<label>
<input type="radio" name="jan-batch-download-mode" value="files" checked>
<div class="jan-batch-inline-seg-btn">
${EXPORT_FILES_ICON_SVG}
<span>Files</span>
</div>
</label>
<label>
<input type="radio" name="jan-batch-download-mode" value="zip">
<div class="jan-batch-inline-seg-btn">
${EXPORT_ZIP_ICON_SVG}
<span>ZIP</span>
</div>
</label>
</div>
</div>
</div>
<div class="jan-batch-settings-block">
<div class="jan-batch-section-title">
${BATCH_SECTION_DOWNLOAD_ICON_SVG}
<span>Format</span>
</div>
<div class="jan-batch-format-grid">
<label class="jan-batch-format-card is-png">
<input type="radio" name="jan-batch-export-format" value="png" checked>
<div class="jan-batch-format-svg">${BATCH_FORMAT_PNG_SVG}</div>
<div class="jan-batch-format-badge">PNG</div>
</label>
<label class="jan-batch-format-card is-json">
<input type="radio" name="jan-batch-export-format" value="json">
<div class="jan-batch-format-svg">${BATCH_FORMAT_JSON_SVG}</div>
<div class="jan-batch-format-badge">JSON</div>
</label>
<label class="jan-batch-format-card is-txt">
<input type="radio" name="jan-batch-export-format" value="txt">
<div class="jan-batch-format-svg">${BATCH_FORMAT_TXT_SVG}</div>
<div class="jan-batch-format-badge">TXT</div>
</label>
</div>
</div>
<div style="flex: 1;"></div>
</div>
<div class="jan-batch-queue-section">
<div class="jan-batch-queue-top-area">
<div class="jan-batch-progress-info">
<div class="jan-batch-progress-stats">
<strong>Download Queue</strong>
<span>0 / 0 Tasks</span>
</div>
<div class="jan-batch-global-progress-bar">
<div class="jan-batch-global-progress-fill"></div>
</div>
</div>
<div class="jan-batch-bulk-actions-group">
<div class="jan-batch-select-all-wrapper" title="Select Filtered">
<input type="checkbox" class="jan-batch-custom-checkbox jan-batch-select-all">
<button type="button" class="jan-batch-filter-toggle" title="Filter Selection" aria-expanded="false">
<span class="jan-batch-filter-dot jan-batch-filter-dot--current" style="background: var(--jan-batch-accent); box-shadow: 0 0 6px rgba(255, 255, 255, 0.4);"></span>
<span class="jan-batch-filter-text">All</span>
${FILTER_ICON_SVG}
</button>
<div class="jan-batch-filter-menu">
<div class="jan-batch-filter-item is-active" data-filter="all">
<div class="jan-batch-filter-label">
<span class="jan-batch-filter-dot" style="background: var(--jan-batch-accent); box-shadow: 0 0 6px rgba(255, 255, 255, 0.4);"></span>
All
</div>
<svg class="jan-batch-filter-check" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 6 9 17l-5-5" />
</svg>
</div>
<div class="jan-batch-filter-item" data-filter="ready">
<div class="jan-batch-filter-label">
<span class="jan-batch-filter-dot" style="background: var(--jan-batch-success); box-shadow: 0 0 6px rgba(76, 175, 80, 0.4);"></span>
Success
</div>
<svg class="jan-batch-filter-check" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 6 9 17l-5-5" />
</svg>
</div>
<div class="jan-batch-filter-item" data-filter="error">
<div class="jan-batch-filter-label">
<span class="jan-batch-filter-dot" style="background: var(--jan-batch-error); box-shadow: 0 0 6px rgba(244, 67, 54, 0.4);"></span>
Error
</div>
<svg class="jan-batch-filter-check" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 6 9 17l-5-5" />
</svg>
</div>
<div class="jan-batch-filter-item" data-filter="pending">
<div class="jan-batch-filter-label">
<span class="jan-batch-filter-dot" style="background: rgba(160, 178, 220, 0.65);"></span>
Idle
</div>
<svg class="jan-batch-filter-check" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 6 9 17l-5-5" />
</svg>
</div>
</div>
</div>
<div class="jan-batch-bulk-divider"></div>
<button type="button" class="jan-batch-icon-btn is-primary jan-batch-bulk-fetch" title="Fetch Features for Selected" disabled>
${BULK_FETCH_ICON_SVG}
</button>
<button type="button" class="jan-batch-icon-btn is-success jan-batch-bulk-download" title="Download Selected" disabled>
${BULK_DOWNLOAD_ICON_SVG}
</button>
<button type="button" class="jan-batch-icon-btn is-danger jan-batch-bulk-remove" title="Remove Selected" disabled>
${TRASH_ICON_SVG}
</button>
</div>
</div>
<div class="jan-batch-queue-box">
</div>
</div>
`;
}
function createSelectionHudMarkup() {
return `
<div class="jan-batch-hud-floating-bar">
<div class="jan-pod jan-pod--circle">
<button type="button" class="jan-batch-hud-icon-btn jan-batch-hud-select-btn" title="Select All">
<span class="jan-batch-hud-icon jan-batch-hud-icon--all">${HUD_SELECT_ALL_ICON_SVG}</span>
<span class="jan-batch-hud-icon jan-batch-hud-icon--partial" hidden>${HUD_SELECT_PARTIAL_ICON_SVG}</span>
<span class="jan-batch-hud-icon jan-batch-hud-icon--none" hidden>${HUD_SELECT_NONE_ICON_SVG}</span>
</button>
</div>
<div class="jan-pod">
<button type="button" class="jan-batch-hud-confirm-btn">Confirm (0)</button>
</div>
<div class="jan-pod jan-pod--circle jan-pod--close">
<button type="button" class="jan-batch-hud-close-btn" title="Close">
${HUD_CLOSE_ICON_SVG}
</button>
</div>
</div>
`;
}
function syncFormatCards(viewEl) {
const formatCards = Array.from(viewEl.querySelectorAll(".jan-batch-format-card"));
formatCards.forEach((card) => {
const input = card.querySelector('input[name="jan-batch-export-format"]');
card.classList.toggle("is-active", Boolean(input?.checked));
});
}
function updateSelectionHudIcons(hudSelectBtn, { allChecked, someChecked }) {
const iconAll = hudSelectBtn.querySelector(".jan-batch-hud-icon--all");
const iconPartial = hudSelectBtn.querySelector(".jan-batch-hud-icon--partial");
const iconNone = hudSelectBtn.querySelector(".jan-batch-hud-icon--none");
if (!iconAll || !iconPartial || !iconNone) return;
iconAll.hidden = true;
iconPartial.hidden = true;
iconNone.hidden = true;
if (allChecked) {
iconNone.hidden = false;
hudSelectBtn.title = "Unselect All";
return;
}
if (someChecked) {
iconPartial.hidden = false;
hudSelectBtn.title = "Select All";
return;
}
iconAll.hidden = false;
hudSelectBtn.title = "Select All";
}
function updateBulkActions({ selectedCheckboxes, bulkFetchBtn, bulkDownloadBtn, bulkRemoveBtn }) {
let fetchable = 0;
let downloadable = 0;
selectedCheckboxes.forEach((checkbox) => {
const status = checkbox.getAttribute("data-status");
if (status === "pending" || status === "error" || status === "ready") fetchable += 1;
if (status === "ready") downloadable += 1;
});
bulkFetchBtn.disabled = fetchable === 0;
bulkFetchBtn.title = fetchable > 0 ? `Fetch ${fetchable} card(s)` : "Select items to fetch";
bulkDownloadBtn.disabled = downloadable === 0;
bulkDownloadBtn.title = downloadable > 0 ? `Download ${downloadable} ready card(s)` : "Select ready items to download";
bulkRemoveBtn.disabled = selectedCheckboxes.length === 0;
bulkRemoveBtn.title = selectedCheckboxes.length > 0 ? `Remove ${selectedCheckboxes.length} card(s)` : "Select items to remove";
}
function createBatchFetchController({
queue,
documentRef,
mountEl,
scrapeCharacterCard,
getSelectedExportFormat,
getSelectedDownloadMode,
triggerArtifactDownload: triggerArtifactDownload2,
downloadBulkAsZip: downloadBulkAsZip2,
isAutoRetryEnabled = () => false,
getAutoRetryCount = () => null
}) {
let isFetchingBulk = false;
let isDestroyed = false;
const autoRetryTimers = /* @__PURE__ */ new Map();
const downloadedBatchIds = /* @__PURE__ */ new Set();
function cancelAutoRetryTimer(queueId) {
const timerId = autoRetryTimers.get(queueId);
if (timerId == null) return;
clearTimeout(timerId);
autoRetryTimers.delete(queueId);
const entry = queue.getItem(queueId);
if (entry?.retrying) {
queue.updateItem(queueId, { retrying: false });
}
}
function scheduleAutoRetry(queueId, delayMs) {
if (isDestroyed) return;
queue.updateItem(queueId, { retrying: true });
const timerId = setTimeout(async () => {
autoRetryTimers.delete(queueId);
if (isDestroyed) return;
const entry = queue.getItem(queueId);
if (!entry || entry.status !== "error") return;
queue.updateItem(queueId, { retryCount: entry.retryCount + 1, retrying: false });
await fetchSingleItem(queueId, { isAutoRetry: true });
}, delayMs);
autoRetryTimers.set(queueId, timerId);
}
function resolveAutoRetryLimit() {
if (!isAutoRetryEnabled?.()) return null;
const raw = typeof getAutoRetryCount === "function" ? getAutoRetryCount() : null;
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed < 1) return null;
return Math.min(parsed, 20);
}
function checkBatchAndTriggerZip(batchId) {
if (isDestroyed) return;
if (!batchId || downloadedBatchIds.has(batchId)) return;
const batchItems = queue.getItems().filter((item) => item.batchId === batchId && item.autoExport);
const allSettled = batchItems.every((item) => {
if (item.status === "ready") return true;
if (item.status === "error" && !item.retrying) return true;
return false;
});
if (!allSettled) return;
const readyItems = batchItems.filter((item) => item.status === "ready");
if (readyItems.length === 0) return;
downloadedBatchIds.add(batchId);
void downloadBulkAsZip2({
entries: readyItems,
exportFormat: getSelectedExportFormat(),
documentRef,
mountEl
}).catch(() => {
});
}
async function fetchSingleItem(queueId, { isAutoRetry = false } = {}) {
if (isDestroyed) return;
cancelAutoRetryTimer(queueId);
const entry = queue.getItem(queueId);
if (!entry || entry.status === "fetching") return;
if (typeof scrapeCharacterCard !== "function") {
queue.updateItem(queueId, { status: "error", error: "Scrape not available", progress: 0 });
return;
}
const exportFormat = getSelectedExportFormat();
const currentRetryCount = Number.isFinite(entry.retryCount) ? entry.retryCount : 0;
const nextRetryCount = !isAutoRetry && currentRetryCount > 0 ? 0 : currentRetryCount;
queue.updateItem(queueId, {
status: "fetching",
progress: 0,
error: null,
retryCount: nextRetryCount,
retrying: false
});
try {
const result = await scrapeCharacterCard({
characterId: entry.characterId,
exportFormat,
onProgress: (p) => {
if (isDestroyed) return;
queue.updateItem(queueId, { progress: Math.round(p * 100) });
}
});
if (isDestroyed) return;
if (!result?.downloadArtifact) {
throw new Error("Pipeline did not return a download artifact.");
}
const normalizedCharacter = result.normalizedCharacter ?? null;
const resolvedTitle = normalizedCharacter?.name ?? null;
const resolvedCharacterUrl = entry.characterUrl ?? normalizedCharacter?.characterUrl ?? null;
queue.updateItem(queueId, {
status: "ready",
progress: 100,
downloadArtifact: result.downloadArtifact,
normalizedCharacter,
title: resolvedTitle || entry.title,
characterUrl: resolvedCharacterUrl,
artifactFormat: exportFormat,
fetchedAt: Date.now()
});
const updatedEntry = queue.getItem(queueId);
if (updatedEntry?.autoExport) {
const mode = getSelectedDownloadMode();
if (mode === "files") {
await triggerArtifactDownload2({
downloadArtifact: result.downloadArtifact,
documentRef,
mountEl,
normalizedCharacter,
exportFormat,
exportOptions: readExportSettings(documentRef)
});
} else if (mode === "zip") {
checkBatchAndTriggerZip(updatedEntry.batchId);
}
}
} catch (error) {
if (isDestroyed) return;
const message = error?.data?.message ?? error?.message ?? "Unknown error";
const isProxyOff = error?.code === "PROXY_OFF" || message === "PROXY OFF";
queue.updateItem(queueId, {
status: "error",
progress: 0,
error: isProxyOff ? "PROXY OFF" : message.length > 100 ? message.slice(0, 100) + "..." : message
});
if (!isProxyOff) {
const failedEntry = queue.getItem(queueId);
const retryLimit = resolveAutoRetryLimit();
const shouldAutoRetry = failedEntry && retryLimit != null && failedEntry.retryCount < retryLimit;
if (shouldAutoRetry) {
scheduleAutoRetry(queueId, 3e3);
} else if (failedEntry?.autoExport && getSelectedDownloadMode() === "zip") {
checkBatchAndTriggerZip(failedEntry.batchId);
}
}
}
}
async function fetchBulk(queueIds) {
if (isDestroyed) return;
if (isFetchingBulk) return;
isFetchingBulk = true;
for (const id of queueIds) {
const entry = queue.getItem(id);
if (!entry) continue;
if (entry.status === "fetching") continue;
await fetchSingleItem(id);
}
isFetchingBulk = false;
}
async function tryAutoFetch() {
if (isDestroyed) return;
if (isFetchingBulk) return;
const pending = queue.getItems().filter((e) => e.status === "pending" && e.autoFetch === true);
if (pending.length === 0) return;
await fetchBulk(pending.map((e) => e.id));
}
function destroy() {
if (isDestroyed) return;
isDestroyed = true;
for (const timerId of autoRetryTimers.values()) {
clearTimeout(timerId);
}
autoRetryTimers.clear();
downloadedBatchIds.clear();
}
return {
fetchSingleItem,
fetchBulk,
tryAutoFetch,
cancelAutoRetryTimer,
isFetchingBulk: () => isFetchingBulk,
destroy
};
}
var u8 = Uint8Array, u16 = Uint16Array, i32 = Int32Array;
var fleb = new u8([
0,
0,
0,
0,
0,
0,
0,
0,
1,
1,
1,
1,
2,
2,
2,
2,
3,
3,
3,
3,
4,
4,
4,
4,
5,
5,
5,
5,
0,
/* unused */
0,
0,
/* impossible */
0
]);
var fdeb = new u8([
0,
0,
0,
0,
1,
1,
2,
2,
3,
3,
4,
4,
5,
5,
6,
6,
7,
7,
8,
8,
9,
9,
10,
10,
11,
11,
12,
12,
13,
13,
/* unused */
0,
0
]);
var clim = new u8([16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15]);
var freb = function(eb, start) {
var b = new u16(31);
for (var i2 = 0; i2 < 31; ++i2) {
b[i2] = start += 1 << eb[i2 - 1];
}
var r = new i32(b[30]);
for (var i2 = 1; i2 < 30; ++i2) {
for (var j = b[i2]; j < b[i2 + 1]; ++j) {
r[j] = j - b[i2] << 5 | i2;
}
}
return { b, r };
};
var _a = freb(fleb, 2), fl = _a.b, revfl = _a.r;
fl[28] = 258, revfl[258] = 28;
var _b = freb(fdeb, 0), revfd = _b.r;
var rev = new u16(32768);
for (var i = 0; i < 32768; ++i) {
var x = (i & 43690) >> 1 | (i & 21845) << 1;
x = (x & 52428) >> 2 | (x & 13107) << 2;
x = (x & 61680) >> 4 | (x & 3855) << 4;
rev[i] = ((x & 65280) >> 8 | (x & 255) << 8) >> 1;
}
var hMap = (function(cd, mb, r) {
var s = cd.length;
var i2 = 0;
var l = new u16(mb);
for (; i2 < s; ++i2) {
if (cd[i2])
++l[cd[i2] - 1];
}
var le = new u16(mb);
for (i2 = 1; i2 < mb; ++i2) {
le[i2] = le[i2 - 1] + l[i2 - 1] << 1;
}
var co;
if (r) {
co = new u16(1 << mb);
var rvb = 15 - mb;
for (i2 = 0; i2 < s; ++i2) {
if (cd[i2]) {
var sv = i2 << 4 | cd[i2];
var r_1 = mb - cd[i2];
var v = le[cd[i2] - 1]++ << r_1;
for (var m = v | (1 << r_1) - 1; v <= m; ++v) {
co[rev[v] >> rvb] = sv;
}
}
}
} else {
co = new u16(s);
for (i2 = 0; i2 < s; ++i2) {
if (cd[i2]) {
co[i2] = rev[le[cd[i2] - 1]++] >> 15 - cd[i2];
}
}
}
return co;
});
var flt = new u8(288);
for (var i = 0; i < 144; ++i)
flt[i] = 8;
for (var i = 144; i < 256; ++i)
flt[i] = 9;
for (var i = 256; i < 280; ++i)
flt[i] = 7;
for (var i = 280; i < 288; ++i)
flt[i] = 8;
var fdt = new u8(32);
for (var i = 0; i < 32; ++i)
fdt[i] = 5;
var flm = /* @__PURE__ */ hMap(flt, 9, 0);
var fdm = /* @__PURE__ */ hMap(fdt, 5, 0);
var shft = function(p) {
return (p + 7) / 8 | 0;
};
var slc = function(v, s, e) {
if (e == null || e > v.length)
e = v.length;
return new u8(v.subarray(s, e));
};
var ec = [
"unexpected EOF",
"invalid block type",
"invalid length/literal",
"invalid distance",
"stream finished",
"no stream handler",
,
"no callback",
"invalid UTF-8 data",
"extra field too long",
"date not in range 1980-2099",
"filename too long",
"stream finishing",
"invalid zip data"
// determined by unknown compression method
];
var err = function(ind, msg, nt) {
var e = new Error(msg || ec[ind]);
e.code = ind;
if (Error.captureStackTrace)
Error.captureStackTrace(e, err);
if (!nt)
throw e;
return e;
};
var wbits = function(d, p, v) {
v <<= p & 7;
var o = p / 8 | 0;
d[o] |= v;
d[o + 1] |= v >> 8;
};
var wbits16 = function(d, p, v) {
v <<= p & 7;
var o = p / 8 | 0;
d[o] |= v;
d[o + 1] |= v >> 8;
d[o + 2] |= v >> 16;
};
var hTree = function(d, mb) {
var t = [];
for (var i2 = 0; i2 < d.length; ++i2) {
if (d[i2])
t.push({ s: i2, f: d[i2] });
}
var s = t.length;
var t2 = t.slice();
if (!s)
return { t: et, l: 0 };
if (s == 1) {
var v = new u8(t[0].s + 1);
v[t[0].s] = 1;
return { t: v, l: 1 };
}
t.sort(function(a, b) {
return a.f - b.f;
});
t.push({ s: -1, f: 25001 });
var l = t[0], r = t[1], i0 = 0, i1 = 1, i22 = 2;
t[0] = { s: -1, f: l.f + r.f, l, r };
while (i1 != s - 1) {
l = t[t[i0].f < t[i22].f ? i0++ : i22++];
r = t[i0 != i1 && t[i0].f < t[i22].f ? i0++ : i22++];
t[i1++] = { s: -1, f: l.f + r.f, l, r };
}
var maxSym = t2[0].s;
for (var i2 = 1; i2 < s; ++i2) {
if (t2[i2].s > maxSym)
maxSym = t2[i2].s;
}
var tr = new u16(maxSym + 1);
var mbt = ln(t[i1 - 1], tr, 0);
if (mbt > mb) {
var i2 = 0, dt = 0;
var lft = mbt - mb, cst = 1 << lft;
t2.sort(function(a, b) {
return tr[b.s] - tr[a.s] || a.f - b.f;
});
for (; i2 < s; ++i2) {
var i2_1 = t2[i2].s;
if (tr[i2_1] > mb) {
dt += cst - (1 << mbt - tr[i2_1]);
tr[i2_1] = mb;
} else
break;
}
dt >>= lft;
while (dt > 0) {
var i2_2 = t2[i2].s;
if (tr[i2_2] < mb)
dt -= 1 << mb - tr[i2_2]++ - 1;
else
++i2;
}
for (; i2 >= 0 && dt; --i2) {
var i2_3 = t2[i2].s;
if (tr[i2_3] == mb) {
--tr[i2_3];
++dt;
}
}
mbt = mb;
}
return { t: new u8(tr), l: mbt };
};
var ln = function(n, l, d) {
return n.s == -1 ? Math.max(ln(n.l, l, d + 1), ln(n.r, l, d + 1)) : l[n.s] = d;
};
var lc = function(c) {
var s = c.length;
while (s && !c[--s])
;
var cl = new u16(++s);
var cli = 0, cln = c[0], cls = 1;
var w = function(v) {
cl[cli++] = v;
};
for (var i2 = 1; i2 <= s; ++i2) {
if (c[i2] == cln && i2 != s)
++cls;
else {
if (!cln && cls > 2) {
for (; cls > 138; cls -= 138)
w(32754);
if (cls > 2) {
w(cls > 10 ? cls - 11 << 5 | 28690 : cls - 3 << 5 | 12305);
cls = 0;
}
} else if (cls > 3) {
w(cln), --cls;
for (; cls > 6; cls -= 6)
w(8304);
if (cls > 2)
w(cls - 3 << 5 | 8208), cls = 0;
}
while (cls--)
w(cln);
cls = 1;
cln = c[i2];
}
}
return { c: cl.subarray(0, cli), n: s };
};
var clen = function(cf, cl) {
var l = 0;
for (var i2 = 0; i2 < cl.length; ++i2)
l += cf[i2] * cl[i2];
return l;
};
var wfblk = function(out, pos, dat) {
var s = dat.length;
var o = shft(pos + 2);
out[o] = s & 255;
out[o + 1] = s >> 8;
out[o + 2] = out[o] ^ 255;
out[o + 3] = out[o + 1] ^ 255;
for (var i2 = 0; i2 < s; ++i2)
out[o + i2 + 4] = dat[i2];
return (o + 4 + s) * 8;
};
var wblk = function(dat, out, final, syms, lf, df, eb, li, bs, bl, p) {
wbits(out, p++, final);
++lf[256];
var _a2 = hTree(lf, 15), dlt = _a2.t, mlb = _a2.l;
var _b2 = hTree(df, 15), ddt = _b2.t, mdb = _b2.l;
var _c = lc(dlt), lclt = _c.c, nlc = _c.n;
var _d = lc(ddt), lcdt = _d.c, ndc = _d.n;
var lcfreq = new u16(19);
for (var i2 = 0; i2 < lclt.length; ++i2)
++lcfreq[lclt[i2] & 31];
for (var i2 = 0; i2 < lcdt.length; ++i2)
++lcfreq[lcdt[i2] & 31];
var _e = hTree(lcfreq, 7), lct = _e.t, mlcb = _e.l;
var nlcc = 19;
for (; nlcc > 4 && !lct[clim[nlcc - 1]]; --nlcc)
;
var flen = bl + 5 << 3;
var ftlen = clen(lf, flt) + clen(df, fdt) + eb;
var dtlen = clen(lf, dlt) + clen(df, ddt) + eb + 14 + 3 * nlcc + clen(lcfreq, lct) + 2 * lcfreq[16] + 3 * lcfreq[17] + 7 * lcfreq[18];
if (bs >= 0 && flen <= ftlen && flen <= dtlen)
return wfblk(out, p, dat.subarray(bs, bs + bl));
var lm, ll, dm, dl;
wbits(out, p, 1 + (dtlen < ftlen)), p += 2;
if (dtlen < ftlen) {
lm = hMap(dlt, mlb, 0), ll = dlt, dm = hMap(ddt, mdb, 0), dl = ddt;
var llm = hMap(lct, mlcb, 0);
wbits(out, p, nlc - 257);
wbits(out, p + 5, ndc - 1);
wbits(out, p + 10, nlcc - 4);
p += 14;
for (var i2 = 0; i2 < nlcc; ++i2)
wbits(out, p + 3 * i2, lct[clim[i2]]);
p += 3 * nlcc;
var lcts = [lclt, lcdt];
for (var it = 0; it < 2; ++it) {
var clct = lcts[it];
for (var i2 = 0; i2 < clct.length; ++i2) {
var len = clct[i2] & 31;
wbits(out, p, llm[len]), p += lct[len];
if (len > 15)
wbits(out, p, clct[i2] >> 5 & 127), p += clct[i2] >> 12;
}
}
} else {
lm = flm, ll = flt, dm = fdm, dl = fdt;
}
for (var i2 = 0; i2 < li; ++i2) {
var sym = syms[i2];
if (sym > 255) {
var len = sym >> 18 & 31;
wbits16(out, p, lm[len + 257]), p += ll[len + 257];
if (len > 7)
wbits(out, p, sym >> 23 & 31), p += fleb[len];
var dst = sym & 31;
wbits16(out, p, dm[dst]), p += dl[dst];
if (dst > 3)
wbits16(out, p, sym >> 5 & 8191), p += fdeb[dst];
} else {
wbits16(out, p, lm[sym]), p += ll[sym];
}
}
wbits16(out, p, lm[256]);
return p + ll[256];
};
var deo = /* @__PURE__ */ new i32([65540, 131080, 131088, 131104, 262176, 1048704, 1048832, 2114560, 2117632]);
var et = /* @__PURE__ */ new u8(0);
var dflt = function(dat, lvl, plvl, pre, post, st) {
var s = st.z || dat.length;
var o = new u8(pre + s + 5 * (1 + Math.ceil(s / 7e3)) + post);
var w = o.subarray(pre, o.length - post);
var lst = st.l;
var pos = (st.r || 0) & 7;
if (lvl) {
if (pos)
w[0] = st.r >> 3;
var opt = deo[lvl - 1];
var n = opt >> 13, c = opt & 8191;
var msk_1 = (1 << plvl) - 1;
var prev = st.p || new u16(32768), head = st.h || new u16(msk_1 + 1);
var bs1_1 = Math.ceil(plvl / 3), bs2_1 = 2 * bs1_1;
var hsh = function(i3) {
return (dat[i3] ^ dat[i3 + 1] << bs1_1 ^ dat[i3 + 2] << bs2_1) & msk_1;
};
var syms = new i32(25e3);
var lf = new u16(288), df = new u16(32);
var lc_1 = 0, eb = 0, i2 = st.i || 0, li = 0, wi = st.w || 0, bs = 0;
for (; i2 + 2 < s; ++i2) {
var hv = hsh(i2);
var imod = i2 & 32767, pimod = head[hv];
prev[imod] = pimod;
head[hv] = imod;
if (wi <= i2) {
var rem = s - i2;
if ((lc_1 > 7e3 || li > 24576) && (rem > 423 || !lst)) {
pos = wblk(dat, w, 0, syms, lf, df, eb, li, bs, i2 - bs, pos);
li = lc_1 = eb = 0, bs = i2;
for (var j = 0; j < 286; ++j)
lf[j] = 0;
for (var j = 0; j < 30; ++j)
df[j] = 0;
}
var l = 2, d = 0, ch_1 = c, dif = imod - pimod & 32767;
if (rem > 2 && hv == hsh(i2 - dif)) {
var maxn = Math.min(n, rem) - 1;
var maxd = Math.min(32767, i2);
var ml = Math.min(258, rem);
while (dif <= maxd && --ch_1 && imod != pimod) {
if (dat[i2 + l] == dat[i2 + l - dif]) {
var nl = 0;
for (; nl < ml && dat[i2 + nl] == dat[i2 + nl - dif]; ++nl)
;
if (nl > l) {
l = nl, d = dif;
if (nl > maxn)
break;
var mmd = Math.min(dif, nl - 2);
var md = 0;
for (var j = 0; j < mmd; ++j) {
var ti = i2 - dif + j & 32767;
var pti = prev[ti];
var cd = ti - pti & 32767;
if (cd > md)
md = cd, pimod = ti;
}
}
}
imod = pimod, pimod = prev[imod];
dif += imod - pimod & 32767;
}
}
if (d) {
syms[li++] = 268435456 | revfl[l] << 18 | revfd[d];
var lin = revfl[l] & 31, din = revfd[d] & 31;
eb += fleb[lin] + fdeb[din];
++lf[257 + lin];
++df[din];
wi = i2 + l;
++lc_1;
} else {
syms[li++] = dat[i2];
++lf[dat[i2]];
}
}
}
for (i2 = Math.max(i2, wi); i2 < s; ++i2) {
syms[li++] = dat[i2];
++lf[dat[i2]];
}
pos = wblk(dat, w, lst, syms, lf, df, eb, li, bs, i2 - bs, pos);
if (!lst) {
st.r = pos & 7 | w[pos / 8 | 0] << 3;
pos -= 7;
st.h = head, st.p = prev, st.i = i2, st.w = wi;
}
} else {
for (var i2 = st.w || 0; i2 < s + lst; i2 += 65535) {
var e = i2 + 65535;
if (e >= s) {
w[pos / 8 | 0] = lst;
e = s;
}
pos = wfblk(w, pos + 1, dat.subarray(i2, e));
}
st.i = s;
}
return slc(o, 0, pre + shft(pos) + post);
};
var crct = /* @__PURE__ */ (function() {
var t = new Int32Array(256);
for (var i2 = 0; i2 < 256; ++i2) {
var c = i2, k = 9;
while (--k)
c = (c & 1 && -306674912) ^ c >>> 1;
t[i2] = c;
}
return t;
})();
var crc = function() {
var c = -1;
return {
p: function(d) {
var cr = c;
for (var i2 = 0; i2 < d.length; ++i2)
cr = crct[cr & 255 ^ d[i2]] ^ cr >>> 8;
c = cr;
},
d: function() {
return ~c;
}
};
};
var dopt = function(dat, opt, pre, post, st) {
if (!st) {
st = { l: 1 };
if (opt.dictionary) {
var dict = opt.dictionary.subarray(-32768);
var newDat = new u8(dict.length + dat.length);
newDat.set(dict);
newDat.set(dat, dict.length);
dat = newDat;
st.w = dict.length;
}
}
return dflt(dat, opt.level == null ? 6 : opt.level, opt.mem == null ? st.l ? Math.ceil(Math.max(8, Math.min(13, Math.log(dat.length))) * 1.5) : 20 : 12 + opt.mem, pre, post, st);
};
var mrg = function(a, b) {
var o = {};
for (var k in a)
o[k] = a[k];
for (var k in b)
o[k] = b[k];
return o;
};
var wbytes = function(d, b, v) {
for (; v; ++b)
d[b] = v, v >>>= 8;
};
function deflateSync(data, opts) {
return dopt(data, opts || {}, 0, 0);
}
var fltn = function(d, p, t, o) {
for (var k in d) {
var val = d[k], n = p + k, op = o;
if (Array.isArray(val))
op = mrg(o, val[1]), val = val[0];
if (val instanceof u8)
t[n] = [val, op];
else {
t[n += "/"] = [new u8(0), op];
fltn(val, n, t, o);
}
}
};
var te = typeof TextEncoder != "undefined" && /* @__PURE__ */ new TextEncoder();
var td = typeof TextDecoder != "undefined" && /* @__PURE__ */ new TextDecoder();
var tds = 0;
try {
td.decode(et, { stream: true });
tds = 1;
} catch (e) {
}
function strToU8(str, latin1) {
var i2;
if (te)
return te.encode(str);
var l = str.length;
var ar = new u8(str.length + (str.length >> 1));
var ai = 0;
var w = function(v) {
ar[ai++] = v;
};
for (var i2 = 0; i2 < l; ++i2) {
if (ai + 5 > ar.length) {
var n = new u8(ai + 8 + (l - i2 << 1));
n.set(ar);
ar = n;
}
var c = str.charCodeAt(i2);
if (c < 128 || latin1)
w(c);
else if (c < 2048)
w(192 | c >> 6), w(128 | c & 63);
else if (c > 55295 && c < 57344)
c = 65536 + (c & 1023 << 10) | str.charCodeAt(++i2) & 1023, w(240 | c >> 18), w(128 | c >> 12 & 63), w(128 | c >> 6 & 63), w(128 | c & 63);
else
w(224 | c >> 12), w(128 | c >> 6 & 63), w(128 | c & 63);
}
return slc(ar, 0, ai);
}
var exfl = function(ex) {
var le = 0;
if (ex) {
for (var k in ex) {
var l = ex[k].length;
if (l > 65535)
err(9);
le += l + 4;
}
}
return le;
};
var wzh = function(d, b, f, fn, u, c, ce, co) {
var fl2 = fn.length, ex = f.extra, col = co && co.length;
var exl = exfl(ex);
wbytes(d, b, ce != null ? 33639248 : 67324752), b += 4;
if (ce != null)
d[b++] = 20, d[b++] = f.os;
d[b] = 20, b += 2;
d[b++] = f.flag << 1 | (c < 0 && 8), d[b++] = u && 8;
d[b++] = f.compression & 255, d[b++] = f.compression >> 8;
var dt = new Date(f.mtime == null ? Date.now() : f.mtime), y = dt.getFullYear() - 1980;
if (y < 0 || y > 119)
err(10);
wbytes(d, b, y << 25 | dt.getMonth() + 1 << 21 | dt.getDate() << 16 | dt.getHours() << 11 | dt.getMinutes() << 5 | dt.getSeconds() >> 1), b += 4;
if (c != -1) {
wbytes(d, b, f.crc);
wbytes(d, b + 4, c < 0 ? -c - 2 : c);
wbytes(d, b + 8, f.size);
}
wbytes(d, b + 12, fl2);
wbytes(d, b + 14, exl), b += 16;
if (ce != null) {
wbytes(d, b, col);
wbytes(d, b + 6, f.attrs);
wbytes(d, b + 10, ce), b += 14;
}
d.set(fn, b);
b += fl2;
if (exl) {
for (var k in ex) {
var exf = ex[k], l = exf.length;
wbytes(d, b, +k);
wbytes(d, b + 2, l);
d.set(exf, b + 4), b += 4 + l;
}
}
if (col)
d.set(co, b), b += col;
return b;
};
var wzf = function(o, b, c, d, e) {
wbytes(o, b, 101010256);
wbytes(o, b + 8, c);
wbytes(o, b + 10, c);
wbytes(o, b + 12, d);
wbytes(o, b + 16, e);
};
function zipSync(data, opts) {
if (!opts)
opts = {};
var r = {};
var files = [];
fltn(data, "", r, opts);
var o = 0;
var tot = 0;
for (var fn in r) {
var _a2 = r[fn], file = _a2[0], p = _a2[1];
var compression = p.level == 0 ? 0 : 8;
var f = strToU8(fn), s = f.length;
var com = p.comment, m = com && strToU8(com), ms = m && m.length;
var exl = exfl(p.extra);
if (s > 65535)
err(11);
var d = compression ? deflateSync(file, p) : file, l = d.length;
var c = crc();
c.p(file);
files.push(mrg(p, {
size: file.length,
crc: c.d(),
c: d,
f,
m,
u: s != fn.length || m && com.length != ms,
o,
compression
}));
o += 30 + s + exl + l;
tot += 76 + 2 * (s + exl) + (ms || 0) + l;
}
var out = new u8(tot + 22), oe = o, cdl = tot - o;
for (var i2 = 0; i2 < files.length; ++i2) {
var f = files[i2];
wzh(out, f.o, f, f.f, f.u, f.c.length);
var badd = 30 + f.f.length + exfl(f.extra);
out.set(f.c, f.o + badd);
wzh(out, o, f, f.f, f.u, f.c.length, f.o, f.m), o += 16 + badd + (f.m ? f.m.length : 0);
}
wzf(out, o, files.length, cdl, oe);
return out;
}
async function buildDownloadBlob(downloadArtifact, normalizedCharacter, exportFormat, artifactFormat, exportOptions = null) {
if (normalizedCharacter && exportFormat) {
if (artifactFormat === exportFormat && downloadArtifact?.content) {
return buildDownloadBlob$1(downloadArtifact);
}
const built = await buildExportContent(normalizedCharacter, {
format: exportFormat,
...exportOptions ?? {}
});
return new Blob([built.content], { type: built.mimeType });
}
return buildDownloadBlob$1(downloadArtifact);
}
async function triggerArtifactDownload({
downloadArtifact,
documentRef,
mountEl,
normalizedCharacter,
exportFormat,
exportOptions
}) {
let blob, filename;
if (normalizedCharacter && exportFormat) {
blob = await buildDownloadBlob(
null,
normalizedCharacter,
exportFormat,
null,
exportOptions
);
const baseName = downloadArtifact?.filename?.replace(/\.[^/.]+$/, "") ?? "character";
const ext = exportFormat === "png" ? ".png" : exportFormat === "json" ? ".json" : ".txt";
filename = `${sanitizeFilename(baseName, "character")}${ext}`;
} else {
if (!downloadArtifact?.filename) {
throw new Error("Download artifact is missing a filename.");
}
blob = await buildDownloadBlob(downloadArtifact);
filename = sanitizeFilename(downloadArtifact.filename, "character");
}
downloadBlob({ documentRef, mountEl, blob, filename });
}
function getEntryFilename(entry, exportFormat) {
const artifactName = typeof entry?.downloadArtifact?.filename === "string" ? entry.downloadArtifact.filename.trim() : "";
if (entry?.normalizedCharacter && exportFormat) {
const baseName = artifactName ? artifactName.replace(/\.[^/.]+$/, "") : entry.title || entry.characterId || "character";
const ext2 = exportFormat === "png" ? ".png" : exportFormat === "json" ? ".json" : ".txt";
return `${sanitizeFilename(baseName, "character")}${ext2}`;
}
if (artifactName) return sanitizeFilename(artifactName, "character");
const fallbackBase = sanitizeFilename(entry?.title || entry?.characterId, "character");
const ext = exportFormat === "png" ? ".png" : exportFormat === "json" ? ".json" : ".txt";
return `${fallbackBase}${ext}`;
}
function dedupeFilename(name, usedNames) {
if (!usedNames.has(name)) {
usedNames.add(name);
return name;
}
const match = name.match(/^(.*?)(\.[^.]*)?$/);
const base = match?.[1] ?? name;
const ext = match?.[2] ?? "";
let i2 = 2;
let candidate = `${base} (${i2})${ext}`;
while (usedNames.has(candidate)) {
i2 += 1;
candidate = `${base} (${i2})${ext}`;
}
usedNames.add(candidate);
return candidate;
}
function getZipCompressionLevel(documentRef, exportFormat) {
if (exportFormat === "png") return 0;
const levelSetting = readBatchZipSetting(documentRef);
const levels = {
store: 0,
fast: 1,
standard: 6,
max: 9
};
return levels[levelSetting] ?? 0;
}
async function downloadBulkAsZip({
entries,
exportFormat,
documentRef,
mountEl,
exportOptions = null
}) {
if (entries.length === 0) return;
const resolvedExportOptions = exportOptions ?? readExportSettings(documentRef);
const usedNames = /* @__PURE__ */ new Set();
const zipLevel = getZipCompressionLevel(documentRef, exportFormat);
const zipEntries = {};
for (const entry of entries) {
const blob = await buildDownloadBlob(
entry.downloadArtifact,
entry.normalizedCharacter,
exportFormat,
entry.artifactFormat,
resolvedExportOptions
);
const filename = dedupeFilename(getEntryFilename(entry, exportFormat), usedNames);
const buffer = await blob.arrayBuffer();
zipEntries[filename] = [new Uint8Array(buffer), { level: zipLevel }];
}
const zipBuffer = zipSync(zipEntries);
const zipBlob = new Blob([zipBuffer], { type: "application/zip" });
downloadBlob({
documentRef,
mountEl,
blob: zipBlob,
filename: "jan-batch-export.zip"
});
}
async function downloadBulkEntries({
entries,
exportMode,
exportFormat,
documentRef,
mountEl,
exportOptions = null
}) {
if (entries.length === 0) return;
if (exportMode === "zip") {
await downloadBulkAsZip({
entries,
exportFormat,
documentRef,
mountEl,
exportOptions
});
return;
}
const resolvedExportOptions = exportOptions ?? readExportSettings(documentRef);
for (const entry of entries) {
await triggerArtifactDownload({
downloadArtifact: entry.downloadArtifact,
documentRef,
mountEl,
normalizedCharacter: entry.normalizedCharacter,
exportFormat,
exportOptions: resolvedExportOptions
});
}
}
function getCharacterNameFromWrapper(wrapper) {
const nameEl = wrapper.querySelector(
'[class*="charName"], [class*="name"], a[href*="/characters/"]'
);
return nameEl?.textContent?.trim() || null;
}
function setupBatchSelection({
documentRef,
groupEl,
backdropEl,
hud,
selectCharsBtn,
hudSelectBtn,
hudConfirmBtn,
hudCloseBtn,
queue,
isAutoFetchEnabled,
isAutoExportEnabled,
tryAutoFetch
}) {
const selectedCards = /* @__PURE__ */ new Map();
let selectionObserver = null;
let selectionSyncRaf = null;
const wrapperPositionCache = /* @__PURE__ */ new WeakMap();
const controller = new AbortController();
const { signal } = controller;
function getSelectionStorage() {
return getSessionStorage(documentRef);
}
function loadStoredSelections() {
selectedCards.clear();
const storage = getSelectionStorage();
if (!storage) return;
const parsed = readJson(storage, SELECTION_STORAGE_KEY, null);
if (!Array.isArray(parsed)) {
if (parsed != null) removeStorageItem(storage, SELECTION_STORAGE_KEY);
return;
}
parsed.forEach((item) => {
if (!item?.characterId) return;
selectedCards.set(item.characterId, {
characterId: item.characterId,
title: item.title || item.characterId,
characterUrl: item.characterUrl ?? null
});
});
}
function persistSelections() {
const storage = getSelectionStorage();
if (!storage) return;
if (selectedCards.size === 0) {
removeStorageItem(storage, SELECTION_STORAGE_KEY);
return;
}
writeJson(storage, SELECTION_STORAGE_KEY, Array.from(selectedCards.values()));
}
function clearStoredSelections() {
selectedCards.clear();
const storage = getSelectionStorage();
if (!storage) return;
removeStorageItem(storage, SELECTION_STORAGE_KEY);
}
function getEntryFromWrapper(wrapper) {
const charInput = getCharacterInputFromWrapper(wrapper, documentRef);
if (!charInput) return null;
const characterId = extractCharacterIdFromInput(charInput);
if (!characterId) return null;
return {
wrapper,
characterId,
characterUrl: charInput,
title: getCharacterNameFromWrapper(wrapper) || characterId
};
}
function getVisibleWrapperEntries() {
return Array.from(documentRef.querySelectorAll(PAGE_CARD_WRAPPER_SELECTOR)).map((wrapper) => getEntryFromWrapper(wrapper)).filter(Boolean);
}
function getSelectionObserverTargets() {
const wrappers = Array.from(documentRef.querySelectorAll(PAGE_CARD_WRAPPER_SELECTOR));
const fallback = documentRef.body ?? documentRef.documentElement;
if (wrappers.length === 0) {
return fallback ? [fallback] : [];
}
const targets = /* @__PURE__ */ new Set();
wrappers.forEach((wrapper) => {
const target = wrapper.parentElement ?? fallback;
if (target) targets.add(target);
});
return Array.from(targets);
}
function ensureSelectionOverlay(wrapper) {
if (wrapper.querySelector(`.${SELECTION_OVERLAY_CLASS}`)) return;
const win = documentRef.defaultView ?? globalThis;
const pos = win.getComputedStyle(wrapper).position;
if (!pos || pos === "static") {
if (!wrapperPositionCache.has(wrapper)) {
wrapperPositionCache.set(wrapper, wrapper.style.position);
}
wrapper.style.position = "relative";
}
const overlay = documentRef.createElement("div");
overlay.className = SELECTION_OVERLAY_CLASS;
wrapper.appendChild(overlay);
}
function removeSelectionOverlay(wrapper) {
wrapper.querySelector(`.${SELECTION_OVERLAY_CLASS}`)?.remove();
if (wrapperPositionCache.has(wrapper)) {
wrapper.style.position = wrapperPositionCache.get(wrapper) ?? "";
wrapperPositionCache.delete(wrapper);
}
}
function syncSelectionOverlays() {
const entries = getVisibleWrapperEntries();
entries.forEach(({ wrapper, characterId }) => {
if (selectedCards.has(characterId)) {
ensureSelectionOverlay(wrapper);
} else {
removeSelectionOverlay(wrapper);
}
});
}
function scheduleSelectionSync() {
if (selectionSyncRaf) return;
const win = documentRef.defaultView ?? globalThis;
selectionSyncRaf = win.requestAnimationFrame(() => {
selectionSyncRaf = null;
syncSelectionOverlays();
updateHudCount();
});
}
function startSelectionObserver() {
stopSelectionObserver();
const targets = getSelectionObserverTargets();
if (targets.length === 0) return;
selectionObserver = new MutationObserver(() => scheduleSelectionSync());
targets.forEach((target) => {
selectionObserver.observe(target, { childList: true, subtree: true });
});
}
function stopSelectionObserver() {
selectionObserver?.disconnect();
selectionObserver = null;
if (selectionSyncRaf) {
const win = documentRef.defaultView ?? globalThis;
win.cancelAnimationFrame?.(selectionSyncRaf);
selectionSyncRaf = null;
}
}
function setSelectionMode(active) {
groupEl.classList.toggle(SELECTION_MODE_CLASS, active);
backdropEl.classList.toggle(SELECTION_MODE_BACKDROP_CLASS, active);
documentRef.documentElement?.classList.toggle(SELECTION_MODE_PAGE_CLASS, active);
hud.classList.toggle("is-active", active);
hud.setAttribute("aria-hidden", active ? "false" : "true");
if (active) {
backdropEl.style.pointerEvents = "none";
hud.style.pointerEvents = "";
enterPageSelectionMode();
} else {
backdropEl.style.pointerEvents = "";
hud.style.pointerEvents = "";
exitPageSelectionMode();
}
}
function updateHudCount() {
if (hudConfirmBtn) {
hudConfirmBtn.textContent = `Confirm (${selectedCards.size})`;
}
if (hudSelectBtn) {
const entries = getVisibleWrapperEntries();
const selectedVisible = entries.filter((entry) => selectedCards.has(entry.characterId)).length;
const allChecked = entries.length > 0 && selectedVisible === entries.length;
const someChecked = selectedVisible > 0 && !allChecked;
updateSelectionHudIcons(hudSelectBtn, { allChecked, someChecked });
}
}
hud.addEventListener("click", (event) => {
event.stopPropagation();
}, { signal });
function onPageCardClick(event) {
let target = event.target;
if (!target || typeof target.closest !== "function") {
target = target?.parentElement;
}
const wrapper = target?.closest?.(PAGE_CARD_WRAPPER_SELECTOR);
if (!wrapper) return;
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
const entry = getEntryFromWrapper(wrapper);
if (!entry) return;
if (selectedCards.has(entry.characterId)) {
selectedCards.delete(entry.characterId);
removeSelectionOverlay(wrapper);
persistSelections();
updateHudCount();
return;
}
selectedCards.set(entry.characterId, {
characterId: entry.characterId,
title: entry.title,
characterUrl: entry.characterUrl
});
ensureSelectionOverlay(wrapper);
persistSelections();
updateHudCount();
}
let pageClickHandler = null;
function enterPageSelectionMode() {
if (pageClickHandler) exitPageSelectionMode();
loadStoredSelections();
syncSelectionOverlays();
updateHudCount();
pageClickHandler = (event) => {
if (hud.contains(event.target) || groupEl.contains(event.target)) return;
onPageCardClick(event);
};
documentRef.addEventListener("click", pageClickHandler, { capture: true, signal });
startSelectionObserver();
}
function exitPageSelectionMode() {
if (pageClickHandler) {
documentRef.removeEventListener("click", pageClickHandler, { capture: true });
pageClickHandler = null;
}
stopSelectionObserver();
documentRef.querySelectorAll(`.${SELECTION_OVERLAY_CLASS}`).forEach((el) => {
const wrapper = el.parentElement;
if (wrapper) removeSelectionOverlay(wrapper);
else el.remove();
});
}
function confirmSelection() {
const batchId = generateId();
const doAutoFetch = isAutoFetchEnabled();
const doAutoExport = doAutoFetch && isAutoExportEnabled();
let addedCount = 0;
for (const [, data] of selectedCards) {
const added = queue.addItem(data.characterId, data.title, data.characterUrl, {
autoFetch: doAutoFetch,
autoExport: doAutoExport,
batchId: doAutoFetch ? batchId : null
});
if (added) addedCount += 1;
}
clearStoredSelections();
setSelectionMode(false);
if (addedCount > 0) {
tryAutoFetch();
}
}
function cancelSelection() {
setSelectionMode(false);
}
selectCharsBtn?.addEventListener("click", () => {
setSelectionMode(true);
}, { signal });
hudConfirmBtn?.addEventListener("click", () => {
confirmSelection();
}, { signal });
hudCloseBtn?.addEventListener("click", () => {
cancelSelection();
}, { signal });
hudSelectBtn?.addEventListener("click", () => {
const entries = getVisibleWrapperEntries();
const selectedVisible = entries.filter((entry) => selectedCards.has(entry.characterId)).length;
const allVisibleSelected = entries.length > 0 && selectedVisible === entries.length;
if (allVisibleSelected) {
entries.forEach(({ wrapper, characterId }) => {
if (!selectedCards.has(characterId)) return;
selectedCards.delete(characterId);
removeSelectionOverlay(wrapper);
});
} else {
entries.forEach((entry) => {
if (!selectedCards.has(entry.characterId)) {
selectedCards.set(entry.characterId, {
characterId: entry.characterId,
title: entry.title,
characterUrl: entry.characterUrl
});
}
ensureSelectionOverlay(entry.wrapper);
});
}
persistSelections();
updateHudCount();
}, { signal });
function destroy() {
setSelectionMode(false);
stopSelectionObserver();
controller.abort();
}
return { destroy };
}
const scrollCacheByDocument = /* @__PURE__ */ new WeakMap();
function getScrollCache(documentRef) {
const doc = documentRef?.defaultView?.document ?? documentRef ?? globalThis.document;
if (!doc || typeof doc !== "object") {
return { sidebar: 0, queue: 0 };
}
let cache = scrollCacheByDocument.get(doc);
if (!cache) {
cache = { sidebar: 0, queue: 0 };
scrollCacheByDocument.set(doc, cache);
}
return cache;
}
function createBatchQueueUI({
documentRef,
view,
queue,
queueBox,
selectAllCheckbox,
bulkFetchBtn,
bulkDownloadBtn,
bulkRemoveBtn,
progressStatsSpan,
progressFill,
onAction,
onSelectionChange
}) {
let checkedCharacterIds = /* @__PURE__ */ new Set();
let initializing = false;
const scrollCache = getScrollCache(documentRef);
const controller = new AbortController();
const { signal } = controller;
let fetchedAtIntervalId = null;
let activeObserver = null;
const rowCache = /* @__PURE__ */ new Map();
const filterToggleBtn = view.querySelector(".jan-batch-filter-toggle");
const filterMenu = view.querySelector(".jan-batch-filter-menu");
const filterItems = Array.from(view.querySelectorAll(".jan-batch-filter-item"));
const currentFilterText = view.querySelector(".jan-batch-filter-text");
const currentFilterDot = view.querySelector(".jan-batch-filter-dot--current");
const filterItemsByValue = /* @__PURE__ */ new Map();
let currentFilter = "all";
filterItems.forEach((item) => {
const value = item.getAttribute("data-filter");
if (value) filterItemsByValue.set(value, item);
});
const initialFilterItem = filterItems.find((item) => item.classList.contains("is-active")) ?? filterItems[0];
if (initialFilterItem) {
currentFilter = initialFilterItem.getAttribute("data-filter") || "all";
syncFilterIndicator(initialFilterItem);
}
function setInitializing(value) {
initializing = value;
}
function setCheckedCharacterIds(ids) {
checkedCharacterIds = new Set(ids);
}
function getCheckedCharacterIds() {
return new Set(checkedCharacterIds);
}
function createQueueRowElement(item) {
const doc = documentRef?.defaultView?.document ?? documentRef ?? globalThis.document;
if (!doc) return null;
const template = doc.createElement("template");
template.innerHTML = buildQueueItemMarkupWithId(item).trim();
return template.content.firstElementChild;
}
function updateQueueRowElement(row, item) {
if (!row) return;
const statusClass = item.visualStatus ? ` ${item.visualStatus}` : "";
const selectedClass = item.checked ? " is-selected" : "";
row.className = `jan-batch-queue-item${statusClass}${selectedClass}`;
row.setAttribute("data-queue-id", item.queueId);
const checkbox = row.querySelector(".jan-batch-cb-row");
if (checkbox) {
checkbox.checked = Boolean(item.checked);
checkbox.setAttribute("data-status", item.actionStatus ?? "");
checkbox.setAttribute("data-queue-id", item.queueId);
}
const titleEl = row.querySelector(".jan-batch-item-title");
if (titleEl) {
titleEl.textContent = String(item.title ?? "");
if (titleEl.tagName?.toLowerCase() === "a") {
if (item.characterUrl) {
titleEl.setAttribute("href", item.characterUrl);
titleEl.setAttribute("target", "_blank");
titleEl.setAttribute("rel", "noopener noreferrer");
} else {
titleEl.removeAttribute("href");
titleEl.removeAttribute("target");
titleEl.removeAttribute("rel");
}
}
}
const statusEl = row.querySelector(".jan-batch-item-status-text");
if (statusEl) {
statusEl.textContent = String(item.statusText ?? "");
if (item.fullError) {
statusEl.setAttribute("title", item.fullError);
statusEl.setAttribute("data-full-error", item.fullError);
} else {
statusEl.removeAttribute("title");
statusEl.removeAttribute("data-full-error");
}
}
const progressFill2 = row.querySelector(".jan-batch-item-min-progress-fill");
if (progressFill2) {
const progressValue = Number.isFinite(item.progress) ? item.progress : 0;
progressFill2.style.width = `${progressValue}%`;
}
const iconEl = row.querySelector(".jan-batch-item-icon");
if (iconEl) {
replaceSvgContent(iconEl, item.iconSvg, documentRef);
}
const primaryButton = row.querySelector('.jan-batch-item-actions button[data-action]:not([data-action="remove"])');
if (primaryButton) {
const primaryClass = item.primaryButtonClass ? `jan-batch-icon-btn ${item.primaryButtonClass}`.trim() : "jan-batch-icon-btn";
primaryButton.className = primaryClass;
primaryButton.title = String(item.primaryButtonTitle ?? "");
primaryButton.setAttribute("data-action", item.primaryAction ?? "none");
primaryButton.setAttribute("data-queue-id", item.queueId);
replaceSvgContent(primaryButton, item.primaryButtonIcon, documentRef);
}
const removeButton = row.querySelector('button[data-action="remove"]');
if (removeButton) {
removeButton.setAttribute("data-queue-id", item.queueId);
}
}
function getRowCheckboxes() {
return Array.from(queueBox.querySelectorAll(".jan-batch-cb-row"));
}
function getCheckedQueueIds() {
return getRowCheckboxes().filter((cb) => cb.checked).map((cb) => cb.getAttribute("data-queue-id")).filter(Boolean);
}
function getFilteredCheckboxes(rowCheckboxes) {
if (currentFilter === "all") return rowCheckboxes;
return rowCheckboxes.filter((cb) => cb.getAttribute("data-status") === currentFilter);
}
function syncFilterIndicator(item) {
filterItems.forEach((i2) => i2.classList.toggle("is-active", i2 === item));
const labelText = item?.querySelector(".jan-batch-filter-label")?.textContent?.trim();
if (currentFilterText) currentFilterText.textContent = labelText || "All";
const itemDot = item?.querySelector(".jan-batch-filter-dot");
if (itemDot && currentFilterDot) {
currentFilterDot.style.background = itemDot.style.background;
currentFilterDot.style.boxShadow = itemDot.style.boxShadow;
}
}
function closeFilterMenu() {
filterMenu?.classList.remove("show");
filterToggleBtn?.classList.remove("open");
filterToggleBtn?.setAttribute("aria-expanded", "false");
}
function applyFilterSelection() {
const rowCheckboxes = getRowCheckboxes();
rowCheckboxes.forEach((cb) => {
const status = cb.getAttribute("data-status");
const shouldBeChecked = currentFilter === "all" || status === currentFilter;
if (cb.checked !== shouldBeChecked) cb.checked = shouldBeChecked;
});
syncSelectionState();
}
function setCurrentFilter(filter, { applySelection = false } = {}) {
if (!filter) return;
currentFilter = filter;
const item = filterItemsByValue.get(filter);
if (item) syncFilterIndicator(item);
if (applySelection) {
applyFilterSelection();
} else {
syncSelectionState();
}
}
function syncCheckedCharacterIdsFromDom() {
const currentUuids = new Set(getCheckedQueueIds());
const queueItems = queue.getItems();
checkedCharacterIds = new Set(
queueItems.filter((qi) => currentUuids.has(qi.id)).map((qi) => qi.characterId)
);
}
function syncSelectionState() {
const rowCheckboxes = getRowCheckboxes();
const selectedCheckboxes = rowCheckboxes.filter((cb) => cb.checked);
const targetCheckboxes = getFilteredCheckboxes(rowCheckboxes);
const allChecked = targetCheckboxes.length > 0 && targetCheckboxes.every((cb) => cb.checked);
const someChecked = targetCheckboxes.some((cb) => cb.checked) && !allChecked;
rowCheckboxes.forEach((cb) => {
cb.closest(".jan-batch-queue-item")?.classList.toggle("is-selected", cb.checked);
});
if (selectAllCheckbox) {
selectAllCheckbox.checked = allChecked;
selectAllCheckbox.indeterminate = someChecked;
}
if (bulkFetchBtn && bulkDownloadBtn && bulkRemoveBtn) {
updateBulkActions({
selectedCheckboxes,
bulkFetchBtn,
bulkDownloadBtn,
bulkRemoveBtn
});
}
syncCheckedCharacterIdsFromDom();
if (!initializing) {
onSelectionChange?.(getCheckedCharacterIds());
}
}
function renderQueue(items) {
const rowCheckboxesExist = !initializing && queueBox.querySelector(".jan-batch-cb-row") !== null;
if (rowCheckboxesExist) {
syncCheckedCharacterIdsFromDom();
}
const markupItems = items.map(mapQueueEntryToMarkupItem);
markupItems.forEach((item) => {
item.checked = checkedCharacterIds.has(item.characterId);
});
const savedQueueScroll = queueBox.scrollTop;
const nextIds = new Set(markupItems.map((item) => item.queueId));
for (const [queueId, row] of rowCache.entries()) {
if (!nextIds.has(queueId)) {
row.remove();
rowCache.delete(queueId);
}
}
markupItems.forEach((item) => {
let row = rowCache.get(item.queueId);
if (!row) {
row = createQueueRowElement(item);
if (!row) return;
rowCache.set(item.queueId, row);
} else {
updateQueueRowElement(row, item);
}
queueBox.appendChild(row);
});
if (savedQueueScroll) queueBox.scrollTop = savedQueueScroll;
const stats = queue.getStats();
const completed = stats.ready + stats.error;
if (progressStatsSpan) {
progressStatsSpan.textContent = `${completed} / ${stats.total} Tasks`;
}
if (progressFill) {
const pct = stats.total > 0 ? (completed / stats.total * 100).toFixed(2) : 0;
progressFill.style.width = `${pct}%`;
}
syncSelectionState();
}
function attachEventListeners() {
filterToggleBtn?.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
const willOpen = !filterMenu?.classList.contains("show");
if (willOpen) {
filterMenu?.classList.add("show");
filterToggleBtn.classList.add("open");
filterToggleBtn.setAttribute("aria-expanded", "true");
} else {
closeFilterMenu();
}
}, { signal });
filterItems.forEach((item) => {
item.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
const filterValue = item.getAttribute("data-filter") || "all";
setCurrentFilter(filterValue, { applySelection: true });
closeFilterMenu();
}, { signal });
});
documentRef.addEventListener("click", (event) => {
if (!filterMenu || !filterToggleBtn) return;
if (filterMenu.contains(event.target) || filterToggleBtn.contains(event.target)) return;
closeFilterMenu();
}, { signal });
queueBox.addEventListener("change", (event) => {
if (event.target?.classList?.contains("jan-batch-cb-row")) {
const status = event.target.getAttribute("data-status");
if (event.target.checked && currentFilter !== "all" && status && status !== currentFilter) {
setCurrentFilter("all", { applySelection: false });
}
syncSelectionState();
}
}, { signal });
queueBox.addEventListener("click", (event) => {
if (event.target?.closest("a.jan-batch-item-title")) {
event.stopPropagation();
return;
}
const actionBtn = event.target.closest("[data-action][data-queue-id]");
if (!actionBtn) return;
event.preventDefault();
event.stopPropagation();
const action = actionBtn.getAttribute("data-action");
const queueId = actionBtn.getAttribute("data-queue-id");
if (!action || !queueId) return;
onAction?.({ action, queueId });
}, { signal });
const copyTimers = /* @__PURE__ */ new WeakMap();
queueBox.addEventListener("click", (event) => {
const statusEl = event.target.closest(".jan-batch-item-status-text[data-full-error]");
if (!statusEl) return;
event.preventDefault();
event.stopPropagation();
const fullError = statusEl.getAttribute("data-full-error");
if (!fullError) return;
if (copyTimers.has(statusEl)) return;
const win = documentRef.defaultView ?? globalThis;
win.navigator?.clipboard?.writeText(fullError).then(() => {
if (!statusEl.hasAttribute("data-original-text")) {
statusEl.setAttribute("data-original-text", statusEl.textContent);
}
statusEl.textContent = "Copied!";
const timerId = setTimeout(() => {
copyTimers.delete(statusEl);
const original = statusEl.getAttribute("data-original-text") ?? "";
statusEl.textContent = original;
statusEl.removeAttribute("data-original-text");
}, 1200);
copyTimers.set(statusEl, timerId);
}).catch(() => {
win.prompt?.("Copy error:", fullError);
});
}, { signal });
selectAllCheckbox?.addEventListener("change", () => {
const rowCheckboxes = getRowCheckboxes();
rowCheckboxes.forEach((cb) => {
const status = cb.getAttribute("data-status");
const shouldApply = currentFilter === "all" || status === currentFilter;
if (shouldApply) cb.checked = selectAllCheckbox.checked;
});
syncSelectionState();
}, { signal });
}
function tickBatchFetchedAtNow() {
if (documentRef.hidden || view.offsetHeight === 0) return;
queueBox.querySelectorAll(".jan-batch-queue-item.status-success .jan-batch-item-status-text").forEach((el) => {
const queueId = el.closest("[data-queue-id]")?.getAttribute("data-queue-id");
const entry = queueId ? queue.getItem(queueId) : null;
if (entry?.fetchedAt) el.textContent = formatFetchedAt(entry.fetchedAt);
});
}
function setupFetchedAtTicker() {
if (fetchedAtIntervalId != null) {
globalThis.clearInterval(fetchedAtIntervalId);
}
fetchedAtIntervalId = globalThis.setInterval(() => {
tickBatchFetchedAtNow();
}, 1e4);
activeObserver?.disconnect();
activeObserver = new MutationObserver(() => {
if (view.classList.contains("is-active")) tickBatchFetchedAtNow();
});
activeObserver.observe(view, { attributeFilter: ["class"] });
documentRef.addEventListener("visibilitychange", () => {
if (!documentRef.hidden) tickBatchFetchedAtNow();
}, { signal });
}
function attachScrollCache() {
view.querySelector(".jan-batch-sidebar")?.addEventListener("scroll", (e) => {
scrollCache.sidebar = e.currentTarget.scrollTop;
}, { passive: true, signal });
queueBox?.addEventListener("scroll", () => {
scrollCache.queue = queueBox.scrollTop;
}, { passive: true, signal });
}
function restoreScroll() {
if (scrollCache.sidebar || scrollCache.queue) {
const win = documentRef.defaultView ?? globalThis;
win.requestAnimationFrame(() => {
const sidebar = view.querySelector(".jan-batch-sidebar");
if (sidebar && scrollCache.sidebar) sidebar.scrollTop = scrollCache.sidebar;
if (queueBox && scrollCache.queue) queueBox.scrollTop = scrollCache.queue;
});
}
}
function destroy() {
controller.abort();
if (fetchedAtIntervalId != null) {
globalThis.clearInterval(fetchedAtIntervalId);
fetchedAtIntervalId = null;
}
activeObserver?.disconnect();
activeObserver = null;
rowCache.clear();
}
return {
attachEventListeners,
attachScrollCache,
destroy,
getCheckedCharacterIds,
getCheckedQueueIds,
renderQueue,
restoreScroll,
setCheckedCharacterIds,
setInitializing,
setupFetchedAtTicker,
syncSelectionState
};
}
function saveQueueToSession({ documentRef, queue }) {
const storage = getSessionStorage(documentRef);
if (!storage) return;
const serializable = queue.getItems().map((item) => {
const base = {
id: item.id,
characterId: item.characterId,
title: item.title,
characterUrl: item.characterUrl,
autoFetch: item.autoFetch,
autoExport: item.autoExport,
batchId: item.batchId,
retrying: false,
retryCount: item.retryCount
};
if (item.status === "ready" && item.normalizedCharacter) {
return {
...base,
status: "ready",
progress: 100,
error: null,
normalizedCharacter: item.normalizedCharacter,
artifactFormat: item.artifactFormat ?? null,
artifactFilename: item.downloadArtifact?.filename ?? null,
fetchedAt: item.fetchedAt ?? null
};
}
if (item.status === "error") {
return {
...base,
status: "error",
progress: 0,
error: item.error ?? null,
artifactFormat: null
};
}
return {
...base,
status: "pending",
progress: 0,
error: null,
artifactFormat: null
};
});
writeJson(storage, QUEUE_STORAGE_KEY, serializable);
}
function restoreQueueFromSession({ documentRef, queue }) {
const storage = getSessionStorage(documentRef);
if (!storage) return;
const items = readJson(storage, QUEUE_STORAGE_KEY, null);
if (!Array.isArray(items)) return;
items.forEach((item) => {
if (!item?.characterId) return;
queue.addItem(item.characterId, item.title, item.characterUrl, {
autoFetch: false,
// don't re-trigger auto-fetch on restore
autoExport: false,
batchId: item.batchId ?? null
});
const added = queue.getItems().find((q) => q.characterId === item.characterId);
if (!added) return;
if (item.status === "ready" && item.normalizedCharacter) {
queue.updateItem(added.id, {
status: "ready",
progress: 100,
normalizedCharacter: item.normalizedCharacter,
artifactFormat: item.artifactFormat ?? null,
downloadArtifact: item.artifactFilename ? { filename: item.artifactFilename, content: null, mimeType: null } : null,
fetchedAt: item.fetchedAt ?? null
});
} else if (item.status === "error" && item.error) {
queue.updateItem(added.id, {
status: "error",
error: item.error,
retryCount: item.retryCount ?? 0
});
}
});
}
function loadSettings({ documentRef, view, autoFetchToggle, autoExportToggle, autoRetryToggle, autoRetryCountInput }) {
const storage = getLocalStorage(documentRef);
if (!storage) return;
const s = readJson(storage, SETTINGS_STORAGE_KEY, null);
if (!s) return;
if (typeof s.autoFetch === "boolean" && autoFetchToggle) autoFetchToggle.checked = s.autoFetch;
if (typeof s.autoExport === "boolean" && autoExportToggle) autoExportToggle.checked = s.autoExport;
if (typeof s.autoRetry === "boolean" && autoRetryToggle) autoRetryToggle.checked = s.autoRetry;
if (typeof s.autoRetryCount === "number" && autoRetryCountInput) {
autoRetryCountInput.value = Math.min(20, Math.max(1, s.autoRetryCount));
}
if (s.exportMode) {
const modeInput = view.querySelector(`input[name="jan-batch-download-mode"][value="${s.exportMode}"]`);
if (modeInput) modeInput.checked = true;
}
if (s.format) {
const fmtInput = view.querySelector(`input[name="jan-batch-export-format"][value="${s.format}"]`);
if (fmtInput) fmtInput.checked = true;
}
}
function saveSettings({
documentRef,
autoFetchToggle,
autoExportToggle,
autoRetryToggle,
autoRetryCountInput,
getSelectedDownloadMode,
getSelectedExportFormat
}) {
const storage = getLocalStorage(documentRef);
if (!storage) return;
const parsedAutoRetryCount = Number.parseInt(autoRetryCountInput?.value ?? "", 10);
const autoRetryCount = Number.isFinite(parsedAutoRetryCount) && parsedAutoRetryCount >= 1 ? Math.min(20, parsedAutoRetryCount) : null;
writeJson(storage, SETTINGS_STORAGE_KEY, {
autoFetch: autoFetchToggle?.checked ?? true,
autoExport: autoExportToggle?.checked ?? false,
autoRetry: autoRetryToggle?.checked ?? false,
autoRetryCount: Number.isFinite(autoRetryCount) ? autoRetryCount : null,
exportMode: getSelectedDownloadMode(),
format: getSelectedExportFormat()
});
}
function loadCheckedIds({ documentRef }) {
const storage = getSessionStorage(documentRef);
if (!storage) return /* @__PURE__ */ new Set();
const raw = readJson(storage, CHECKED_STORAGE_KEY, null);
if (Array.isArray(raw)) return new Set(raw);
return /* @__PURE__ */ new Set();
}
function saveCheckedIds({ documentRef, checkedCharacterIds }) {
const storage = getSessionStorage(documentRef);
if (!storage) return;
if (!checkedCharacterIds || checkedCharacterIds.size === 0) {
removeStorageItem(storage, CHECKED_STORAGE_KEY);
return;
}
writeJson(storage, CHECKED_STORAGE_KEY, Array.from(checkedCharacterIds));
}
function createBatchDownloadTab({
documentRef,
groupEl,
backdropEl,
janitorClient = null,
scrapeCharacterCard = null,
generateAlphaProxyConfig = null
}) {
ensureBatchDownloadTabStyle(documentRef);
ensureSelectionOverlayStyle(documentRef);
const tabController = new AbortController();
const { signal } = tabController;
let isDestroyed = false;
const viewerClient = janitorClient ?? createJanitorClient({
documentRef
});
const baseScrapeCharacterCard = typeof scrapeCharacterCard === "function" ? scrapeCharacterCard : createInternalOrchestrator({ client: viewerClient }).scrapeCharacterCard;
const scrapeCharacterCardImpl = (options = {}) => baseScrapeCharacterCard({
...options,
generateAlphaProxyConfig
});
const queue = createBatchQueue();
const view = documentRef.createElement("section");
view.className = "jan-panel-tab-view jan-batch-view";
view.innerHTML = createBatchViewMarkup();
const hud = documentRef.createElement("div");
hud.className = "jan-batch-selection-hud";
hud.setAttribute("aria-hidden", "true");
hud.innerHTML = createSelectionHudMarkup();
backdropEl.appendChild(hud);
const selectAllCheckbox = view.querySelector(".jan-batch-select-all");
const bulkFetchBtn = view.querySelector(".jan-batch-bulk-fetch");
const bulkDownloadBtn = view.querySelector(".jan-batch-bulk-download");
const bulkRemoveBtn = view.querySelector(".jan-batch-bulk-remove");
const selectCharsBtn = view.querySelector(".jan-batch-select-chars-btn");
const queueBox = view.querySelector(".jan-batch-queue-box");
const progressStatsSpan = view.querySelector(".jan-batch-progress-stats span");
const progressFill = view.querySelector(".jan-batch-global-progress-fill");
const autoFetchToggle = view.querySelector(".jan-batch-auto-fetch");
const autoExportToggle = view.querySelector(".jan-batch-auto-export");
const autoRetryToggle = view.querySelector(".jan-batch-auto-retry");
const autoRetryCountInput = view.querySelector(".jan-batch-auto-retry-count");
const hudSelectBtn = hud.querySelector(".jan-batch-hud-select-btn");
const hudConfirmBtn = hud.querySelector(".jan-batch-hud-confirm-btn");
const hudCloseBtn = hud.querySelector(".jan-batch-hud-close-btn");
function getSelectedExportFormat() {
return view.querySelector('input[name="jan-batch-export-format"]:checked')?.value ?? "png";
}
function getSelectedDownloadMode() {
return view.querySelector('input[name="jan-batch-download-mode"]:checked')?.value ?? "files";
}
function isAutoRetryEnabled() {
return autoRetryToggle?.checked ?? false;
}
function getAutoRetryCount() {
if (!autoRetryCountInput) return null;
const raw = autoRetryCountInput.value.trim();
if (!raw) return null;
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed < 1) return null;
return Math.min(parsed, 20);
}
function persistSettings() {
saveSettings({
documentRef,
autoFetchToggle,
autoExportToggle,
autoRetryToggle,
autoRetryCountInput,
getSelectedDownloadMode,
getSelectedExportFormat
});
}
function syncAutoExportRowState() {
const autoExportRow = autoExportToggle?.closest(".jan-batch-setting-row");
const enabled = autoFetchToggle?.checked ?? true;
autoExportRow?.classList.toggle("is-dimmed", !enabled);
if (!enabled && autoExportToggle?.checked) {
autoExportToggle.checked = false;
}
}
function syncAutoRetryRowState() {
if (autoRetryCountInput) {
autoRetryCountInput.disabled = !autoRetryToggle?.checked;
}
}
autoFetchToggle?.addEventListener("change", () => {
syncAutoExportRowState();
syncAutoRetryRowState();
persistSettings();
}, { signal });
autoExportToggle?.addEventListener("change", () => {
persistSettings();
}, { signal });
autoRetryToggle?.addEventListener("change", () => {
syncAutoRetryRowState();
persistSettings();
}, { signal });
autoRetryCountInput?.addEventListener("input", () => {
const raw = autoRetryCountInput.value;
if (raw !== "") {
const val = parseInt(raw, 10);
if (isNaN(val) || val < 1) {
autoRetryCountInput.value = 1;
} else if (val > 20) {
autoRetryCountInput.value = 20;
}
}
persistSettings();
}, { signal });
syncAutoExportRowState();
syncAutoRetryRowState();
const formatInputs = Array.from(view.querySelectorAll('input[name="jan-batch-export-format"]'));
formatInputs.forEach((input) => {
input.addEventListener("change", () => {
syncFormatCards(view);
persistSettings();
}, { signal });
});
Array.from(view.querySelectorAll('input[name="jan-batch-download-mode"]')).forEach((radio) => {
radio.addEventListener("change", () => persistSettings(), { signal });
});
syncFormatCards(view);
const fetchController = createBatchFetchController({
queue,
documentRef,
mountEl: view,
scrapeCharacterCard: scrapeCharacterCardImpl,
getSelectedExportFormat,
getSelectedDownloadMode,
triggerArtifactDownload,
downloadBulkAsZip,
isAutoRetryEnabled,
getAutoRetryCount
});
const queueUi = createBatchQueueUI({
documentRef,
view,
queue,
queueBox,
selectAllCheckbox,
bulkFetchBtn,
bulkDownloadBtn,
bulkRemoveBtn,
progressStatsSpan,
progressFill,
onAction: ({ action, queueId }) => {
if (action === "remove") {
queue.removeItem(queueId);
return;
}
if (action === "fetch") {
fetchController.cancelAutoRetryTimer(queueId);
fetchController.fetchSingleItem(queueId);
return;
}
if (action === "view") {
const entry = queue.getItem(queueId);
if (entry?.characterUrl) {
const viewerTab = documentRef.querySelector(".jan-character-viewer-view");
if (viewerTab) {
const viewBtn = documentRef.querySelector('.jan-bar-tab[aria-label="Character Viewer"]');
if (viewBtn) viewBtn.click();
unloadCharacterViewer(viewerTab);
loadCharacterViewerInput(viewerTab, entry.characterUrl);
}
}
}
},
onSelectionChange: (checkedCharacterIds) => {
saveCheckedIds({ documentRef, checkedCharacterIds });
}
});
queueUi.attachEventListeners();
queueUi.attachScrollCache();
queueUi.setupFetchedAtTicker();
const selectionController = setupBatchSelection({
documentRef,
groupEl,
backdropEl,
hud,
selectCharsBtn,
hudSelectBtn,
hudConfirmBtn,
hudCloseBtn,
queue,
isAutoFetchEnabled: () => autoFetchToggle?.checked ?? true,
isAutoExportEnabled: () => autoExportToggle?.checked ?? false,
tryAutoFetch: fetchController.tryAutoFetch
});
bulkFetchBtn?.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
const ids = queueUi.getCheckedQueueIds();
fetchController.fetchBulk(ids);
}, { signal });
bulkDownloadBtn?.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
const ids = queueUi.getCheckedQueueIds();
const entries = ids.map((id) => queue.getItem(id)).filter((entry) => entry && entry.status === "ready" && entry.downloadArtifact);
downloadBulkEntries({
entries,
exportMode: getSelectedDownloadMode(),
exportFormat: getSelectedExportFormat(),
documentRef,
mountEl: view
});
}, { signal });
bulkRemoveBtn?.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
const ids = queueUi.getCheckedQueueIds();
queue.removeItems(ids);
}, { signal });
loadSettings({ documentRef, view, autoFetchToggle, autoExportToggle, autoRetryToggle, autoRetryCountInput });
syncAutoExportRowState();
syncAutoRetryRowState();
syncFormatCards(view);
queueUi.setInitializing(true);
restoreQueueFromSession({ documentRef, queue });
queueUi.setCheckedCharacterIds(loadCheckedIds({ documentRef }));
queueUi.renderQueue(queue.getItems());
queueUi.setInitializing(false);
const win = documentRef.defaultView ?? globalThis;
const scheduleRenderQueue = createRafScheduler$1((items) => queueUi.renderQueue(items), win);
const schedulePersistQueue = createDebounced(
() => saveQueueToSession({ documentRef, queue }),
350,
win
);
const unsubscribeQueue = queue.subscribe((items) => {
scheduleRenderQueue(items);
schedulePersistQueue();
});
queueUi.restoreScroll();
function destroy() {
if (isDestroyed) return;
isDestroyed = true;
schedulePersistQueue.flush?.();
schedulePersistQueue.cancel?.();
scheduleRenderQueue.cancel?.();
unsubscribeQueue?.();
queueUi.destroy?.();
fetchController.destroy?.();
selectionController?.destroy?.();
tabController.abort();
hud.remove();
}
view.__janDestroy = destroy;
return view;
}
function renderBatchDownloadPane() {
return `
<div class="settings-pane" id="pane-batch-download">
<div class="setting-group">
<div class="setting-group-title">Export Options</div>
<div class="setting-row">
<div class="setting-label-block">
<span class="setting-label">ZIP Compression Level</span>
<span id="hint-exp_batch_zip" class="setting-desc">No compression so it's the fastest.</span>
</div>
<div class="inline-segment">
<label><input type="radio" class="setting-control" data-key="exp_batch_zip" name="exp_batch_zip" value="store" data-hint="No compression so it's the fastest." checked><span class="inline-seg-btn">Store</span></label>
<label><input type="radio" class="setting-control" data-key="exp_batch_zip" name="exp_batch_zip" value="fast" data-hint="Level 1. Very fast compression with small size savings."><span class="inline-seg-btn">Fast</span></label>
<label><input type="radio" class="setting-control" data-key="exp_batch_zip" name="exp_batch_zip" value="standard" data-hint="Level 6. Balanced speed and file size."><span class="inline-seg-btn">Standard</span></label>
<label><input type="radio" class="setting-control" data-key="exp_batch_zip" name="exp_batch_zip" value="max" data-hint="Level 9. Smallest files, slowest export."><span class="inline-seg-btn">Maximum</span></label>
</div>
</div>
</div>
</div>
`;
}
function renderCharacterViewerPane() {
return `
<div class="settings-pane" id="pane-character-viewer">
<div class="setting-group">
<div class="setting-group-title">Visual</div>
<div class="setting-row">
<div class="setting-label-block">
<span class="setting-label">Name Display</span>
<span class="setting-desc">Choose which name shows in the viewer sidebar</span>
</div>
<div class="inline-segment">
<label><input type="radio" class="setting-control" data-key="vis_name_display" name="vis_name_display" value="chat" checked><span class="inline-seg-btn">Always Chat Name</span></label>
<label><input type="radio" class="setting-control" data-key="vis_name_display" name="vis_name_display" value="name"><span class="inline-seg-btn">Always Name</span></label>
</div>
</div>
<div class="setting-row col">
<div class="setting-label-block">
<span class="setting-label">Character Avatar</span>
<span class="setting-desc">Choose how avatars are shown</span>
</div>
<div class="choice-cards-grid">
<label class="choice-card">
<input type="radio" class="setting-control" data-key="vis_char_avatar" name="vis_char_avatar" value="none" checked>
<div class="choice-card-icon">${SETTINGS_AVATAR_NONE_ICON_SVG}</div>
<span class="choice-card-title">None</span>
<span class="choice-card-desc">No filter</span>
</label>
<label class="choice-card">
<input type="radio" class="setting-control" data-key="vis_char_avatar" name="vis_char_avatar" value="blur">
<div class="choice-card-icon">${SETTINGS_AVATAR_BLUR_ICON_SVG}</div>
<span class="choice-card-title">Blur NSFW</span>
<span class="choice-card-desc">Blur NSFW avatars</span>
</label>
<label class="choice-card">
<input type="radio" class="setting-control" data-key="vis_char_avatar" name="vis_char_avatar" value="off">
<div class="choice-card-icon">${SETTINGS_AVATAR_OFF_ICON_SVG}</div>
<span class="choice-card-title">OFF</span>
<span class="choice-card-desc">Hide avatar section</span>
</label>
</div>
</div>
</div>
</div>
`;
}
function renderGeneralPane() {
return `
<div class="settings-pane active" id="pane-general">
<div class="setting-group">
<div class="setting-group-title">Pronoun Macros</div>
<div class="setting-row col">
<div style="width:100%; display:flex; justify-content:space-between; align-items:flex-start;">
<div class="setting-label-block">
<span class="setting-label">Persona Pronouns</span>
</div>
<div class="inline-segment">
<label><input type="radio" class="setting-control" name="pron_preset" value="she"><span class="inline-seg-btn">She/Her</span></label>
<label><input type="radio" class="setting-control" name="pron_preset" value="he"><span class="inline-seg-btn">He/Him</span></label>
<label><input type="radio" class="setting-control" name="pron_preset" value="they" checked><span class="inline-seg-btn">They/Them</span></label>
<label><input type="radio" class="setting-control" name="pron_preset" value="custom"><span class="inline-seg-btn">Custom</span></label>
</div>
</div>
<div class="pronoun-inputs-grid">
<div class="pronoun-input-col">
<span class="pronoun-input-label">Subjective</span>
<input type="text" class="setting-input pron-input" data-key="sub" placeholder="they">
</div>
<div class="pronoun-input-col">
<span class="pronoun-input-label">Objective</span>
<input type="text" class="setting-input pron-input" data-key="obj" placeholder="them">
</div>
<div class="pronoun-input-col">
<span class="pronoun-input-label">Possessive</span>
<input type="text" class="setting-input pron-input" data-key="poss" placeholder="their">
</div>
<div class="pronoun-input-col">
<span class="pronoun-input-label">Poss. Pronoun</span>
<input type="text" class="setting-input pron-input" data-key="poss_p" placeholder="theirs">
</div>
<div class="pronoun-input-col">
<span class="pronoun-input-label">Reflexive</span>
<input type="text" class="setting-input pron-input" data-key="ref" placeholder="themselves">
</div>
</div>
</div>
</div>
<div class="setting-group">
<div class="setting-group-title">Export Options</div>
<div class="setting-row">
<div class="setting-label-block">
<span class="setting-label">Name Field</span>
<span class="setting-desc">Choose which name is saved in the V2 name field</span>
</div>
<div class="inline-segment">
<label><input type="radio" class="setting-control" data-key="exp_gen_name" name="exp_gen_name" value="chat" checked><span class="inline-seg-btn">Chat Name</span></label>
<label><input type="radio" class="setting-control" data-key="exp_gen_name" name="exp_gen_name" value="name"><span class="inline-seg-btn">Name</span></label>
</div>
</div>
<div class="setting-row">
<div class="setting-label-block">
<span class="setting-label">Export Tags</span>
<span class="setting-desc">Include character tags in the V2 export</span>
</div>
<div class="inline-segment">
<label><input type="radio" class="setting-control" data-key="exp_gen_tags" name="exp_gen_tags" value="on" checked><span class="inline-seg-btn">Tags</span></label>
<label><input type="radio" class="setting-control" data-key="exp_gen_tags" name="exp_gen_tags" value="off"><span class="inline-seg-btn">No Tags</span></label>
</div>
</div>
<div class="setting-row">
<div class="setting-label-block">
<span class="setting-label">Export Creator Notes</span>
<span class="setting-desc">Include creator notes/character bio in the V2 export</span>
</div>
<div class="inline-segment">
<label><input type="radio" class="setting-control" data-key="exp_gen_notes" name="exp_gen_notes" value="on" checked><span class="inline-seg-btn">Creator Notes</span></label>
<label><input type="radio" class="setting-control" data-key="exp_gen_notes" name="exp_gen_notes" value="off"><span class="inline-seg-btn">No Creator Notes</span></label>
</div>
</div>
</div>
</div>
`;
}
function renderStoragePane() {
return `
<div class="settings-pane" id="pane-storage">
<div class="setting-group">
<div class="setting-group-title">Data</div>
<div class="danger-zone">
<div>
<div class="danger-label">Reset all settings</div>
<div class="danger-hint">Restore every setting to its default. This cannot be undone.
</div>
</div>
<button class="btn-danger">Reset</button>
</div>
</div>
</div>
`;
}
const SETTINGS_TAB_STYLE_ID = "jan-settings-tab-style";
const SETTINGS_FONT_STYLESHEET_ID = "jan-settings-font-stylesheet";
const SETTINGS_FONT_STYLESHEET_URL = "https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600&display=swap";
const SETTINGS_PANE_STORAGE_KEY = "jan-settings-active-pane";
function ensureSettingsTabStyle(documentRef) {
if (!documentRef?.head) return;
ensureGoogleFontStylesheet(documentRef, {
id: SETTINGS_FONT_STYLESHEET_ID,
href: SETTINGS_FONT_STYLESHEET_URL
});
if (documentRef.getElementById(SETTINGS_TAB_STYLE_ID)) return;
const style = documentRef.createElement("style");
style.id = SETTINGS_TAB_STYLE_ID;
style.textContent = `
.jan-settings-view {
--bg-color: #050505;
--panel-bg: linear-gradient(180deg, #0a0a0a 0%, #000000 100%);
--border-color: rgba(255, 255, 255, 0.07);
--text-color: #e8e8ec;
--text-muted: rgba(255, 255, 255, 0.5);
--accent: #fff;
--hover-bg: rgba(255, 255, 255, 0.05);
--pod-bg: linear-gradient(180deg, #181818 0%, #000000 100%);
--font-family: 'Outfit', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--success: #4CAF50;
--error: #f44336;
color: var(--text-color);
display: none;
flex-direction: column;
font-family: var(--font-family);
font-size: 16px;
line-height: 1.2;
height: 100%;
min-height: 0;
margin: 0;
overflow: hidden;
padding: 0;
position: relative;
width: 100%;
}
.jan-settings-view,
.jan-settings-view * {
box-sizing: border-box;
}
.jan-settings-view.is-active {
display: flex;
}
.jan-panel-tab-view.jan-settings-view.is-active {
display: flex;
}
.jan-settings-view .main-content {
display: flex;
flex: 1;
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
min-height: 0;
}
/* ══════════════════════════════════════
LEFT SETTINGS NAV
══════════════════════════════════════ */
.jan-settings-view .settings-nav {
box-sizing: content-box;
flex: 0 0 auto;
width: 178px;
max-width: 178px;
box-shadow: 1px 0 0 rgba(255, 255, 255, 0.05);
background: rgba(0, 0, 0, 0.15);
margin: 0;
padding: 18px 10px;
display: flex;
flex-direction: column;
gap: 2px;
align-self: stretch;
}
.jan-settings-view .nav-item {
-webkit-appearance: none;
appearance: none;
display: flex !important;
align-items: center;
gap: 9px;
width: 100% !important;
box-sizing: content-box !important;
white-space: nowrap;
padding: 8px 11px !important;
margin: 0 !important;
border: none !important;
border-radius: 7px;
outline: none;
background: transparent;
box-shadow: none !important;
color: var(--text-muted);
font-family: inherit;
font-size: 13px !important;
font-weight: 400 !important;
line-height: normal;
text-align: left;
text-decoration: none;
white-space: nowrap;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.jan-settings-view .nav-item svg {
flex-shrink: 0;
opacity: 0.55;
transition: opacity 0.15s;
}
.jan-settings-view .nav-item:hover {
background: transparent !important;
color: var(--text-color);
}
.jan-settings-view .nav-item:hover svg {
opacity: 0.9;
}
.jan-settings-view .nav-item.active {
background: transparent !important;
color: var(--text-color);
font-weight: 500;
}
.jan-settings-view .nav-item.active svg {
opacity: 1;
}
.jan-settings-view .nav-spacer {
flex: 1;
}
/* ══════════════════════════════════════
RIGHT SETTINGS CONTENT
══════════════════════════════════════ */
.jan-settings-view .settings-content {
flex: 1;
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
position: relative;
}
.jan-settings-view .settings-pane {
display: none;
position: absolute;
inset: 0;
overflow-y: auto;
padding: 28px 32px;
flex-direction: column;
gap: 26px;
}
.jan-settings-view .settings-pane.active {
display: flex;
}
.jan-settings-view .settings-pane::-webkit-scrollbar {
width: 5px;
}
.jan-settings-view .settings-pane::-webkit-scrollbar-track {
background: transparent;
}
.jan-settings-view .settings-pane::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 10px;
}
.jan-settings-view .settings-pane::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
}
/* ── Setting Group ── */
.jan-settings-view .setting-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.jan-settings-view .setting-group-title {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-muted);
margin-bottom: 3px;
}
/* ── Setting Row ── */
.jan-settings-view .setting-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 20px;
padding: 12px 15px;
background: rgba(255, 255, 255, 0.02);
border-radius: 9px;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.05);
}
.jan-settings-view .setting-row.col {
flex-direction: column;
align-items: flex-start;
gap: 13px;
}
.jan-settings-view .setting-label-block {
display: flex;
flex-direction: column;
gap: 3px;
}
.jan-settings-view .setting-label {
font-size: 0.875rem;
color: var(--text-color);
font-weight: 400;
}
.jan-settings-view .setting-desc {
font-size: 0.775rem;
color: rgba(255, 255, 255, 0.36);
line-height: 1.45;
}
.jan-settings-view .setting-hint {
font-size: 0.72rem;
color: rgba(255, 255, 255, 0.45);
margin-top: 3px;
}
/* ── Toggle Switch ── */
.jan-settings-view .toggle-switch {
position: relative;
display: inline-block;
width: 40px;
height: 22px;
flex-shrink: 0;
}
.jan-settings-view .toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.jan-settings-view .toggle-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(255, 255, 255, 0.06);
transition: .3s cubic-bezier(0.4, 0.0, 0.2, 1);
border-radius: 22px;
border: 1px solid rgba(255, 255, 255, 0.06);
}
.jan-settings-view .toggle-slider:before {
position: absolute;
content: "";
height: 16px;
width: 16px;
left: 2px;
bottom: 2px;
background-color: rgba(255, 255, 255, 0.35);
transition: .3s cubic-bezier(0.4, 0.0, 0.2, 1);
border-radius: 50%;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
}
.jan-settings-view input:checked + .toggle-slider {
background-color: rgba(255, 255, 255, 0.12);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.15);
}
.jan-settings-view input:checked + .toggle-slider:before {
transform: translateX(18px);
background-color: var(--accent);
box-shadow: 0 0 6px rgba(255, 255, 255, 0.3);
}
/* ── Inline Segment ── */
.jan-settings-view .inline-segment {
display: flex;
background: rgba(0, 0, 0, 0.3);
border-radius: 6px;
padding: 3px;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.2), 0 0 0 1px rgba(255, 255, 255, 0.05);
flex-shrink: 0;
}
.jan-settings-view .inline-segment label {
cursor: pointer;
margin: 0;
position: relative;
}
/* Subtle separator line between buttons (except the last) */
.jan-settings-view .inline-segment label:not(:last-child)::after {
content: '';
position: absolute;
right: -1px; /* offset for thicker line */
top: 25%;
bottom: 25%;
width: 2px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.08); /* faint divider line */
z-index: 1;
transition: opacity 0.2s;
}
/* Hide the separator nearest to the currently checked segment so it doesn't clash with the checked pill shadow */
.jan-settings-view .inline-segment label:has(input:checked)::after {
opacity: 0;
}
.jan-settings-view .inline-segment label:has(+ label input:checked)::after {
opacity: 0;
}
.jan-settings-view .inline-segment input {
display: none;
}
.jan-settings-view .inline-seg-btn {
padding: 5px 12px;
font-size: 0.78rem;
font-weight: 500;
color: var(--text-muted);
border-radius: 4px;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
user-select: none;
font-family: inherit;
}
.jan-settings-view .inline-segment input:checked + .inline-seg-btn {
background: rgba(255, 255, 255, 0.1);
color: var(--text-color);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3), 0 0 0 1px rgba(255, 255, 255, 0.05);
}
/* Theme segment */
.jan-settings-view .inline-segment input[value="dark"]:checked + .inline-seg-btn {
color: #b39ddb;
background: rgba(179, 157, 219, 0.1);
box-shadow: inset 0 0 0 1px rgba(179, 157, 219, 0.22);
}
.jan-settings-view .inline-segment input[value="light"]:checked + .inline-seg-btn {
color: #ffcc80;
background: rgba(255, 204, 128, 0.1);
box-shadow: inset 0 0 0 1px rgba(255, 204, 128, 0.22);
}
/* Files/ZIP segment */
.jan-settings-view .inline-segment input[value="files"]:checked + .inline-seg-btn {
color: #5a9cf5;
background: rgba(90, 156, 245, 0.1);
box-shadow: inset 0 0 0 1px rgba(90, 156, 245, 0.2);
}
.jan-settings-view .inline-segment input[value="zip"]:checked + .inline-seg-btn {
color: #c377e0;
background: rgba(195, 119, 224, 0.1);
box-shadow: inset 0 0 0 1px rgba(195, 119, 224, 0.2);
}
/* ── Choice Cards ── */
.jan-settings-view .choice-cards-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
width: 100%;
}
.jan-settings-view .choice-card {
display: flex;
flex-direction: column;
gap: 8px;
background: rgba(255, 255, 255, 0.015);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
padding: 16px;
cursor: pointer;
transition: all 0.2s;
}
.jan-settings-view .choice-card:hover {
background: rgba(255, 255, 255, 0.03);
border-color: rgba(255, 255, 255, 0.12);
}
.jan-settings-view .choice-card:has(input:checked) {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.28);
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.05);
}
.jan-settings-view .choice-card input {
display: none;
}
.jan-settings-view .choice-card-icon {
color: rgba(255, 255, 255, 0.4);
display: flex;
align-items: center;
}
.jan-settings-view .choice-card:has(input:checked) .choice-card-icon {
color: #fff;
}
.jan-settings-view .choice-card-title {
font-weight: 500;
font-size: 0.85rem;
color: #fff;
}
.jan-settings-view .choice-card-desc {
font-size: 0.7rem;
color: rgba(255, 255, 255, 0.5);
line-height: 1.4;
}
/* ── Text Input ── */
.jan-settings-view .setting-input {
background: rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 6px;
color: var(--text-color);
font-family: inherit;
font-size: 0.82rem;
padding: 8px 10px;
width: 100%;
box-sizing: border-box;
transition: border-color 0.2s, background 0.2s;
}
.jan-settings-view .setting-input:focus {
outline: none;
border-color: rgba(255, 255, 255, 0.25);
background: rgba(0, 0, 0, 0.3);
}
.jan-settings-view .setting-input::placeholder {
color: rgba(255, 255, 255, 0.2);
}
.jan-settings-view .pronoun-inputs-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 10px;
width: 100%;
}
.jan-settings-view .pronoun-input-col {
display: flex;
flex-direction: column;
gap: 6px;
}
.jan-settings-view .pronoun-input-label {
font-size: 0.73rem;
color: rgba(255, 255, 255, 0.4);
font-weight: 500;
text-align: center;
}
/* ── Format Cards ── */
.jan-settings-view .format-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
max-width: 300px;
}
.jan-settings-view .format-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 13px 4px;
background: rgba(255, 255, 255, 0.015);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 10px;
cursor: pointer;
transition: all 0.2s ease;
}
.jan-settings-view .format-card:hover {
background: rgba(255, 255, 255, 0.04);
}
.jan-settings-view .format-png:hover {
border-color: rgba(90, 156, 245, 0.2);
}
.jan-settings-view .format-png:hover .format-svg {
color: rgba(90, 156, 245, 0.7);
}
.jan-settings-view .format-json:hover {
border-color: rgba(212, 137, 58, 0.2);
}
.jan-settings-view .format-json:hover .format-svg {
color: rgba(212, 137, 58, 0.7);
}
.jan-settings-view .format-txt:hover {
border-color: rgba(143, 168, 190, 0.2);
}
.jan-settings-view .format-txt:hover .format-svg {
color: rgba(143, 168, 190, 0.7);
}
.jan-settings-view .format-card input {
display: none;
}
.jan-settings-view .format-svg {
display: flex;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.35);
transition: all 0.2s ease;
}
.jan-settings-view .format-badge {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-muted);
letter-spacing: 0.05em;
background: rgba(0, 0, 0, 0.35);
padding: 2px 8px;
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.04);
transition: all 0.2s ease;
}
.jan-settings-view .format-png:has(input:checked) {
background: rgba(90, 156, 245, 0.06);
border-color: rgba(90, 156, 245, 0.28);
box-shadow: 0 0 0 1px rgba(90, 156, 245, 0.35);
}
.jan-settings-view .format-png input:checked ~ .format-svg {
color: #5a9cf5;
}
.jan-settings-view .format-png input:checked ~ .format-badge {
color: #fff;
background: #5a9cf5;
border-color: #5a9cf5;
}
.jan-settings-view .format-json:has(input:checked) {
background: rgba(212, 137, 58, 0.06);
border-color: rgba(212, 137, 58, 0.28);
box-shadow: 0 0 0 1px rgba(212, 137, 58, 0.35);
}
.jan-settings-view .format-json input:checked ~ .format-svg {
color: #d4893a;
}
.jan-settings-view .format-json input:checked ~ .format-badge {
color: #fff;
background: #d4893a;
border-color: #d4893a;
}
.jan-settings-view .format-txt:has(input:checked) {
background: rgba(143, 168, 190, 0.06);
border-color: rgba(143, 168, 190, 0.25);
box-shadow: 0 0 0 1px rgba(143, 168, 190, 0.32);
}
.jan-settings-view .format-txt input:checked ~ .format-svg {
color: #8fa8be;
}
.jan-settings-view .format-txt input:checked ~ .format-badge {
color: #fff;
background: #8fa8be;
border-color: #8fa8be;
}
/* ── Definition Info Card ── */
.jan-settings-view .def-info-card {
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 9px;
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 11px;
}
.jan-settings-view .def-info-title {
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: rgba(255, 255, 255, 0.3);
}
.jan-settings-view .def-info-row {
display: flex;
align-items: flex-start;
gap: 11px;
}
.jan-settings-view .def-info-icon {
flex-shrink: 0;
margin-top: 1px;
}
.jan-settings-view .def-info-text {
display: flex;
flex-direction: column;
gap: 3px;
}
.jan-settings-view .def-info-label {
font-size: 0.83rem;
font-weight: 500;
}
.jan-settings-view .def-info-desc {
font-size: 0.78rem;
color: rgba(255, 255, 255, 0.4);
line-height: 1.5;
}
.jan-settings-view .def-info-divider {
height: 1px;
background: rgba(255, 255, 255, 0.05);
}
/* ── Danger zone ── */
.jan-settings-view .danger-zone {
background: rgba(244, 67, 54, 0.04);
border: 1px solid rgba(244, 67, 54, 0.1);
border-radius: 9px;
padding: 13px 15px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 20px;
}
.jan-settings-view .danger-label {
font-size: 0.875rem;
color: var(--text-color);
}
.jan-settings-view .danger-hint {
font-size: 0.775rem;
color: rgba(255, 255, 255, 0.32);
margin-top: 2px;
}
.jan-settings-view .btn-danger {
padding: 7px 15px;
background: rgba(244, 67, 54, 0.06);
border: 1px solid rgba(244, 67, 54, 0.18);
border-radius: 7px;
color: rgba(244, 67, 54, 0.75);
font-family: inherit;
font-size: 0.78rem;
font-weight: 500;
letter-spacing: 0.03em;
cursor: pointer;
transition: color 0.15s, background 0.15s, border-color 0.15s, transform 0.1s;
white-space: nowrap;
flex-shrink: 0;
}
.jan-settings-view .btn-danger:hover {
background: rgba(244, 67, 54, 0.1);
border-color: rgba(244, 67, 54, 0.32);
color: #f44336;
}
/* ── Confirm Modal ── */
.jan-settings-view .confirm-modal-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s;
}
.jan-settings-view .confirm-modal-overlay.is-open {
opacity: 1;
pointer-events: auto;
}
.jan-settings-view .confirm-modal {
background: #050505;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
padding: 22px 24px 20px;
width: 220px;
display: flex;
flex-direction: column;
gap: 16px;
transform: scale(0.96);
transition: transform 0.15s cubic-bezier(0.2, 0, 0, 1);
}
.jan-settings-view .confirm-modal-overlay.is-open .confirm-modal {
transform: scale(1);
}
.jan-settings-view .confirm-modal-title {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-color);
text-align: center;
}
.jan-settings-view .confirm-modal-actions {
display: flex;
gap: 7px;
justify-content: center;
}
.jan-settings-view .btn-modal-cancel {
padding: 6px 14px;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.09);
border-radius: 6px;
color: rgba(255, 255, 255, 0.45);
font-family: inherit;
font-size: 0.78rem;
font-weight: 400;
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
}
.jan-settings-view .btn-modal-cancel:hover {
border-color: rgba(255, 255, 255, 0.18);
color: rgba(255, 255, 255, 0.75);
}
.jan-settings-view .btn-modal-confirm {
padding: 6px 14px;
background: rgba(244, 67, 54, 0.07);
border: 1px solid rgba(244, 67, 54, 0.2);
border-radius: 6px;
color: rgba(244, 67, 54, 0.8);
font-family: inherit;
font-size: 0.78rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.jan-settings-view .btn-modal-confirm:hover {
background: rgba(244, 67, 54, 0.12);
border-color: rgba(244, 67, 54, 0.32);
color: #f44336;
}
`;
documentRef.head.appendChild(style);
}
function getSettingsStorage(documentRef) {
try {
return documentRef?.defaultView?.localStorage ?? globalThis.localStorage ?? null;
} catch {
return null;
}
}
function createSettingsMarkup() {
return `
<div class="main-content">
<!-- ── LEFT NAV ── -->
<div class="settings-nav">
<button class="nav-item active" data-pane="general">
${SETTINGS_NAV_GENERAL_ICON_SVG}
General
</button>
<button class="nav-item" data-pane="character-viewer">
${SETTINGS_NAV_CHARACTER_ICON_SVG}
Character Viewer
</button>
<button class="nav-item" data-pane="batch-download">
${SETTINGS_NAV_BATCH_ICON_SVG}
Batch Download
</button>
<div class="nav-spacer"></div>
<button class="nav-item" data-pane="storage">
${SETTINGS_NAV_STORAGE_ICON_SVG}
Storage
</button>
</div>
<!-- ── RIGHT CONTENT ── -->
<div class="settings-content">
${renderGeneralPane()}
${renderCharacterViewerPane()}
${renderBatchDownloadPane()}
${renderStoragePane()}
</div>
</div>
<div class="confirm-modal-overlay" id="jan-reset-modal">
<div class="confirm-modal">
<div class="confirm-modal-title">Are you sure?</div>
<div class="confirm-modal-actions">
<button class="btn-modal-cancel">Cancel</button>
<button class="btn-modal-confirm">Reset</button>
</div>
</div>
</div>
`;
}
function updateRadioHint(view, el) {
if (el.type !== "radio" || !el.checked || !el.dataset.hint) return;
const hintEl = view.querySelector(`#hint-${el.dataset.key}`);
if (hintEl) hintEl.textContent = el.dataset.hint;
}
function emitSettingsChanged(documentRef, key, value) {
const EventCtor = documentRef?.defaultView?.CustomEvent ?? globalThis.CustomEvent;
if (!EventCtor) return;
documentRef.dispatchEvent(new EventCtor("jan-settings-changed", {
detail: { key, value: String(value) }
}));
}
function setupSettingsControls(view, documentRef) {
const storage = getSettingsStorage(documentRef);
view.querySelectorAll(".setting-control[data-key]").forEach((el) => {
const key = el.dataset.key;
if (!key) return;
const stored = storage?.getItem(`${SETTINGS_STORAGE_PREFIX}${key}`);
const val = stored !== null ? stored : SETTINGS_DEFAULTS[key];
if (el.type === "radio") {
if (el.value === val) {
el.checked = true;
updateRadioHint(view, el);
}
} else if (el.type === "checkbox") {
el.checked = val === "true";
}
el.addEventListener("change", () => {
let toStore = el.value;
if (el.type === "checkbox") toStore = el.checked ? "true" : "false";
storage?.setItem(`${SETTINGS_STORAGE_PREFIX}${key}`, toStore);
if (el.type === "radio") {
view.querySelectorAll(`.setting-control[name="${el.name}"]`).forEach((radio) => {
if (radio.checked) updateRadioHint(view, radio);
});
}
emitSettingsChanged(documentRef, key, toStore);
});
});
}
function setupPronounMacros(view, documentRef) {
const storage = getSettingsStorage(documentRef);
const pronRadios = view.querySelectorAll('input[name="pron_preset"]');
const pronInputsArray = view.querySelectorAll(".pron-input");
const pronInputs = {};
pronInputsArray.forEach((input) => {
pronInputs[input.dataset.key] = input;
});
function savePronouns() {
const data = {};
Object.keys(pronInputs).forEach((k) => {
data[k] = pronInputs[k].value;
});
storage?.setItem(PRONOUN_STORAGE_KEY, JSON.stringify(data));
const activeRadio = view.querySelector('input[name="pron_preset"]:checked');
if (activeRadio) storage?.setItem(PRONOUN_PRESET_STORAGE_KEY, activeRadio.value);
}
pronRadios.forEach((radio) => {
radio.addEventListener("change", (event) => {
const val = event.target.value;
if (PRONOUN_PRESETS[val]) {
Object.keys(pronInputs).forEach((k) => {
pronInputs[k].value = PRONOUN_PRESETS[val][k];
});
savePronouns();
}
});
});
pronInputsArray.forEach((input) => {
input.addEventListener("input", () => {
const customOpt = view.querySelector('input[name="pron_preset"][value="custom"]');
if (customOpt) customOpt.checked = true;
savePronouns();
});
});
function loadPronouns() {
const preset = storage?.getItem(PRONOUN_PRESET_STORAGE_KEY) || DEFAULT_PRONOUN_PRESET;
const radio = view.querySelector(`input[name="pron_preset"][value="${preset}"]`);
if (radio) radio.checked = true;
const dataStr = storage?.getItem(PRONOUN_STORAGE_KEY);
let data = PRONOUN_PRESETS[DEFAULT_PRONOUN_PRESET];
if (dataStr) {
try {
data = JSON.parse(dataStr);
} catch {
}
} else if (PRONOUN_PRESETS[preset]) {
data = PRONOUN_PRESETS[preset];
}
Object.keys(pronInputs).forEach((k) => {
if (data[k]) pronInputs[k].value = data[k];
});
}
loadPronouns();
}
function setupResetButton(view, documentRef) {
const resetBtn = view.querySelector(".btn-danger");
const modal = view.querySelector("#jan-reset-modal");
if (!resetBtn || !modal) return;
const cancelBtn = modal.querySelector(".btn-modal-cancel");
const confirmBtn = modal.querySelector(".btn-modal-confirm");
function openModal() {
modal.classList.add("is-open");
}
function closeModal() {
modal.classList.remove("is-open");
}
function doReset() {
const storage = getSettingsStorage(documentRef);
Object.keys(SETTINGS_DEFAULTS).forEach((key) => {
storage?.removeItem?.(`${SETTINGS_STORAGE_PREFIX}${key}`);
});
view.querySelectorAll(".setting-control[data-key]").forEach((el) => {
const key = el.dataset.key;
if (!key) return;
const defaultValue = SETTINGS_DEFAULTS[key];
if (defaultValue == null) return;
if (el.type === "radio") {
el.checked = el.value === defaultValue;
if (el.checked) updateRadioHint(view, el);
} else if (el.type === "checkbox") {
el.checked = String(defaultValue) === "true";
}
});
const presetData = PRONOUN_PRESETS[DEFAULT_PRONOUN_PRESET] ?? PRONOUN_PRESETS.they ?? {};
const presetRadio = view.querySelector(`input[name="pron_preset"][value="${DEFAULT_PRONOUN_PRESET}"]`);
if (presetRadio) presetRadio.checked = true;
view.querySelectorAll(".pron-input").forEach((input) => {
const key = input.dataset.key;
input.value = presetData?.[key] ?? "";
});
storage?.removeItem?.(PRONOUN_STORAGE_KEY);
storage?.removeItem?.(PRONOUN_PRESET_STORAGE_KEY);
Object.entries(SETTINGS_DEFAULTS).forEach(([key, value]) => {
emitSettingsChanged(documentRef, key, value);
});
}
resetBtn.addEventListener("click", openModal);
cancelBtn.addEventListener("click", closeModal);
confirmBtn.addEventListener("click", () => {
doReset();
closeModal();
});
modal.addEventListener("click", (e) => {
if (e.target === modal) closeModal();
});
}
function setupNavPersistence(view, documentRef) {
const storage = getSettingsStorage(documentRef);
const navItems = Array.from(view.querySelectorAll(".nav-item"));
const panes = Array.from(view.querySelectorAll(".settings-pane"));
const normalizePaneId = (paneId) => paneId === "about" ? "storage" : paneId;
function setActivePane(paneId) {
const normalized = normalizePaneId(paneId);
if (!normalized) return;
navItems.forEach((item) => {
item.classList.toggle("active", item.dataset.pane === normalized);
});
panes.forEach((pane) => {
pane.classList.toggle("active", pane.id === `pane-${normalized}`);
});
storage?.setItem(SETTINGS_PANE_STORAGE_KEY, normalized);
}
navItems.forEach((item) => {
item.addEventListener("click", () => {
const paneId = item.dataset.pane;
if (paneId) setActivePane(paneId);
});
});
const storedRaw = storage?.getItem(SETTINGS_PANE_STORAGE_KEY);
const stored = normalizePaneId(storedRaw);
const validStored = stored && panes.some((pane) => pane.id === `pane-${stored}`);
setActivePane(validStored ? stored : "general");
}
function createSettingsTab({ documentRef = globalThis.document } = {}) {
ensureSettingsTabStyle(documentRef);
const view = documentRef.createElement("section");
view.className = "jan-panel-tab-view jan-settings-view";
view.innerHTML = createSettingsMarkup();
setupSettingsControls(view, documentRef);
setupPronounMacros(view, documentRef);
setupNavPersistence(view, documentRef);
setupResetButton(view, documentRef);
return view;
}
const CONFIG = {
dims: {
width: "1200px",
// edit panel width here
height: "720px",
// edit panel height here
offsetY: "80px",
// edit vertical offset here
blur: "4px"
// edit backdrop blur strength here
},
anim: {
inDuration: 240,
// ms open animation duration
outDuration: 180,
// ms close animation duration
scaleFrom: 0.8
// edit open animation start scale here (0.0 - 1.0)
},
colors: {
dark: {
start: "#0a0a0a",
// top of panel / bottom of top-tab
end: "#000000"
// bottom of panel
}
},
ids: {
backdrop: "jan-panel-backdrop",
panel: "jan-panel",
style: "jan-panel-style"
},
storage: {
theme: "jan-panel-theme",
tab: "jan-panel-active-tab"
}
};
const TAB_DEFS = [
{ svg: TAB_CHARACTER_SVG, label: "Character Viewer" },
{ svg: TAB_DOWNLOAD_SVG, label: "Batch Download" },
{ svg: TAB_SETTINGS_SVG, label: "Settings" }
];
const BROWSER = (() => {
const ua = typeof navigator !== "undefined" ? navigator.userAgent : "";
if (ua.includes("Firefox/")) return "firefox";
if (ua.includes("Edg/")) return "edge";
return "other";
})();
const TOP_TAB_HEIGHT = BROWSER === "firefox" ? 44 : 42;
const blockWheel = (event) => {
if (event.ctrlKey || !event.deltaY) return;
event.preventDefault();
event.stopPropagation();
};
const state = {
isOpen: false,
currentTheme: "dark",
activeAnim: null,
// Animation object for cancellation
panelRuntime: null,
// { backdrop, group, setActiveTab, characterViewerView }
elements: {
backdrop: null,
group: null
}
};
const safeStorage = {
get: (type, doc) => {
try {
return doc?.defaultView?.[type] ?? globalThis[type] ?? null;
} catch {
return null;
}
},
getItem: (type, key, doc) => safeStorage.get(type, doc)?.getItem(key),
setItem: (type, key, val, doc) => {
try {
safeStorage.get(type, doc)?.setItem(key, String(val));
} catch {
}
}
};
const Storage = {
loadTheme: (doc) => safeStorage.getItem("localStorage", CONFIG.storage.theme, doc) === "1" ? "light" : "dark",
saveTheme: (theme, doc) => safeStorage.setItem("localStorage", CONFIG.storage.theme, theme === "light" ? "1" : "0", doc),
loadTabIndex: (doc) => {
const raw = safeStorage.getItem("sessionStorage", CONFIG.storage.tab, doc);
const idx = parseInt(raw, 10);
return Number.isFinite(idx) && idx >= 0 ? idx : 0;
},
saveTabIndex: (idx, doc) => safeStorage.setItem("sessionStorage", CONFIG.storage.tab, idx, doc)
};
function ensurePanelStyle(documentRef) {
if (!documentRef?.head || documentRef.getElementById(CONFIG.ids.style)) return;
const style = documentRef.createElement("style");
style.id = CONFIG.ids.style;
style.textContent = `
:root {
--jp-bg: linear-gradient(180deg, ${CONFIG.colors.dark.start} 0%, ${CONFIG.colors.dark.end} 100%);
--jp-border: 1px solid rgba(0, 0, 0, 0.8);
--jp-shadow: inset 0 0.5px 0.5px rgba(255, 255, 255, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 30px 60px rgba(0, 0, 0, 0.95);
--jp-text: #e8e8ec;
--jp-pod-bg: linear-gradient(180deg, #181818 0%, #000000 100%);
--jp-pod-border: 1px solid rgba(0, 0, 0, 0.9);
--jp-pod-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), 0 15px 35px rgba(0, 0, 0, 0.95);
--jp-btn-text: rgba(255, 255, 255, 0.4);
--jp-btn-hover-bg: rgba(255, 255, 255, 0.1);
--jp-btn-hover-text: #fff;
--jp-btn-active-bg: rgba(255, 255, 255, 0.15);
--jp-btn-active-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1);
--jp-tab-fill: url(#gObsidian);
--jp-tab-stroke: rgba(0, 0, 0, 0.8);
--jp-tab-hl: rgba(255, 255, 255, 0.35);
--jp-tab-hl-under: rgba(255, 255, 255, 0.1);
--jp-tab-text: rgba(255, 255, 255, 0.95);
--jp-tab-text-shadow: 0 2px 10px rgba(0,0,0,0.8), 0 1px 2px rgba(0,0,0,0.5);
--jp-close-color: #ff5555;
--jp-close-hover-bg: rgba(255, 60, 60, 0.2);
--jp-close-hover-color: #ff8888;
--jp-placeholder-color: rgba(255, 255, 255, 0.72);
--jp-placeholder-desc: rgba(255, 255, 255, 0.48);
}
.jan-panel--light {
--jp-bg: #ffffff;
--jp-border: 1px solid #8e8e93;
--jp-shadow: 0 15px 35px rgba(0, 0, 0, 0.08);
--jp-text: #1c1c1e;
--jp-pod-bg: #ffffff;
--jp-pod-border: 1px solid #8e8e93;
--jp-pod-shadow: 0 10px 25px rgba(0, 0, 0, 0.08);
--jp-btn-text: #666666;
--jp-btn-hover-bg: #000000;
--jp-btn-hover-text: #ffffff;
--jp-btn-active-bg: #000000;
--jp-btn-active-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.4);
--jp-tab-fill: #ffffff;
--jp-tab-stroke: #8e8e93;
--jp-tab-hl: transparent;
--jp-tab-hl-under: transparent;
--jp-tab-text: #1c1c1e;
--jp-tab-text-shadow: none;
--jp-close-color: #d32f2f;
--jp-close-hover-bg: #ff3b30; /* Red engulf */
--jp-close-hover-color: #ffffff;
--jp-placeholder-color: rgba(0, 0, 0, 0.7);
--jp-placeholder-desc: rgba(0, 0, 0, 0.5);
}
/* Light mode specific override for singular pod hover engulf */
.jan-panel--light .jan-pod--circle:hover {
background: #000000;
border-color: #000000;
}
.jan-panel--light .jan-pod--circle:hover > button {
color: #ffffff;
background: transparent;
}
.jan-panel--light .jan-pod--circle.jan-pod--close:hover {
background: #ff3b30;
border-color: #ff3b30;
}
#${CONFIG.ids.backdrop} {
align-items: center;
backdrop-filter: blur(${CONFIG.dims.blur});
-webkit-backdrop-filter: blur(${CONFIG.dims.blur});
background: rgba(0, 0, 0, 0.50);
bottom: 0; box-sizing: border-box; display: flex; justify-content: center;
left: 0; position: fixed; right: 0; top: 0; z-index: 99999;
}
.jan-panel-group {
margin-bottom: ${CONFIG.dims.offsetY};
pointer-events: none; position: relative; transform-origin: center center;
}
.jan-bar {
align-items: center; background: transparent; display: flex; gap: 12px;
left: 50%; pointer-events: auto; position: absolute; top: calc(100% + 40px);
transform: translateX(-50%); white-space: nowrap;
}
.jan-pod {
align-items: center; background: var(--jp-pod-bg); border: var(--jp-pod-border);
border-radius: 9999px; box-shadow: var(--jp-pod-shadow);
box-sizing: border-box; display: flex; gap: 4px; padding: 6px;
transition: background 200ms ease, border-color 200ms ease, box-shadow 200ms ease;
}
.jan-pod--circle { border-radius: 50%; padding: 0; }
#${CONFIG.ids.panel} {
background: var(--jp-bg); border: var(--jp-border); border-radius: 12px;
box-shadow: var(--jp-shadow); box-sizing: border-box;
color: var(--jp-text); display: flex; flex-direction: column;
font-family: inherit; height: ${CONFIG.dims.height};
max-height: calc(90vh - 60px); max-width: 95vw; overflow: hidden;
pointer-events: auto; width: ${CONFIG.dims.width};
transition: background 200ms ease, color 200ms ease, border-color 200ms ease, box-shadow 200ms ease;
}
.jan-panel-close, .jan-panel-theme, .jan-bar-tab {
align-items: center; background: transparent; border: none; border-radius: 50%;
cursor: pointer; display: inline-flex; flex: 0 0 auto; height: 42px;
justify-content: center; padding: 0; pointer-events: auto; position: relative;
transition: color 150ms ease, background 150ms ease; width: 42px;
}
.jan-panel-theme { color: var(--jp-btn-text); }
.jan-panel-theme:hover { background: var(--jp-btn-hover-bg); color: var(--jp-btn-hover-text); }
.jan-panel-theme svg { display: block; height: 18px; width: 18px; }
.jan-panel-close { color: var(--jp-close-color); }
.jan-panel-close:hover { background: var(--jp-close-hover-bg); color: var(--jp-close-hover-color); }
.jan-panel-close svg { display: block; height: 12px; width: 12px; }
.jan-bar-tab { color: var(--jp-btn-text); }
.jan-bar-tab:hover { background: var(--jp-btn-hover-bg); color: var(--jp-btn-hover-text); }
.jan-bar-tab.active {
background: var(--jp-btn-active-bg); box-shadow: var(--jp-btn-active-shadow);
color: #fff;
}
.jan-bar-tab-icon { align-items: center; display: flex; flex-shrink: 0; justify-content: center; }
.jan-bar-tab-icon svg { display: block; height: 18px; width: 18px; }
.jan-panel-top-tab {
position: absolute; top: -40px; left: 50%; transform: translateX(-50%);
width: 284px; height: ${TOP_TAB_HEIGHT}px; z-index: 10;
display: flex; align-items: center; justify-content: center; pointer-events: none;
}
.tab-shape-bg { position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 1; pointer-events: none; }
.tab-fill { fill: var(--jp-tab-fill); pointer-events: auto; }
.tab-stroke { stroke: var(--jp-tab-stroke); pointer-events: none; }
.tab-macos-highlight-under { stroke: var(--jp-tab-hl-under); stroke-width: 1px; transform: translateY(0.5px); pointer-events: none; }
.tab-macos-highlight { stroke: var(--jp-tab-hl); stroke-width: 0.5px; opacity: 0.8; pointer-events: none; }
.jan-panel-tab-name-container {
position: relative; z-index: 2; width: 240px; height: 100%;
display: flex; align-items: center; justify-content: center;
overflow: hidden; padding-bottom: 2px;
}
.jan-panel-tab-name {
color: var(--jp-tab-text); font-family: inherit; font-size: 14px; font-weight: 600;
letter-spacing: 0.18em; text-transform: uppercase; text-shadow: var(--jp-tab-text-shadow);
user-select: none; -webkit-user-select: none; white-space: nowrap;
will-change: transform, opacity, filter;
}
.jan-panel-body { flex: 1 1 auto; min-height: 0; overflow: hidden; position: relative; }
.jan-panel-tab-view { display: none; height: 100%; width: 100%; }
.jan-panel-tab-view.is-active { display: block; }
/* Focus styles */
.jan-panel-theme:focus-visible,
.jan-bar-tab:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.40);
outline-offset: 2px;
}
.jan-panel-close:focus-visible {
outline: 2px solid rgba(220, 50, 50, 0.50);
outline-offset: 2px;
}
.jan-panel--light .jan-panel-theme:focus-visible,
.jan-panel--light .jan-panel-close:focus-visible,
.jan-panel--light .jan-bar-tab:focus-visible {
outline-color: rgba(0, 0, 0, 0.35);
}
/* Placeholder */
.jan-panel-placeholder-view.is-active { align-items: center; display: flex; justify-content: center; }
.jan-panel-placeholder-copy {
align-items: center; color: var(--jp-placeholder-color); display: flex;
flex-direction: column; gap: 10px; max-width: 420px; padding: 32px; text-align: center;
}
.jan-panel-placeholder-copy h3 { font-size: 18px; font-weight: 600; letter-spacing: 0.03em; margin: 0; }
.jan-panel-placeholder-copy p { color: var(--jp-placeholder-desc); line-height: 1.55; margin: 0; }
`;
documentRef.head.appendChild(style);
}
function createBackdrop(doc, theme) {
const el = doc.createElement("div");
el.id = CONFIG.ids.backdrop;
el.setAttribute("aria-hidden", "true");
if (theme === "light") el.classList.add("jan-panel--light");
el.addEventListener("pointerdown", (e) => {
if (e.target === el) closePanel({ documentRef: doc });
});
return el;
}
function createButton(doc, className, label, iconNode, onClick) {
const btn = doc.createElement("button");
btn.type = "button";
btn.className = className;
if (label) btn.setAttribute("aria-label", label);
if (iconNode) btn.appendChild(iconNode);
if (onClick) btn.addEventListener("click", onClick);
return btn;
}
function createFloatingBar(doc, onThemeToggle, onTabChange, onClose) {
const bar = doc.createElement("div");
bar.className = "jan-bar";
bar.addEventListener("wheel", blockWheel, { passive: false });
const themePod = doc.createElement("div");
themePod.className = "jan-pod jan-pod--circle";
const themeIcon = createSvgNode(
state.currentTheme === "dark" ? DARK_MODE_ICON_SVG : LIGHT_MODE_ICON_SVG,
doc
);
const themeBtn = createButton(
doc,
"jan-panel-theme",
state.currentTheme === "dark" ? "Switch to light mode" : "Switch to dark mode",
themeIcon,
onThemeToggle
);
themePod.appendChild(themeBtn);
const tabsPod = doc.createElement("div");
tabsPod.className = "jan-pod";
const tabBtns = TAB_DEFS.map((def, i2) => {
const iconWrap = doc.createElement("span");
iconWrap.className = "jan-bar-tab-icon";
const iconSvg = createSvgNode(def.svg, doc);
if (iconSvg) iconWrap.appendChild(iconSvg);
const btn = createButton(doc, "jan-bar-tab" + (i2 === 0 ? " active" : ""), def.label, iconWrap);
btn.addEventListener("click", () => onTabChange(i2));
return btn;
});
tabBtns.forEach((btn) => tabsPod.appendChild(btn));
const closePod = doc.createElement("div");
closePod.className = "jan-pod jan-pod--circle jan-pod--close";
const closeBtn = createButton(
doc,
"jan-panel-close",
"Close panel",
createSvgNode(PANEL_CLOSE_ICON_SVG, doc),
onClose
);
closePod.appendChild(closeBtn);
bar.append(themePod, tabsPod, closePod);
return { bar, themeBtn, tabBtns };
}
function createTopTab(doc) {
const el = doc.createElement("div");
el.className = "jan-panel-top-tab";
el.addEventListener("wheel", blockWheel, { passive: false });
const pathFillD = `M 0 40 A 12 12 0 0 0 12 28 L 12 12 A 12 12 0 0 1 24 0 L 260 0 A 12 12 0 0 1 272 12 L 272 28 A 12 12 0 0 0 284 40 L 284 ${TOP_TAB_HEIGHT} L 0 ${TOP_TAB_HEIGHT} Z`;
const innerHighlightD = "M 0 41 A 11 11 0 0 0 11 30 L 11 12 A 13 13 0 0 1 24 1 L 260 1 A 13 13 0 0 1 273 12 L 273 30 A 11 11 0 0 0 284 41";
const pathD = "M 0 40 A 12 12 0 0 0 12 28 L 12 12 A 12 12 0 0 1 24 0 L 260 0 A 12 12 0 0 1 272 12 L 272 28 A 12 12 0 0 0 284 40";
el.innerHTML = `
<svg class="tab-shape-bg" viewBox="0 0 284 ${TOP_TAB_HEIGHT}" width="284" height="${TOP_TAB_HEIGHT}" aria-hidden="true">
<defs>
<linearGradient id="gObsidian" x1="0" y1="1" x2="0" y2="0">
<stop offset="0%" stop-color="${CONFIG.colors.dark.start}"/>
<stop offset="100%" stop-color="#141414"/>
</linearGradient>
</defs>
<path class="tab-fill" d="${pathFillD}" />
<path class="tab-macos-highlight-under" d="${innerHighlightD}" fill="none" stroke-linecap="round" />
<path class="tab-macos-highlight" d="${innerHighlightD}" fill="none" stroke-linecap="round" />
<path class="tab-stroke" d="${pathD}" fill="none" stroke-width="1.2" stroke-linecap="round" />
</svg>
<div class="jan-panel-tab-name-container">
<div class="jan-panel-tab-name">${TAB_DEFS[0].label}</div>
</div>
`;
return { el, nameEl: el.querySelector(".jan-panel-tab-name") };
}
function prefersReducedMotion(doc) {
try {
return Boolean(doc?.defaultView?.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches);
} catch {
return false;
}
}
function animateIn(backdrop, panel) {
if (!backdrop?.animate || !panel?.animate || prefersReducedMotion(backdrop?.ownerDocument)) {
backdrop.style.opacity = "1";
panel.style.opacity = "1";
panel.style.transform = "scale(1)";
return;
}
const opts = { duration: CONFIG.anim.inDuration, fill: "forwards" };
const bdAnim = backdrop.animate([{ opacity: 0 }, { opacity: 1 }], { ...opts, easing: "ease" });
bdAnim.addEventListener("finish", () => bdAnim.cancel(), { once: true });
const pAnim = panel.animate(
[{ opacity: 0, transform: `scale(${CONFIG.anim.scaleFrom})` }, { opacity: 1, transform: "scale(1)" }],
{ ...opts, easing: "cubic-bezier(0.22, 1, 0.36, 1)" }
);
pAnim.addEventListener("finish", () => pAnim.cancel(), { once: true });
}
function animateOut(backdrop, panel, onDone) {
if (!backdrop?.animate || !panel?.animate || prefersReducedMotion(backdrop?.ownerDocument)) {
onDone?.();
return null;
}
const opts = { duration: CONFIG.anim.outDuration, fill: "forwards" };
backdrop.animate([{ opacity: 1 }, { opacity: 0 }], { ...opts, easing: "ease" });
const pAnim = panel.animate(
[{ opacity: 1, transform: "scale(1)" }, { opacity: 0, transform: `scale(${CONFIG.anim.scaleFrom})` }],
{ ...opts, easing: "cubic-bezier(0.55, 0, 0.45, 1)" }
);
pAnim.addEventListener("finish", onDone, { once: true });
return pAnim;
}
function animateTabLabel(el, newLabel, direction) {
if (prefersReducedMotion(el?.ownerDocument) || !el?.animate) {
el.textContent = newLabel;
return;
}
el.getAnimations?.().forEach((a) => a.cancel());
const outY = direction === "next" ? "-24px" : "24px";
const inY = direction === "next" ? "24px" : "-24px";
el.animate(
[
{ opacity: 1, transform: "translateY(0) scale(1)", filter: "blur(0px)" },
{ opacity: 0, transform: `translateY(${outY}) scale(0.95)`, filter: "blur(2px)" }
],
{ duration: 180, easing: "cubic-bezier(0.55, 0, 1, 0.45)", fill: "forwards" }
).addEventListener("finish", () => {
el.textContent = newLabel;
el.animate(
[
{ opacity: 0, transform: `translateY(${inY}) scale(0.95)`, filter: "blur(2px)" },
{ opacity: 1, transform: "translateY(0) scale(1)", filter: "blur(0px)" }
],
{ duration: 250, easing: "cubic-bezier(0, 0.55, 0.45, 1)", fill: "forwards" }
);
}, { once: true });
}
function buildPanel(documentRef, onClose, { generateAlphaProxyConfig = null } = {}) {
const backdrop = createBackdrop(documentRef, state.currentTheme);
const group = documentRef.createElement("div");
group.className = "jan-panel-group";
if (state.currentTheme === "light") group.classList.add("jan-panel--light");
const onThemeToggle = () => {
state.currentTheme = state.currentTheme === "dark" ? "light" : "dark";
Storage.saveTheme(state.currentTheme, documentRef);
const isLight = state.currentTheme === "light";
group.classList.toggle("jan-panel--light", isLight);
backdrop.classList.toggle("jan-panel--light", isLight);
if (barApi.themeBtn) {
replaceSvgContent(
barApi.themeBtn,
isLight ? LIGHT_MODE_ICON_SVG : DARK_MODE_ICON_SVG,
documentRef
);
barApi.themeBtn.setAttribute("aria-label", isLight ? "Switch to dark mode" : "Switch to light mode");
}
};
let activeTabIdx = 0;
const setActiveTab = (nextIdx) => {
if (!Number.isInteger(nextIdx) || nextIdx < 0 || nextIdx >= TAB_DEFS.length || nextIdx === activeTabIdx) return;
const dir = nextIdx > activeTabIdx ? "next" : "prev";
activeTabIdx = nextIdx;
barApi.tabBtns.forEach((btn, i2) => btn.classList.toggle("active", i2 === nextIdx));
tabViews.forEach((view, i2) => view.classList.toggle("is-active", i2 === nextIdx));
animateTabLabel(topTabApi.nameEl, TAB_DEFS[nextIdx].label, dir);
Storage.saveTabIndex(nextIdx, documentRef);
if (nextIdx === 0) notifyCharacterViewerTabActive(characterViewerView);
};
const barApi = createFloatingBar(documentRef, onThemeToggle, setActiveTab, onClose);
const topTabApi = createTopTab(documentRef);
const panel = documentRef.createElement("div");
panel.id = CONFIG.ids.panel;
panel.setAttribute("role", "dialog");
panel.setAttribute("aria-modal", "true");
panel.setAttribute("aria-label", "Jan script panel");
const body = documentRef.createElement("div");
body.className = "jan-panel-body";
const characterViewerView = createCharacterViewerTab({
documentRef,
backdropEl: backdrop,
generateAlphaProxyConfig
});
const batchDownloadView = createBatchDownloadTab({ documentRef, groupEl: group, backdropEl: backdrop });
const settingsView = createSettingsTab({ documentRef });
const tabViews = [
characterViewerView,
batchDownloadView,
settingsView
];
tabViews[0].classList.add("is-active");
tabViews.forEach((v) => body.appendChild(v));
panel.appendChild(body);
group.append(panel, topTabApi.el, barApi.bar);
backdrop.appendChild(group);
const savedIdx = Storage.loadTabIndex(documentRef);
if (savedIdx > 0 && savedIdx < TAB_DEFS.length) {
activeTabIdx = savedIdx;
barApi.tabBtns.forEach((btn, i2) => btn.classList.toggle("active", i2 === savedIdx));
tabViews.forEach((view, i2) => view.classList.toggle("is-active", i2 === savedIdx));
topTabApi.nameEl.textContent = TAB_DEFS[savedIdx].label;
}
if (activeTabIdx === 0) {
globalThis.setTimeout(() => notifyCharacterViewerTabActive(characterViewerView), 0);
}
return {
backdrop,
group,
setActiveTab,
characterViewerView,
destroy: () => {
tabViews.forEach((view) => {
if (typeof view?.__janDestroy === "function") {
try {
view.__janDestroy();
} catch {
}
}
});
}
};
}
function openPanel({
documentRef = globalThis.document,
generateAlphaProxyConfig = null
} = {}) {
if (!documentRef?.body) return;
if (state.isOpen) return state.panelRuntime;
state.currentTheme = Storage.loadTheme(documentRef);
if (state.activeAnim) {
state.activeAnim.cancel();
state.activeAnim = null;
}
state.elements.backdrop?.remove();
state.panelRuntime = null;
ensurePanelStyle(documentRef);
const runtime = buildPanel(
documentRef,
() => closePanel({ documentRef }),
{ generateAlphaProxyConfig }
);
state.elements.backdrop = runtime.backdrop;
state.elements.group = runtime.group;
state.panelRuntime = runtime;
state.isOpen = true;
documentRef.body.appendChild(runtime.backdrop);
animateIn(runtime.backdrop, runtime.group);
return runtime;
}
async function openCharacterViewerPanel({
documentRef = globalThis.document,
characterInput = null,
generateAlphaProxyConfig = null
} = {}) {
const runtime = openPanel({ documentRef, generateAlphaProxyConfig });
if (!runtime) return false;
if (characterInput) {
unloadCharacterViewer(runtime.characterViewerView);
}
runtime.setActiveTab?.(0);
if (characterInput) {
return await loadCharacterViewerInput(runtime.characterViewerView, characterInput);
}
return true;
}
function closePanel({ documentRef = globalThis.document } = {}) {
if (!state.isOpen || !state.elements.backdrop || !state.elements.group) return;
state.isOpen = false;
state.panelRuntime?.destroy?.();
state.activeAnim = animateOut(state.elements.backdrop, state.elements.group, () => {
state.elements.backdrop?.remove();
state.elements.backdrop = null;
state.elements.group = null;
state.activeAnim = null;
state.panelRuntime = null;
});
}
function togglePanel(options) {
state.isOpen ? closePanel(options) : openPanel(options);
}
const OPENER_BUTTON_ID = "jan-opener-button";
const OPENER_STYLE_ID = "jan-opener-style";
const RIGHT_ACTIONS_CONTAINER_SELECTOR = ".pp-top-bar-right > div";
const HEADER_CONTAINER_SELECTOR = ".pp-top-bar-right";
const CLEANUP_KEY = "__janOpenerCleanup__";
const OPENER_ICON_SVG = `
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<g fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="12" cy="12" r="3"/>
<path d="M13.765 2.152C13.398 2 12.932 2 12 2s-1.398 0-1.765.152a2 2 0 0 0-1.083 1.083c-.092.223-.129.484-.143.863a1.62 1.62 0 0 1-.79 1.353a1.62 1.62 0 0 1-1.567.008c-.336-.178-.579-.276-.82-.308a2 2 0 0 0-1.478.396C4.04 5.79 3.806 6.193 3.34 7s-.7 1.21-.751 1.605a2 2 0 0 0 .396 1.479c.148.192.355.353.676.555c.473.297.777.803.777 1.361s-.304 1.064-.777 1.36c-.321.203-.529.364-.676.556a2 2 0 0 0-.396 1.479c.052.394.285.798.75 1.605c.467.807.7 1.21 1.015 1.453a2 2 0 0 0 1.479.396c.24-.032.483-.13.819-.308a1.62 1.62 0 0 1 1.567.008c.483.28.77.795.79 1.353c.014.38.05.64.143.863a2 2 0 0 0 1.083 1.083C10.602 22 11.068 22 12 22s1.398 0 1.765-.152a2 2 0 0 0 1.083-1.083c.092-.223.129-.483.143-.863c.02-.558.307-1.074.79-1.353a1.62 1.62 0 0 1 1.567-.008c.336.178.579.276.819.308a2 2 0 0 0 1.479-.396c.315-.242.548-.646 1.014-1.453s.7-1.21.751-1.605a2 2 0 0 0-.396-1.479c-.148-.192-.355-.353-.676-.555A1.62 1.62 0 0 1 19.562 12c0-.558.304-1.064.777-1.36c.321-.203.529-.364.676-.556a2 2 0 0 0 .396-1.479c-.052-.394-.285-.798-.75-1.605c-.467-.807-.7-1.21-1.015-1.453a2 2 0 0 0-1.479-.396c-.24.032-.483.13-.82.308a1.62 1.62 0 0 1-1.566-.008a1.62 1.62 0 0 1-.79-1.353c-.014-.38-.05-.64-.143-.863a2 2 0 0 0-1.083-1.083Z"/>
</g>
</svg>
`;
let activeRuntime = null;
function ensureOpenerStyle(documentRef) {
if (!documentRef?.head) return;
if (documentRef.getElementById(OPENER_STYLE_ID)) return;
const styleElement = documentRef.createElement("style");
styleElement.id = OPENER_STYLE_ID;
styleElement.textContent = `
#${OPENER_BUTTON_ID}.jan-opener-button {
align-items: center;
background: none;
border: none;
color: inherit;
cursor: pointer;
display: inline-flex;
flex: 0 0 auto;
height: 38px;
justify-content: center;
margin-right: 8px;
min-width: 38px;
opacity: 0.55;
padding: 0;
transition: opacity 200ms ease;
width: 38px;
}
#${OPENER_BUTTON_ID}.jan-opener-button:hover {
opacity: 1;
}
#${OPENER_BUTTON_ID}.jan-opener-button:focus-visible {
border-radius: 6px;
outline: 2px solid rgba(255, 255, 255, 0.55);
outline-offset: 2px;
}
#${OPENER_BUTTON_ID}.jan-opener-button svg {
display: block;
height: 22px;
transform-origin: center;
width: 22px;
}
`;
documentRef.head.appendChild(styleElement);
}
function getRotationDeg(el, documentRef) {
if (!el || !documentRef) return 0;
const win = documentRef.defaultView || globalThis;
if (typeof win.getComputedStyle !== "function") return 0;
const matrix = win.getComputedStyle(el).transform;
if (!matrix || matrix === "none") return 0;
const m = matrix.match(/matrix\(([^)]+)\)/);
if (!m) return 0;
const [a, b] = m[1].split(",").map((value) => Number(value.trim()));
if (!Number.isFinite(a) || !Number.isFinite(b)) return 0;
const deg = Math.atan2(b, a) * (180 / Math.PI);
return deg < 0 ? deg + 360 : deg;
}
function canAnimate(svg) {
return typeof svg?.animate === "function";
}
function makeRewindKeyframes(fromAngle, totalAngle) {
const MAX_STEP = 120;
const steps = Math.ceil(totalAngle / MAX_STEP);
return Array.from({ length: steps + 1 }, (_, i2) => ({
transform: `rotate(${fromAngle - i2 / steps * totalAngle}deg)`,
...i2 < steps && { easing: "linear" }
}));
}
function createOpenerButton(documentRef) {
const button = documentRef.createElement("button");
button.type = "button";
button.id = OPENER_BUTTON_ID;
button.className = "jan-opener-button";
button.setAttribute("aria-label", "Open script UI");
appendSvg(button, OPENER_ICON_SVG, documentRef);
const getSvg = () => button.querySelector("svg");
let forwardAnim = null;
let rewindAnim = null;
const handleMouseEnter = () => {
const svg = getSvg();
if (!canAnimate(svg)) return;
const startAngle = getRotationDeg(svg, documentRef);
if (rewindAnim) {
rewindAnim.cancel();
rewindAnim = null;
}
if (forwardAnim) {
forwardAnim.cancel();
forwardAnim = null;
}
forwardAnim = svg.animate(
[
{ transform: `rotate(${startAngle}deg)` },
{ transform: `rotate(${startAngle + 360}deg)` }
],
{ duration: 3e3, iterations: Infinity, easing: "linear" }
);
};
const handleMouseLeave = () => {
if (!forwardAnim) return;
const currentTime = forwardAnim.currentTime;
if (typeof currentTime !== "number") {
forwardAnim.cancel();
forwardAnim = null;
return;
}
const totalAngle = currentTime * (360 / 3e3);
const visualAngle = totalAngle % 360;
forwardAnim.cancel();
forwardAnim = null;
const svg = getSvg();
if (!canAnimate(svg)) return;
if (totalAngle < 0.5) return;
const duration = Math.max(500, Math.min(1200, totalAngle / 360 * 300));
rewindAnim = svg.animate(
makeRewindKeyframes(visualAngle, totalAngle),
{ duration, easing: "cubic-bezier(0.33, 1, 0.68, 1)", fill: "none" }
);
rewindAnim.addEventListener("finish", () => {
rewindAnim = null;
});
};
const handleClick = (event) => {
event.preventDefault();
event.stopPropagation();
togglePanel({ documentRef });
};
button.addEventListener("mouseenter", handleMouseEnter);
button.addEventListener("mouseleave", handleMouseLeave);
button.addEventListener("click", handleClick);
button[CLEANUP_KEY] = () => {
forwardAnim?.cancel();
rewindAnim?.cancel();
button.removeEventListener("mouseenter", handleMouseEnter);
button.removeEventListener("mouseleave", handleMouseLeave);
button.removeEventListener("click", handleClick);
};
return button;
}
function findRightActionsContainer(documentRef) {
return documentRef.querySelector(RIGHT_ACTIONS_CONTAINER_SELECTOR);
}
function isElementNode(node) {
return node?.nodeType === 1;
}
function isWithinHeader(node) {
const element = isElementNode(node) ? node : node?.parentElement;
return Boolean(element?.closest?.(HEADER_CONTAINER_SELECTOR));
}
function shouldScheduleEnsure(mutations) {
for (const mutation of mutations) {
if (mutation.type !== "childList") continue;
if (isWithinHeader(mutation.target)) return true;
const nodes = [...mutation.addedNodes, ...mutation.removedNodes];
for (const node of nodes) {
if (!isElementNode(node)) continue;
const element = node;
if (element.id === OPENER_BUTTON_ID) return true;
if (element.matches?.(RIGHT_ACTIONS_CONTAINER_SELECTOR)) return true;
if (element.matches?.(HEADER_CONTAINER_SELECTOR)) return true;
if (element.querySelector?.(RIGHT_ACTIONS_CONTAINER_SELECTOR)) return true;
}
}
return false;
}
function ensureHeaderOpener(documentRef) {
const actionsContainer = findRightActionsContainer(documentRef);
if (!actionsContainer) {
const staleOpener = documentRef.getElementById(OPENER_BUTTON_ID);
staleOpener?.[CLEANUP_KEY]?.();
staleOpener?.remove();
return null;
}
const existingOpenerButton = documentRef.getElementById(OPENER_BUTTON_ID);
const firstChild = actionsContainer.firstElementChild;
if (existingOpenerButton) {
if (existingOpenerButton.parentElement !== actionsContainer) {
actionsContainer.insertBefore(existingOpenerButton, firstChild);
} else if (actionsContainer.firstElementChild !== existingOpenerButton) {
actionsContainer.insertBefore(existingOpenerButton, firstChild);
}
return existingOpenerButton;
}
const openerButton = createOpenerButton(documentRef);
actionsContainer.insertBefore(openerButton, firstChild);
return openerButton;
}
function initHeaderOpener({ documentRef = globalThis.document } = {}) {
if (!documentRef?.body || typeof MutationObserver !== "function") {
return () => {
};
}
if (activeRuntime?.documentRef === documentRef) {
activeRuntime.ensure();
return activeRuntime.destroy;
}
activeRuntime?.destroy?.();
ensureOpenerStyle(documentRef);
const ensure = () => {
ensureOpenerStyle(documentRef);
ensureHeaderOpener(documentRef);
};
ensure();
const scheduleEnsure = createRafScheduler$1(
ensure,
documentRef?.defaultView ?? globalThis
);
const observer = new MutationObserver((mutations) => {
if (shouldScheduleEnsure(mutations)) {
scheduleEnsure();
}
});
observer.observe(documentRef.body, {
childList: true,
subtree: true
});
const windowRef = documentRef.defaultView;
windowRef?.addEventListener?.("popstate", scheduleEnsure);
windowRef?.addEventListener?.("hashchange", scheduleEnsure);
const destroy = () => {
observer.disconnect();
windowRef?.removeEventListener?.("popstate", scheduleEnsure);
windowRef?.removeEventListener?.("hashchange", scheduleEnsure);
const openerButton = documentRef.getElementById(OPENER_BUTTON_ID);
openerButton?.[CLEANUP_KEY]?.();
openerButton?.remove();
documentRef.getElementById(OPENER_STYLE_ID)?.remove();
if (activeRuntime?.destroy === destroy) {
activeRuntime = null;
}
};
activeRuntime = {
documentRef,
ensure,
destroy
};
return destroy;
}
function destroyHeaderOpener() {
activeRuntime?.destroy?.();
}
function autoInitHeaderOpener() {
if (!globalThis.document) {
return () => {
};
}
return initHeaderOpener({ documentRef: globalThis.document });
}
const VIEWER_ICON_DEFAULT_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="12" cy="8" r="5"/><path d="M20 21a8 8 0 0 0-16 0"/></svg>`;
const VIEWER_ICON_HOVER_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M21 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h6"/><path d="m21 3-9 9"/><path d="M15 3h6v6"/></svg>`;
const CARD_VIEWER_STYLE_ID = "jan-cv-btn-style";
const CHAT_CARD_WRAPPER_SELECTOR = '[class*="_chatCard_"]';
const CARD_WRAPPER_SELECTOR = [
".pp-cc-wrapper",
".profile-character-card-wrapper",
CHAT_CARD_WRAPPER_SELECTOR
].join(", ");
const CARD_VIEWER_REVEAL_SELECTOR = [
".pp-cc-wrapper:hover .jan-cv-btn",
".profile-character-card-wrapper:hover .jan-cv-btn",
`${CHAT_CARD_WRAPPER_SELECTOR}:hover .jan-cv-btn`
].join(",\n ");
const CARD_VIEWER_STYLE = `
.jan-cv-btn {
appearance: none;
-webkit-appearance: none;
position: absolute;
top: -20px;
left: 50%;
transform: translateX(-50%);
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
padding: 0;
font-size: 14px;
border-radius: 9999px;
background: rgba(28, 28, 28, 0.92);
color: rgba(255, 255, 255, 0.72);
border: none;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
cursor: pointer;
opacity: 0;
pointer-events: none;
transition: opacity 120ms ease;
z-index: 100;
line-height: 0;
}
${CARD_VIEWER_REVEAL_SELECTOR} {
opacity: 1;
pointer-events: auto;
}
.jan-cv-btn--chat-card {
top: -20px;
right: auto;
left: 50%;
transform: translateX(-50%);
}
.jan-cv-icon-default,
.jan-cv-icon-hover {
display: flex;
align-items: center;
justify-content: center;
line-height: 0;
transition: opacity 160ms ease;
}
.jan-cv-icon-hover {
position: absolute;
inset: 0;
opacity: 0;
}
.jan-cv-btn:hover .jan-cv-icon-default { opacity: 0; }
.jan-cv-btn:hover .jan-cv-icon-hover { opacity: 1; }
.jan-cv-btn svg { display: block; pointer-events: none; }
`;
function getCardWrappers(scope) {
return [...scope.querySelectorAll(CARD_WRAPPER_SELECTOR)];
}
function isCardWrapper(node) {
return typeof node?.matches === "function" && node.matches(CARD_WRAPPER_SELECTOR);
}
function isChatCardWrapper(node) {
return typeof node?.matches === "function" && node.matches(CHAT_CARD_WRAPPER_SELECTOR);
}
function hasViewerButton(wrapper) {
if (!wrapper?.querySelector) return false;
try {
return Boolean(wrapper.querySelector(":scope > .jan-cv-btn"));
} catch {
return Boolean(wrapper.querySelector(".jan-cv-btn"));
}
}
function injectButton$1(wrapper, documentRef) {
if (hasViewerButton(wrapper)) return;
const win = documentRef.defaultView ?? globalThis;
const pos = win.getComputedStyle(wrapper).position;
if (!pos || pos === "static") {
wrapper.style.position = "relative";
}
const btn = documentRef.createElement("button");
btn.className = "jan-cv-btn";
if (isChatCardWrapper(wrapper)) {
btn.classList.add("jan-cv-btn--chat-card");
}
btn.type = "button";
btn.title = "Open in Character Viewer";
btn.setAttribute("aria-label", "Open in Character Viewer");
const iconDefault = documentRef.createElement("span");
iconDefault.className = "jan-cv-icon-default";
appendSvg(iconDefault, VIEWER_ICON_DEFAULT_SVG, documentRef);
const iconHover = documentRef.createElement("span");
iconHover.className = "jan-cv-icon-hover";
appendSvg(iconHover, VIEWER_ICON_HOVER_SVG, documentRef);
btn.append(iconDefault, iconHover);
wrapper.appendChild(btn);
}
function injectAll(scope, documentRef) {
if (isCardWrapper(scope) && hasCharacterLink(scope)) {
injectButton$1(scope, documentRef);
}
getCardWrappers(scope).forEach((wrapper) => {
if (!hasCharacterLink(wrapper)) return;
injectButton$1(wrapper, documentRef);
});
}
function shouldScanNode(node) {
if (!isElementNode$1(node)) return false;
if (isCardWrapper(node)) return true;
return Boolean(node.querySelector?.(CARD_WRAPPER_SELECTOR));
}
async function handleViewerButtonClick(event) {
const target = event?.target;
const button = target?.closest?.(".jan-cv-btn");
if (!button || button.disabled) return;
const documentRef = button.ownerDocument ?? globalThis.document;
const wrapper = button.closest?.(CARD_WRAPPER_SELECTOR);
if (!wrapper) return;
event.preventDefault();
event.stopPropagation();
const characterInput = getCharacterInputFromWrapper(wrapper, documentRef);
if (!characterInput) return;
await withBusyButton(
button,
() => openCharacterViewerPanel({
documentRef,
characterInput
})
);
}
let _observer$1 = null;
let _retryCleanup$1 = null;
let _controller$1 = null;
let _active$1 = false;
function initCardViewerButtons({ documentRef = globalThis.document } = {}) {
if (_active$1) return;
if (!documentRef) return;
_active$1 = true;
ensureStyle(documentRef, { id: CARD_VIEWER_STYLE_ID, cssText: CARD_VIEWER_STYLE });
injectAll(documentRef, documentRef);
_controller$1 = new AbortController();
documentRef.addEventListener("click", handleViewerButtonClick, {
capture: true,
signal: _controller$1.signal
});
const scheduleInject = createRafScheduler(documentRef, () => {
if (!_active$1) return;
injectAll(documentRef, documentRef);
});
_observer$1 = createMutationObserver(documentRef, (mutations) => {
for (const m of mutations) {
if (m.type !== "childList") continue;
for (const node of m.addedNodes) {
if (!shouldScanNode(node)) continue;
scheduleInject();
return;
}
}
});
_retryCleanup$1 = scheduleRetries(
[250, 750, 1500, 2500],
() => injectAll(documentRef, documentRef),
{ isActive: () => _active$1 }
);
}
function destroyCardViewerButtons({ documentRef = globalThis.document } = {}) {
_active$1 = false;
_observer$1?.disconnect();
_observer$1 = null;
_retryCleanup$1?.();
_retryCleanup$1 = null;
_controller$1?.abort();
_controller$1 = null;
removeStyle(documentRef, CARD_VIEWER_STYLE_ID);
documentRef?.querySelectorAll?.(".jan-cv-btn")?.forEach((b) => b.remove());
}
function autoInitCardViewerButtons() {
if (!globalThis.document) return () => {
};
initCardViewerButtons({ documentRef: globalThis.document });
return () => destroyCardViewerButtons({ documentRef: globalThis.document });
}
const CHAR_PAGE_BTN_ID = "jan-char-page-cv-btn";
const CHAR_PAGE_STYLE_ID = "jan-char-page-cv-btn-style";
const CHAR_PAGE_STYLE = `
/* Button — panel philosophy: transparent → subtle glass on hover */
#${CHAR_PAGE_BTN_ID} {
appearance: none;
-webkit-appearance: none;
background: transparent;
border: none;
border-radius: 6px;
box-sizing: border-box;
color: rgba(255, 255, 255, 0.65);
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 8px;
font-family: inherit;
font-size: inherit;
font-weight: 600;
line-height: 1.2;
padding: 8px 14px;
transition: color 150ms ease, background 150ms ease;
user-select: none;
vertical-align: middle;
white-space: nowrap;
}
#${CHAR_PAGE_BTN_ID}:hover {
background: rgba(255, 255, 255, 0.08);
color: #fff;
}
#${CHAR_PAGE_BTN_ID}:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.40);
outline-offset: 2px;
}
#${CHAR_PAGE_BTN_ID}:disabled {
cursor: not-allowed;
opacity: 0.5;
}
/* Icon wrap — stacks default + hover icons, crossfades on hover */
.jan-cp-icon-wrap {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
line-height: 0;
position: relative;
width: 1em;
height: 1em;
}
.jan-cp-icon-default,
.jan-cp-icon-hover {
display: flex;
align-items: center;
justify-content: center;
transition: opacity 160ms ease;
}
.jan-cp-icon-hover {
inset: 0;
opacity: 0;
position: absolute;
}
#${CHAR_PAGE_BTN_ID} svg { display: block; pointer-events: none; }
#${CHAR_PAGE_BTN_ID}:hover .jan-cp-icon-default { opacity: 0; }
#${CHAR_PAGE_BTN_ID}:hover .jan-cp-icon-hover { opacity: 1; }
`;
function findMoreButtonsContainer(documentRef) {
for (const h2 of documentRef.querySelectorAll("h2.chakra-heading")) {
if (h2.textContent.trim().toLowerCase() !== "more") continue;
let el = h2.nextElementSibling;
while (el) {
if (el.tagName === "DIV") return el;
el = el.nextElementSibling;
}
}
return null;
}
function hasCharacterPageButton(container) {
return Boolean(container?.querySelector?.(`#${CHAR_PAGE_BTN_ID}`));
}
function injectButton(container, characterInput, documentRef) {
if (!container || hasCharacterPageButton(container)) return false;
const btn = documentRef.createElement("button");
btn.id = CHAR_PAGE_BTN_ID;
btn.type = "button";
btn.title = "Open in Character Viewer";
btn.setAttribute("aria-label", "Open in Character Viewer");
const iconWrap = documentRef.createElement("span");
iconWrap.className = "jan-cp-icon-wrap";
const iconDefault = documentRef.createElement("span");
iconDefault.className = "jan-cp-icon-default";
appendSvg(iconDefault, VIEWER_ICON_DEFAULT_SVG, documentRef);
const iconHover = documentRef.createElement("span");
iconHover.className = "jan-cp-icon-hover";
appendSvg(iconHover, VIEWER_ICON_HOVER_SVG, documentRef);
const label = documentRef.createElement("span");
label.textContent = "Open in Character Viewer";
iconWrap.append(iconDefault, iconHover);
btn.append(iconWrap, label);
container.insertBefore(btn, container.firstElementChild);
return true;
}
function tryInject(documentRef) {
const win = documentRef.defaultView ?? globalThis;
const characterId = getCharacterIdFromPath(win.location?.pathname);
if (!characterId) return false;
const container = findMoreButtonsContainer(documentRef);
if (!container) return false;
return injectButton(container, buildCharacterInput(characterId, documentRef), documentRef);
}
async function handleCharacterPageButtonClick(event) {
const target = event?.target;
const button = target?.closest?.(`#${CHAR_PAGE_BTN_ID}`);
if (!button || button.disabled) return;
const documentRef = button.ownerDocument ?? globalThis.document;
const win = documentRef.defaultView ?? globalThis;
const characterId = getCharacterIdFromPath(win.location?.pathname);
if (!characterId) return;
event.preventDefault();
event.stopPropagation();
const characterInput = buildCharacterInput(characterId, documentRef);
await withBusyButton(
button,
() => openCharacterViewerPanel({ documentRef, characterInput })
);
}
let _observer = null;
let _retryCleanup = null;
let _controller = null;
let _active = false;
function initCharacterPageButton({ documentRef = globalThis.document } = {}) {
if (_active) return;
if (!documentRef) return;
_active = true;
ensureStyle(documentRef, { id: CHAR_PAGE_STYLE_ID, cssText: CHAR_PAGE_STYLE });
tryInject(documentRef);
_controller = new AbortController();
documentRef.addEventListener("click", handleCharacterPageButtonClick, {
capture: true,
signal: _controller.signal
});
const scheduleInject = createRafScheduler(documentRef, () => {
if (!_active) return;
if (!documentRef.getElementById(CHAR_PAGE_BTN_ID)) {
tryInject(documentRef);
}
});
_observer = createMutationObserver(documentRef, (mutations) => {
for (const m of mutations) {
if (m.type !== "childList") continue;
for (const node of m.addedNodes) {
if (!isElementNode$1(node)) continue;
scheduleInject();
return;
}
}
});
_retryCleanup = scheduleRetries(
[150, 500, 1e3, 2500],
() => tryInject(documentRef),
{ isActive: () => _active }
);
}
function destroyCharacterPageButton({ documentRef = globalThis.document } = {}) {
_active = false;
_observer?.disconnect();
_observer = null;
_retryCleanup?.();
_retryCleanup = null;
_controller?.abort();
_controller = null;
removeStyle(documentRef, CHAR_PAGE_STYLE_ID);
documentRef?.getElementById?.(CHAR_PAGE_BTN_ID)?.remove();
}
function autoInitCharacterPageButton() {
if (!globalThis.document) return () => {
};
initCharacterPageButton({ documentRef: globalThis.document });
return () => destroyCharacterPageButton({ documentRef: globalThis.document });
}
if (typeof document !== "undefined") {
autoInitHeaderOpener();
autoInitCardViewerButtons();
autoInitCharacterPageButton();
}
exports.autoInitCardViewerButtons = autoInitCardViewerButtons;
exports.autoInitCharacterPageButton = autoInitCharacterPageButton;
exports.autoInitHeaderOpener = autoInitHeaderOpener;
exports.buildDownloadArtifact = buildDownloadArtifact;
exports.buildExportContent = buildExportContent;
exports.buildGenerateAlphaPayload = buildGenerateAlphaPayload;
exports.buildSendMessageRequestBody = buildSendMessageRequestBody;
exports.createCharacterViewerTab = createCharacterViewerTab;
exports.createInternalOrchestrator = createInternalOrchestrator;
exports.createJanitorClient = createJanitorClient;
exports.destroyCardViewerButtons = destroyCardViewerButtons;
exports.destroyCharacterPageButton = destroyCharacterPageButton;
exports.destroyHeaderOpener = destroyHeaderOpener;
exports.extractCharacterIdFromInput = extractCharacterIdFromInput;
exports.initCardViewerButtons = initCardViewerButtons;
exports.initCharacterPageButton = initCharacterPageButton;
exports.initHeaderOpener = initHeaderOpener;
exports.normalizeCharacterFields = normalizeCharacterFields;
exports.parseGenerateAlphaFields = parseGenerateAlphaFields;
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
return exports;
})({});