-
❓ .gitignoreUnknown, 37 B⬇
-
❓ README.mdUnknown, 2.94 kB⬇
-
🌐 admin_panel.phpWeb, 1.54 kB⬇
-
🌐 api.jsWeb, 22.21 kB⬇
-
🌐 api.phpWeb, 9.62 kB⬇
-
🌐 app.jsWeb, 61.42 kB⬇
-
🌐 attachments.jsWeb, 7.30 kB⬇
-
🌐 base.cssWeb, 2.23 kB⬇
-
🌐 choices-editor.jsWeb, 28.11 kB⬇
-
🌐 choices.cssWeb, 1.14 kB⬇
-
🌐 content.jsWeb, 31.08 kB⬇
-
🌐 context.jsWeb, 9.26 kB⬇
-
🌐 conversation-index.jsWeb, 10.74 kB⬇
-
🌐 conversation.cssWeb, 16.33 kB⬇
-
🌐 conversation.jsWeb, 1.11 kB⬇
-
🌐 dialogs.cssWeb, 11.43 kB⬇
-
🌐 diction.jsWeb, 948 B⬇
-
🌐 document-index.jsWeb, 1.04 kB⬇
-
🌐 document-model.jsWeb, 12.88 kB⬇
-
🌐 document.cssWeb, 6.74 kB⬇
-
🌐 document.jsWeb, 44.96 kB⬇
-
🌐 dynamic.phpWeb, 533 B⬇
-
📷 favicon-lg.pngImage, 9.11 kB⬇
-
📷 favicon.pngImage, 3.12 kB⬇
-
🌐 index-lists.cssWeb, 4.77 kB⬇
-
🌐 index.phpWeb, 49.72 kB⬇
-
🌐 indexjs.jsWeb, 377 B⬇
-
🌐 instant-answer.cssWeb, 5.17 kB⬇
-
🌐 layout.cssWeb, 11.37 kB⬇
-
🌐 login.phpWeb, 997 B⬇
-
❓ manifest.webmanifestUnknown, 262 B⬇
-
🌐 memory.jsWeb, 407 B⬇
-
🌐 mermaid.cssWeb, 6.16 kB⬇
-
🌐 model-presets.jsonWeb, 5.51 kB⬇
-
🌐 models.jsonWeb, 8.05 kB⬇
-
🌐 notes.cssWeb, 11.97 kB⬇
-
🌐 overlays.cssWeb, 13.97 kB⬇
-
⚠️ php.iniOS File, 424 B⬇
-
🌐 rankings-view.jsWeb, 5.67 kB⬇
-
🌐 rankings.cssWeb, 3.35 kB⬇
-
🌐 scratchpad-conversation.jsWeb, 4.87 kB⬇
-
🌐 scratchpad-index.jsWeb, 15.55 kB⬇
-
🌐 scratchpad-model.jsWeb, 13.29 kB⬇
-
🌐 scratchpad.cssWeb, 3.12 kB⬇
-
🌐 settings.cssWeb, 4.65 kB⬇
-
🌐 shared-view.cssWeb, 3.48 kB⬇
-
🌐 shared.cssWeb, 16.26 kB⬇
-
🌐 shared.jsWeb, 14.63 kB⬇
-
🌐 shared.phpWeb, 3.28 kB⬇
-
🌐 sticky-notes-editor.jsWeb, 64.84 kB⬇
-
🌐 storage.jsWeb, 36.73 kB⬇
-
🌐 style.cssWeb, 12.06 kB⬇
-
🌐 sw.jsWeb, 1.69 kB⬇
-
🌐 sync.jsWeb, 19.02 kB⬇
-
🌐 tools-index.jsWeb, 15.00 kB⬇
-
🌐 users.jsWeb, 49.86 kB⬇
-
🌐 utility-models.jsonWeb, 1.04 kB⬇
-
🌐 voice-models.jsonWeb, 4.81 kB⬇
import Api from './api.js';
import { generateUUID } from './util/format-utils.js';
import { get_auth_storage_key, KEY_ACTIVE_USER_ID } from './util/auth-storage.js';
/**
* Class Storage
* Manages the application's local storage persistence.
* Handles configuration settings, the global conversation index,
* and individual conversation history data.
*/
class Storage {
/**
* Initializes Api instance and default prefix state.
*/
constructor() {
this.api = new Api();
this.user_id_prefix = '';
this.KEY_LOCATION_ID = 'LOCATION_ID';
this.KEY_AUTH_TOKEN = 'AUTH_TOKEN';
this.KEY_APP_VERSION_DATE = 'APP_VERSION_DATE';
this.KEY_ACTIVE_USER_ID = KEY_ACTIVE_USER_ID;
this.KEY_LOGGED_IN_USERS = 'LOGGED_IN_USERS';
this.AUDIO_CACHE_MAX_BYTES = 50 * 1024 * 1024;
}
/**
* Initializes location ID, loads models, retrieves user ID, and sets up storage keys.
* @returns {Promise<void>}
*/
async init() {
this.init_location_id();
await this.api.load_models();
const user_id = await this.api.get_user_id();
if (user_id) {
this.user_id_prefix = user_id + '_';
}
this.KEY_APP_CONFIG = this.user_id_prefix + 'APP_CONFIG';
this.KEY_CONFIG_THEME = 'THEME';
this.KEY_CONFIG_UTILITY_MODEL = 'UTILITY_MODEL';
this.KEY_CONFIG_TTS_MODEL = 'TTS_MODEL';
this.KEY_CONFIG_TTS_VOICE = 'TTS_VOICE';
this.KEY_CONFIG_INSTANT_ANSWER_MODEL = 'INSTANT_ANSWER_MODEL';
this.KEY_CONFIG_INSTANT_ANSWER_SEARCH = 'INSTANT_ANSWER_SEARCH';
this.KEY_CONFIG_INSTANT_ANSWER_AUTO_READ = 'INSTANT_ANSWER_AUTO_READ';
this.KEY_CONFIG_SELECTED_CONVERSATION_GUID = 'SELECTED_CONVERSATION_GUID';
this.KEY_CONFIG_EXPERIMENTAL_FEATURES = 'EXPERIMENTAL_FEATURES';
this.KEY_CONFIG_BACKGROUND_KEEP_ALIVE = 'BACKGROUND_KEEP_ALIVE';
this.KEY_CONFIG_ORGANIZER = 'ORGANIZER';
this.KEY_CONFIG_ATTACHMENTS = 'ATTACHMENTS';
this.KEY_CONFIG_APP_DOCUMENTS = 'APP_DOCUMENTS';
this.KEY_CONFIG_DOCUMENTS = 'DOCUMENTS';
this.KEY_CONFIG_MEMORY = 'MEMORY';
this.KEY_CONFIG_DICTION = 'DICTION';
this.KEY_CONFIG_SHOW_CHUNKS = 'SHOW_CHUNKS';
this.KEY_CONFIG_COLLABORATION = 'COLLABORATION';
this.KEY_CONFIG_DEFAULTS = this.user_id_prefix + 'APP_CONFIG_DEFAULTS';
this.KEY_CODE_ANALYSIS = this.user_id_prefix + 'CODE_ANALYSIS';
this.KEY_CONFIG_DEFAULT_VERBOSITY = 'VERBOSITY';
this.KEY_CONFIG_DEFAULT_MODEL = 'MODEL';
this.KEY_CONFIG_DEFAULT_MODEL_FAMILY = 'MODEL_FAMILY';
this.KEY_CONFIG_DEFAULT_MODEL_VERSION = 'MODEL_VERSION';
this.KEY_CONFIG_DEFAULT_THINKING_LEVEL = 'THINKING_LEVEL';
this.KEY_CONFIG_DEFAULT_SYNCED = 'SYNCED';
this.KEY_CONFIG_SHOW_SUGGESTED_QUERIES = 'SHOW_SUGGESTED_QUERIES';
this.KEY_CONFIG_SHOW_RELATED_QUERIES = 'SHOW_RELATED_QUERIES';
this.KEY_CONFIG_ENFORCE_TOPICS = 'ENFORCE_TOPICS';
this.KEY_CONFIG_AUTO_RUN_PROPOSED_QUERIES = 'AUTO_RUN_PROPOSED_QUERIES';
this.KEY_CONFIG_PLAY_CHIME = 'PLAY_CHIME';
this.KEY_CONFIG_ENABLE_WEB_SEARCH = 'ENABLE_WEB_SEARCH';
this.KEY_CONFIG_DISABLE_SAFEGUARDS = 'DISABLE_SAFEGUARDS';
this.KEY_CONFIG_ENCOURAGE_CREATIVITY = 'ENCOURAGE_CREATIVITY';
this.KEY_CONFIG_MAX_TOOL_USE = 'MAX_TOOL_USE';
this.KEY_CONFIG_ALLOW_TOOL_REQUESTS = 'ALLOW_TOOL_REQUESTS';
this.KEY_CONFIG_UI_ZOOM = 'UI_ZOOM';
this.KEY_APP_INDEX = this.user_id_prefix + 'APP_INDEX';
this.KEY_INDEX_DATE_CREATED = 'DATE_CREATED';
this.KEY_INDEX_DATE_UPDATED = 'DATE_UPDATED';
this.KEY_INDEX_SYNC = 'SYNC';
this.KEY_INDEX_GUID = 'GUID';
this.KEY_SHOW_ARCHIVES = 'SHOW_ARCHIVES';
this.KEY_APP_CONVERSATION_PREFIX = this.user_id_prefix + 'CONVERSATION_';
this.KEY_CONVERSATION_TITLE = 'TITLE';
this.KEY_CONVERSATION_CUSTOM_TITLE = 'CUSTOM_TITLE';
this.KEY_CONVERSATION_SUMMARY = 'SUMMARY';
this.KEY_CONVERSATION_TIMESTAMP = 'TIMESTAMP';
this.KEY_CONVERSATION_HISTORY = 'HISTORY';
this.KEY_CONVERSATION_ARCHIVED = 'ARCHIVED';
this.KEY_CONVERSATION_VERBOSITY = 'VERBOSITY';
this.KEY_CONVERSATION_MODEL = 'MODEL';
this.KEY_CONVERSATION_MODEL_FAMILY = 'MODEL_FAMILY';
this.KEY_CONVERSATION_MODEL_VERSION = 'MODEL_VERSION';
this.KEY_CONVERSATION_THINKING_LEVEL = 'THINKING_LEVEL';
this.KEY_CONVERSATION_TYPE = 'TYPE';
this.KEY_CONVERSATION_SHOW_SUGGESTED_QUERIES = 'SHOW_SUGGESTED_QUERIES';
this.KEY_CONVERSATION_SHOW_RELATED_QUERIES = 'SHOW_RELATED_QUERIES';
this.KEY_CONVERSATION_ENFORCE_TOPICS = 'ENFORCE_TOPICS';
this.KEY_CONVERSATION_AUTO_RUN_PROPOSED_QUERIES = 'AUTO_RUN_PROPOSED_QUERIES';
this.KEY_CONVERSATION_DISABLE_SAFEGUARDS = 'DISABLE_SAFEGUARDS';
this.KEY_CONVERSATION_ENCOURAGE_CREATIVITY = 'ENCOURAGE_CREATIVITY';
this.KEY_CONVERSATION_TOPICS = 'TOPICS';
this.KEY_CONVERSATION_CONSIDERATIONS = 'CONSIDERATIONS';
this.KEY_CONVERSATION_ATTACHMENTS = 'ATTACHMENTS';
this.KEY_CONVERSATION_SEARCH = 'SEARCH';
this.KEY_CONVERSATION_SYNCED = 'SYNCED';
this.KEY_CONVERSATION_DOCUMENT = 'DOCUMENT';
this.KEY_CONVERSATION_CHOICES = 'CHOICES';
this.KEY_CONVERSATION_CHOICES_HASH = 'CHOICES_HASH';
this.KEY_CONVERSATION_READ_COSTS = 'READ_COSTS';
this.KEY_CONVERSATION_RANKINGS = 'RANKINGS';
this.KEY_CONVERSATION_STICKIES = 'STICKIES';
this.KEY_CONVERSATION_STICKIES_HASH = 'STICKIES_HASH';
this.KEY_CONVERSATION_TAGS = 'TAGS';
this.KEY_CONVERSATION_THINKING = 'THINKING';
this.KEY_CONVERSATION_DICTION = 'DICTION';
this.KEY_CONVERSATION_DOCUMENT_TYPE = 'DOCUMENT_TYPE';
this.CONVERSATION_TYPE_CHAT = 'CHAT';
this.CONVERSATION_TYPE_DOCUMENT = 'DOCUMENT';
this.CONVERSATION_TYPE_SCRATCHPAD = 'SCRATCHPAD';
this.CONVERSATION_TYPE_RANKING = 'RANKING';
this.CONVERSATION_TYPE_COLORS = 'COLORS';
this.CONVERSATION_TYPE_STICKY_NOTES = 'STICKY_NOTES';
this.DOCUMENT_TYPE_PAGE = 'PAGE';
this.DOCUMENT_TYPE_JOURNAL = 'JOURNAL';
this.DOCUMENT_TYPE_BOOK = 'BOOK';
this.DOCUMENT_TYPE_OUTLINE = 'OUTLINE';
}
/**
* Retrieves the stored JWT authentication token for the active user from localStorage.
* @returns {string|null}
*/
get_auth_token() {
return localStorage.getItem(get_auth_storage_key(this.KEY_AUTH_TOKEN));
}
/**
* Sets the JWT authentication token for the active user in localStorage.
* @param {string} token
*/
set_auth_token(token) {
if (token) {
localStorage.setItem(get_auth_storage_key(this.KEY_AUTH_TOKEN), String(token));
}
}
/**
* Clears the active user's stored JWT authentication token from localStorage.
*/
clear_auth_token() {
localStorage.removeItem(get_auth_storage_key(this.KEY_AUTH_TOKEN));
}
/**
* Retrieves the stored version date for the active user from localStorage.
* @returns {string|null}
*/
get_stored_version_date() {
return localStorage.getItem(get_auth_storage_key(this.KEY_APP_VERSION_DATE));
}
/**
* Saves the current version date for the active user in localStorage.
* @param {string} date - Version date string (e.g. '20260909').
*/
set_stored_version_date(date) {
if (date) {
localStorage.setItem(get_auth_storage_key(this.KEY_APP_VERSION_DATE), String(date));
}
}
/**
* Ensures a unique location UUID exists for the active user in localStorage.
* @returns {string} The location UUID.
*/
init_location_id() {
const key = get_auth_storage_key(this.KEY_LOCATION_ID);
let location_id = localStorage.getItem(key);
if (!location_id) {
location_id = generateUUID();
localStorage.setItem(key, location_id);
}
return location_id;
}
/**
* Retrieves the location UUID for the active user from localStorage.
* @returns {string|null}
*/
get_location_id() {
return localStorage.getItem(get_auth_storage_key(this.KEY_LOCATION_ID));
}
//region Active User & Multi-User Session Cache
/**
* Retrieves the active user id from localStorage.
* @returns {string|null}
*/
get_active_user_id() {
return localStorage.getItem(this.KEY_ACTIVE_USER_ID);
}
/**
* Sets the active user id in localStorage.
* @param {string|number} userId
*/
set_active_user_id(userId) {
localStorage.setItem(this.KEY_ACTIVE_USER_ID, String(userId));
}
/**
* Sets the active user and stores their token (and optionally location id) under user-scoped keys.
* @param {string|number} userId
* @param {string} token - JWT token.
* @param {string|null} [locationId=null] - Location UUID used for this user's session.
*/
set_active_user_auth(userId, token, locationId = null) {
this.set_active_user_id(userId);
localStorage.setItem(`${userId}_${this.KEY_AUTH_TOKEN}`, String(token));
if (locationId) {
localStorage.setItem(`${userId}_${this.KEY_LOCATION_ID}`, locationId);
}
}
/**
* Records a successful authentication: sets the active user's token
* and adds the user to the logged-in registry.
* @param {Object} user - User object with at least id, username, name.
* @param {string} token - Fresh JWT token.
*/
complete_authentication(user, token) {
if (!user || !user.id) return;
this.set_active_user_auth(String(user.id), token);
this.add_logged_in_user(user);
}
/**
* Removes the active user's session: token, location id, and registry entry.
* Leaves other cached users untouched.
*/
clear_active_session() {
const userId = this.get_active_user_id();
if (userId !== null) {
localStorage.removeItem(`${userId}_${this.KEY_AUTH_TOKEN}`);
localStorage.removeItem(`${userId}_${this.KEY_LOCATION_ID}`);
this.remove_logged_in_user(userId);
}
localStorage.removeItem(this.KEY_ACTIVE_USER_ID);
}
/**
* Retrieves the registry of users logged in on this device.
* @returns {Array<{id: number|string, username: string, name: string}>}
*/
get_logged_in_users() {
return JSON.parse(localStorage.getItem(this.KEY_LOGGED_IN_USERS) || '[]');
}
/**
* Adds or updates a user in the logged-in registry.
* @param {Object} user - User object with at least id and username.
*/
add_logged_in_user(user) {
if (!user || !user.id) return;
const users = this.get_logged_in_users().filter(u => String(u.id) !== String(user.id));
users.push({ id: user.id, username: user.username || '', name: user.name || '' });
localStorage.setItem(this.KEY_LOGGED_IN_USERS, JSON.stringify(users));
}
/**
* Removes a user from the logged-in registry.
* @param {string|number} userId
*/
remove_logged_in_user(userId) {
const users = this.get_logged_in_users().filter(u => String(u.id) !== String(userId));
localStorage.setItem(this.KEY_LOGGED_IN_USERS, JSON.stringify(users));
}
/**
* Returns the location UUID to use for a fresh login. Reuses the cached
* location id when the username is already registered on this device,
* otherwise generates a new UUID.
* @param {string} username
* @returns {string} Location UUID.
*/
get_login_location(username) {
const target = username.toLowerCase();
const user = this.get_logged_in_users().find(u => (u.username || '').toLowerCase() === target);
const existing = user ? localStorage.getItem(`${user.id}_${this.KEY_LOCATION_ID}`) : null;
return existing || generateUUID();
}
//endregion
//region Code Analysis Cache
/**
* Retrieves cached code analysis results for a code block hash.
* @param {string} hash - The code block hash key.
* @returns {Object|null} The cached analysis data, or null when absent.
*/
get_code_analysis(hash) {
if (!hash) return null;
const cache = JSON.parse(localStorage.getItem(this.KEY_CODE_ANALYSIS) || '{}');
return cache[hash] || null;
}
/**
* Saves code analysis results for a code block hash, trimming the cache to
* the most recent entries.
* @param {string} hash - The code block hash key.
* @param {Object} data - The analysis data to cache.
*/
save_code_analysis(hash, data) {
if (!hash || !data) return;
const cache = JSON.parse(localStorage.getItem(this.KEY_CODE_ANALYSIS) || '{}');
cache[hash] = data;
const MAX_ENTRIES = 200;
const keys = Object.keys(cache);
if (keys.length > MAX_ENTRIES) {
keys.sort((a, b) => (cache[a].timestamp || 0) - (cache[b].timestamp || 0));
for (const key of keys.slice(0, keys.length - MAX_ENTRIES)) {
delete cache[key];
}
}
localStorage.setItem(this.KEY_CODE_ANALYSIS, JSON.stringify(cache));
}
//endregion
//region App Defaults
/**
* Updates a specific key in the application defaults object and dispatches a storage event.
* @param {string} key The key for the item in the APP_CONFIG_DEFAULTS object to be updated.
* @param {*} value The new value to be assigned to the key.
*/
update_app_defaults(key, value) {
const defaults = this.get_app_defaults();
defaults[key] = value;
localStorage.setItem(this.KEY_CONFIG_DEFAULTS, JSON.stringify(defaults));
const event = new StorageEvent('storage', {
key: this.KEY_CONFIG_DEFAULTS
});
window.dispatchEvent(event);
}
/**
* Retrieves the current application defaults object from localStorage.
* @returns {Object} The parsed defaults object.
*/
get_app_defaults() {
const defaults = JSON.parse(localStorage.getItem(this.KEY_CONFIG_DEFAULTS) || '{}');
if (defaults[this.KEY_CONFIG_DEFAULT_VERBOSITY] === undefined) {
defaults[this.KEY_CONFIG_DEFAULT_VERBOSITY] = 'standard';
}
if (defaults[this.KEY_CONFIG_DEFAULT_MODEL] === undefined) {
defaults[this.KEY_CONFIG_DEFAULT_MODEL] = 'basic';
}
if (defaults[this.KEY_CONFIG_DEFAULT_MODEL_VERSION] === undefined || defaults[this.KEY_CONFIG_DEFAULT_MODEL_FAMILY] === undefined) {
const presetKey = defaults[this.KEY_CONFIG_DEFAULT_MODEL] || 'basic';
const preset = Api.PRESETS[presetKey] || Api.PRESETS['basic'];
if (preset) {
defaults[this.KEY_CONFIG_DEFAULT_MODEL_FAMILY] = preset.provider || 'gemini';
defaults[this.KEY_CONFIG_DEFAULT_MODEL_VERSION] = preset.model || 'gemini-3.5-flash-lite';
defaults[this.KEY_CONFIG_DEFAULT_THINKING_LEVEL] = preset.thinking !== undefined ? preset.thinking : 'MINIMAL';
} else {
defaults[this.KEY_CONFIG_DEFAULT_MODEL_FAMILY] = 'gemini';
defaults[this.KEY_CONFIG_DEFAULT_MODEL_VERSION] = 'gemini-3.5-flash-lite';
defaults[this.KEY_CONFIG_DEFAULT_THINKING_LEVEL] = 'MINIMAL';
}
}
if (defaults[this.KEY_CONFIG_SHOW_SUGGESTED_QUERIES] === undefined) {
defaults[this.KEY_CONFIG_SHOW_SUGGESTED_QUERIES] = false;
}
if (defaults[this.KEY_CONFIG_SHOW_RELATED_QUERIES] === undefined) {
defaults[this.KEY_CONFIG_SHOW_RELATED_QUERIES] = false;
}
if (defaults[this.KEY_CONFIG_ENFORCE_TOPICS] === undefined) {
defaults[this.KEY_CONFIG_ENFORCE_TOPICS] = false;
}
if (defaults[this.KEY_CONFIG_AUTO_RUN_PROPOSED_QUERIES] === undefined) {
defaults[this.KEY_CONFIG_AUTO_RUN_PROPOSED_QUERIES] = false;
}
if (defaults[this.KEY_CONFIG_PLAY_CHIME] === undefined) {
defaults[this.KEY_CONFIG_PLAY_CHIME] = false;
}
if (defaults[this.KEY_CONFIG_ENABLE_WEB_SEARCH] === undefined) {
defaults[this.KEY_CONFIG_ENABLE_WEB_SEARCH] = false;
}
if (defaults[this.KEY_CONFIG_DISABLE_SAFEGUARDS] === undefined) {
defaults[this.KEY_CONFIG_DISABLE_SAFEGUARDS] = false;
}
if (defaults[this.KEY_CONFIG_ENCOURAGE_CREATIVITY] === undefined) {
defaults[this.KEY_CONFIG_ENCOURAGE_CREATIVITY] = false;
}
return defaults;
}
//endregion
//region App Configuration
/**
* Updates a specific key in the application configuration object and dispatches a storage event.
* @param {string} key The key for the item in the APP_CONFIG object to be updated.
* @param {*} value The new value to be assigned to the key.
*/
update_app_config(key, value) {
const config = this.get_app_config();
config[key] = value;
localStorage.setItem(this.KEY_APP_CONFIG, JSON.stringify(config));
const event = new StorageEvent('storage', {
key: this.KEY_APP_CONFIG
});
window.dispatchEvent(event);
}
/**
* Retrieves the current application configuration object from localStorage.
* @returns {Object} The parsed configuration object.
*/
get_app_config() {
return JSON.parse(localStorage.getItem(this.KEY_APP_CONFIG) || '{}');
}
//endregion
//region App Index Management
/**
* Updates or creates metadata for a conversation in the global application index.
* @param {string} guid The unique identifier of the conversation. If empty, a new GUID is generated; unknown guids get a new entry.
* @param {boolean} [sync] Whether the conversation should be marked for synchronization. When omitted on update, the existing sync flag is preserved.
* @param {string|null} [type] The type of conversation, defaults to CHAT.
* @returns {string} The GUID of the updated or newly created conversation.
*/
update_app_index(guid, sync, type = null) {
const index = this.get_app_index();
const date_string = new Date().toISOString();
let conversation_meta = guid ? index.find(item => item[this.KEY_INDEX_GUID] === guid) : null;
if (!conversation_meta) {
if (!guid) {
guid = generateUUID();
}
conversation_meta = {
[this.KEY_INDEX_GUID]: guid,
[this.KEY_INDEX_DATE_CREATED]: date_string,
[this.KEY_INDEX_DATE_UPDATED]: date_string,
[this.KEY_INDEX_SYNC]: sync || false,
[this.KEY_CONVERSATION_TYPE]: type || this.CONVERSATION_TYPE_CHAT
};
index.push(conversation_meta);
} else {
conversation_meta[this.KEY_INDEX_DATE_UPDATED] = date_string;
if (sync !== undefined) {
conversation_meta[this.KEY_INDEX_SYNC] = sync || false;
}
if (type) {
conversation_meta[this.KEY_CONVERSATION_TYPE] = type;
}
}
localStorage.setItem(this.KEY_APP_INDEX, JSON.stringify(index));
const event = new StorageEvent('storage', {
key: this.KEY_APP_INDEX
});
window.dispatchEvent(event);
return guid;
}
/**
* Retrieves the global application index array from localStorage.
* @returns {Array<Object>} The array of conversation metadata objects.
*/
get_app_index() {
return JSON.parse(localStorage.getItem(this.KEY_APP_INDEX) || '[]');
}
/**
* Removes a conversation's metadata from the application index.
* @param {string} guid The unique identifier of the conversation to delete.
*/
index_delete(guid) {
const index = this.get_app_index();
const new_index = index.filter(item => item[this.KEY_INDEX_GUID] !== guid);
localStorage.setItem(this.KEY_APP_INDEX, JSON.stringify(new_index));
localStorage.removeItem(this.KEY_APP_CONVERSATION_PREFIX + guid);
const event = new StorageEvent('storage', {
key: this.KEY_APP_INDEX
});
window.dispatchEvent(event);
}
/**
* Deletes a conversation's entry from the app index. Thin alias kept so
* callers read consistently with the other index operations.
* @param {string} guid - The conversation guid.
*/
delete_from_app_index(guid) {
this.index_delete(guid);
}
/**
* Deletes all conversation records and index entries from localStorage.
*/
delete_all_conversations() {
const index = this.get_app_index();
index.forEach(item => {
const guid = item[this.KEY_INDEX_GUID];
localStorage.removeItem(this.KEY_APP_CONVERSATION_PREFIX + guid);
});
localStorage.removeItem(this.KEY_APP_INDEX);
const event = new StorageEvent('storage', {
key: this.KEY_APP_INDEX
});
window.dispatchEvent(event);
}
//endregion
//region Conversation Management
/**
* Retrieves a full conversation object by its GUID from localStorage.
* Initializes default fields if they do not exist.
* @param {string} guid The unique identifier of the conversation.
* @returns {Object} The complete conversation object with defaults.
*/
get_conversation(guid) {
let conversation = JSON.parse(localStorage.getItem(this.KEY_APP_CONVERSATION_PREFIX + guid) || '{}');
const app_defaults = this.get_app_defaults();
const index = this.get_app_index();
const index_item = index.find(item => item[this.KEY_INDEX_GUID] === guid);
const index_type = index_item?.[this.KEY_CONVERSATION_TYPE];
if (conversation[this.KEY_CONVERSATION_TYPE] === undefined) {
conversation[this.KEY_CONVERSATION_TYPE] = index_type || this.CONVERSATION_TYPE_CHAT;
}
if (!conversation[this.KEY_CONVERSATION_TITLE]) {
if (conversation[this.KEY_CONVERSATION_TYPE] === this.CONVERSATION_TYPE_SCRATCHPAD) {
conversation[this.KEY_CONVERSATION_TITLE] = 'New Scratchpad';
} else if (conversation[this.KEY_CONVERSATION_TYPE] === this.CONVERSATION_TYPE_DOCUMENT) {
const doc_type = conversation[this.KEY_CONVERSATION_DOCUMENT_TYPE];
conversation[this.KEY_CONVERSATION_TITLE] = doc_type ? 'New ' + this.get_document_type_label(doc_type) : 'New Document';
} else {
conversation[this.KEY_CONVERSATION_TITLE] = 'New Conversation';
}
}
if (!conversation[this.KEY_CONVERSATION_SUMMARY]) {
conversation[this.KEY_CONVERSATION_SUMMARY] = '';
}
if (conversation[this.KEY_CONVERSATION_CUSTOM_TITLE] === undefined) {
conversation[this.KEY_CONVERSATION_CUSTOM_TITLE] = false;
}
if (!conversation[this.KEY_CONVERSATION_TIMESTAMP]) {
conversation[this.KEY_CONVERSATION_TIMESTAMP] = new Date().toISOString();
}
if (!conversation[this.KEY_CONVERSATION_HISTORY]) {
conversation[this.KEY_CONVERSATION_HISTORY] = [];
}
if (conversation[this.KEY_CONVERSATION_ARCHIVED] === undefined) {
conversation[this.KEY_CONVERSATION_ARCHIVED] = false;
}
if (conversation[this.KEY_CONVERSATION_VERBOSITY] === undefined) {
conversation[this.KEY_CONVERSATION_VERBOSITY] = app_defaults[this.KEY_CONFIG_DEFAULT_VERBOSITY];
}
if (conversation[this.KEY_CONVERSATION_MODEL] === undefined) {
conversation[this.KEY_CONVERSATION_MODEL] = app_defaults[this.KEY_CONFIG_DEFAULT_MODEL];
}
if (conversation[this.KEY_CONVERSATION_MODEL_FAMILY] === undefined) {
conversation[this.KEY_CONVERSATION_MODEL_FAMILY] = app_defaults[this.KEY_CONFIG_DEFAULT_MODEL_FAMILY] || 'gemini';
}
if (conversation[this.KEY_CONVERSATION_MODEL_VERSION] === undefined) {
conversation[this.KEY_CONVERSATION_MODEL_VERSION] = app_defaults[this.KEY_CONFIG_DEFAULT_MODEL_VERSION] || 'gemini-3.5-flash-lite';
}
if (conversation[this.KEY_CONVERSATION_THINKING_LEVEL] === undefined) {
conversation[this.KEY_CONVERSATION_THINKING_LEVEL] = app_defaults[this.KEY_CONFIG_DEFAULT_THINKING_LEVEL] !== undefined ? app_defaults[this.KEY_CONFIG_DEFAULT_THINKING_LEVEL] : 'MINIMAL';
}
if (conversation[this.KEY_CONVERSATION_SHOW_SUGGESTED_QUERIES] === undefined) {
conversation[this.KEY_CONVERSATION_SHOW_SUGGESTED_QUERIES] = app_defaults[this.KEY_CONFIG_SHOW_SUGGESTED_QUERIES];
}
if (conversation[this.KEY_CONVERSATION_SHOW_RELATED_QUERIES] === undefined) {
conversation[this.KEY_CONVERSATION_SHOW_RELATED_QUERIES] = app_defaults[this.KEY_CONFIG_SHOW_RELATED_QUERIES];
}
if (conversation[this.KEY_CONVERSATION_ENFORCE_TOPICS] === undefined) {
conversation[this.KEY_CONVERSATION_ENFORCE_TOPICS] = app_defaults[this.KEY_CONFIG_ENFORCE_TOPICS];
}
if (conversation[this.KEY_CONVERSATION_AUTO_RUN_PROPOSED_QUERIES] === undefined) {
conversation[this.KEY_CONVERSATION_AUTO_RUN_PROPOSED_QUERIES] = false;
}
if (conversation[this.KEY_CONVERSATION_TOPICS] === undefined) {
conversation[this.KEY_CONVERSATION_TOPICS] = [];
}
if (conversation[this.KEY_CONVERSATION_CONSIDERATIONS] === undefined) {
conversation[this.KEY_CONVERSATION_CONSIDERATIONS] = [];
}
if (conversation[this.KEY_CONVERSATION_ATTACHMENTS] === undefined) {
conversation[this.KEY_CONVERSATION_ATTACHMENTS] = [];
}
if (conversation[this.KEY_CONVERSATION_SEARCH] === undefined) {
conversation[this.KEY_CONVERSATION_SEARCH] = app_defaults[this.KEY_CONFIG_ENABLE_WEB_SEARCH] || false;
}
if (conversation[this.KEY_CONVERSATION_STICKIES] === undefined) {
conversation[this.KEY_CONVERSATION_STICKIES] = [];
}
if (conversation[this.KEY_CONVERSATION_TAGS] === undefined) {
conversation[this.KEY_CONVERSATION_TAGS] = [];
}
return conversation;
}
/**
* Retrieves the full conversation object for the currently selected GUID from localStorage.
* @returns {Object|null} The conversation object or null if none selected.
*/
get_selected_conversation() {
const config = this.get_app_config();
const guid = config[this.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
if (!guid) return null;
return this.get_conversation(guid);
}
/**
* Saves the full conversation object for the given GUID to localStorage
* and notifies the on_conversation_saved hook (used by the sync service).
* @param {string} guid The unique identifier of the conversation.
* @param {Object} conversation The conversation object to save.
* @param {boolean} [quiet=false] - Whether to skip the storage event that
* triggers a re-render of the active view
* (background updates that already
* rendered their change).
*/
save_conversation(guid, conversation, quiet = false) {
if (!guid) return;
localStorage.setItem(this.KEY_APP_CONVERSATION_PREFIX + guid, JSON.stringify(conversation));
if (!quiet) {
window.dispatchEvent(new StorageEvent('storage', {
key: this.KEY_APP_CONVERSATION_PREFIX + guid
}));
}
if (this.on_conversation_saved) {
this.on_conversation_saved(guid, conversation);
}
}
/**
* Updates a single field in a conversation and dispatches a storage event.
* @param {string} guid The unique identifier of the conversation.
* @param {string} field The field name to update.
* @param {*} value The value to assign to the field.
*/
update_conversation_field(guid, field, value) {
if (!guid) return;
const conversation = this.get_conversation(guid);
conversation[field] = value;
this.save_conversation(guid, conversation);
}
/**
* Adds a new topic to a conversation if it is not already present.
* @param {string} guid The unique identifier of the conversation.
* @param {string} topic The topic to add.
*/
add_conversation_topic(guid, topic) {
if (!guid || !topic) return;
const conversation = this.get_conversation(guid);
const topics = conversation[this.KEY_CONVERSATION_TOPICS] || [];
if (!topics.includes(topic)) {
topics.push(topic);
this.update_conversation_field(guid, this.KEY_CONVERSATION_TOPICS, topics);
}
}
/**
* Alias for add_conversation_topic.
* @param {string} guid - The unique identifier of the conversation.
* @param {string} topic - The topic to add.
*/
addTopicToConversation(guid, topic) {
this.add_conversation_topic(guid, topic);
}
/**
* Removes a topic from a conversation.
* @param {string} guid The unique identifier of the conversation.
* @param {string} topic The topic to remove.
*/
remove_conversation_topic(guid, topic) {
if (!guid || !topic) return;
const conversation = this.get_conversation(guid);
let topics = conversation[this.KEY_CONVERSATION_TOPICS] || [];
if (topics.includes(topic)) {
topics = topics.filter(t => t !== topic);
this.update_conversation_field(guid, this.KEY_CONVERSATION_TOPICS, topics);
}
}
/**
* Alias for remove_conversation_topic.
* @param {string} guid - The unique identifier of the conversation.
* @param {string} topic - The topic to remove.
*/
deleteTopicFromConversation(guid, topic) {
this.remove_conversation_topic(guid, topic);
}
/**
* Adds a new consideration to a conversation if it is not already present.
* @param {string} guid The unique identifier of the conversation.
* @param {string} consideration The consideration to add.
*/
add_conversation_consideration(guid, consideration) {
if (!guid || !consideration) return;
const conversation = this.get_conversation(guid);
const considerations = conversation[this.KEY_CONVERSATION_CONSIDERATIONS] || [];
if (!considerations.includes(consideration)) {
considerations.push(consideration);
this.update_conversation_field(guid, this.KEY_CONVERSATION_CONSIDERATIONS, considerations);
}
}
/**
* Alias for add_conversation_consideration.
* @param {string} guid - The unique identifier of the conversation.
* @param {string} consideration - The consideration to add.
*/
addConsiderationToConversation(guid, consideration) {
this.add_conversation_consideration(guid, consideration);
}
/**
* Removes a consideration from a conversation.
* @param {string} guid The unique identifier of the conversation.
* @param {string} consideration The consideration to remove.
*/
remove_conversation_consideration(guid, consideration) {
if (!guid || !consideration) return;
const conversation = this.get_conversation(guid);
let considerations = conversation[this.KEY_CONVERSATION_CONSIDERATIONS] || [];
if (considerations.includes(consideration)) {
considerations = considerations.filter(c => c !== consideration);
this.update_conversation_field(guid, this.KEY_CONVERSATION_CONSIDERATIONS, considerations);
}
}
/**
* Alias for remove_conversation_consideration.
* @param {string} guid - The unique identifier of the conversation.
* @param {string} consideration - The consideration to remove.
*/
deleteConsiderationFromConversation(guid, consideration) {
this.remove_conversation_consideration(guid, consideration);
}
//endregion
/**
* Returns the display label for a document type.
* @param {string} type The document type constant.
* @returns {string} The display label, defaulting to 'Page'.
*/
get_document_type_label(type) {
switch (type) {
case this.DOCUMENT_TYPE_JOURNAL: return 'Journal';
case this.DOCUMENT_TYPE_BOOK: return 'Book';
case this.DOCUMENT_TYPE_OUTLINE: return 'Outline';
default: return 'Page';
}
}
/**
* Returns the label for the index column toggle button in document mode,
* named for what the index lists per document type.
* @param {string|null} type The document type constant.
* @returns {string} The display label, defaulting to 'History'.
*/
get_document_index_label(type) {
switch (type) {
case this.DOCUMENT_TYPE_JOURNAL: return 'Calendar';
case this.DOCUMENT_TYPE_BOOK: return 'Chapters';
case this.DOCUMENT_TYPE_OUTLINE: return 'Sections';
default: return 'History';
}
}
//region Attachments
/**
* Retrieves the global document metadata list from the app configuration.
* @returns {Array<Object>} Array of {id, name, mime, size, date}.
*/
get_app_documents() {
return this.get_app_config()[this.KEY_CONFIG_APP_DOCUMENTS] || [];
}
/**
* Adds a document metadata entry to the global document list.
* @param {Object} meta The document metadata {id, name, mime, size, date}.
*/
add_app_document(meta) {
if (!meta || !meta.id) return;
const documents = this.get_app_documents();
if (!documents.some(doc => doc.id === meta.id)) {
documents.push(meta);
this.update_app_config(this.KEY_CONFIG_APP_DOCUMENTS, documents);
}
}
/**
* Removes a document from the global list, its IndexedDB blob, and any
* conversation attachment lists referencing it.
* @param {string} id The document id.
*/
async remove_app_document(id) {
if (!id) return;
const documents = this.get_app_documents().filter(doc => doc.id !== id);
this.update_app_config(this.KEY_CONFIG_APP_DOCUMENTS, documents);
const index = this.get_app_index();
for (const item of index) {
const guid = item[this.KEY_INDEX_GUID];
const conversation = this.get_conversation(guid);
const attachments = conversation[this.KEY_CONVERSATION_ATTACHMENTS] || [];
if (attachments.includes(id)) {
this.update_conversation_field(guid, this.KEY_CONVERSATION_ATTACHMENTS, attachments.filter(a => a !== id));
}
}
await this.delete_document_blob(id);
}
/**
* Adds a document id to a conversation's attachment list if not already present.
* @param {string} guid The unique identifier of the conversation.
* @param {string} id The document id.
*/
add_conversation_attachment(guid, id) {
if (!guid || !id) return;
const conversation = this.get_conversation(guid);
const attachments = conversation[this.KEY_CONVERSATION_ATTACHMENTS] || [];
if (!attachments.includes(id)) {
attachments.push(id);
this.update_conversation_field(guid, this.KEY_CONVERSATION_ATTACHMENTS, attachments);
}
}
/**
* Removes a document id from a conversation's attachment list.
* @param {string} guid The unique identifier of the conversation.
* @param {string} id The document id.
*/
remove_conversation_attachment(guid, id) {
if (!guid || !id) return;
const conversation = this.get_conversation(guid);
const attachments = conversation[this.KEY_CONVERSATION_ATTACHMENTS] || [];
if (attachments.includes(id)) {
this.update_conversation_field(guid, this.KEY_CONVERSATION_ATTACHMENTS, attachments.filter(a => a !== id));
}
}
/**
* Opens (and initializes) the IndexedDB document blob store.
* @returns {Promise<IDBDatabase>}
*/
open_document_store() {
return new Promise((resolve, reject) => {
const request = indexedDB.open('tinai-documents', 1);
request.onupgradeneeded = () => {
if (!request.result.objectStoreNames.contains('documents')) {
request.result.createObjectStore('documents');
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
/**
* Saves a document blob in IndexedDB under the given id.
* @param {string} id The document id.
* @param {Blob} blob The file content.
* @returns {Promise<void>}
*/
save_document_blob(id, blob) {
return this.open_document_store().then(db => new Promise((resolve, reject) => {
const tx = db.transaction('documents', 'readwrite');
tx.objectStore('documents').put(blob, id);
tx.oncomplete = () => { db.close(); resolve(); };
tx.onerror = () => { db.close(); reject(tx.error); };
}));
}
/**
* Retrieves a document blob from IndexedDB.
* @param {string} id The document id.
* @returns {Promise<Blob|null>}
*/
get_document_blob(id) {
return this.open_document_store().then(db => new Promise((resolve, reject) => {
const tx = db.transaction('documents', 'readonly');
const request = tx.objectStore('documents').get(id);
request.onsuccess = () => { db.close(); resolve(request.result || null); };
request.onerror = () => { db.close(); reject(request.error); };
}));
}
/**
* Deletes a document blob from IndexedDB.
* @param {string} id The document id.
* @returns {Promise<void>}
*/
delete_document_blob(id) {
return this.open_document_store().then(db => new Promise((resolve, reject) => {
const tx = db.transaction('documents', 'readwrite');
tx.objectStore('documents').delete(id);
tx.oncomplete = () => { db.close(); resolve(); };
tx.onerror = () => { db.close(); reject(tx.error); };
}));
}
/**
* Opens (and initializes) the IndexedDB synthesized-audio cache store.
* @returns {Promise<IDBDatabase>}
*/
open_audio_store() {
return new Promise((resolve, reject) => {
const request = indexedDB.open('tinai-audio', 1);
request.onupgradeneeded = () => {
if (!request.result.objectStoreNames.contains('audio')) {
request.result.createObjectStore('audio', { keyPath: 'key' });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
/**
* Retrieves a synthesized audio blob from the cache.
* @param {string} key The cache key.
* @returns {Promise<Blob|null>}
*/
get_cached_audio(key) {
return this.open_audio_store().then(db => new Promise((resolve, reject) => {
const tx = db.transaction('audio', 'readonly');
const request = tx.objectStore('audio').get(key);
request.onsuccess = () => { db.close(); resolve(request.result ? request.result.blob : null); };
request.onerror = () => { db.close(); reject(request.error); };
}));
}
/**
* Saves a synthesized audio blob in the cache and trims the cache to the
* size cap, evicting the oldest entries first.
* @param {string} key The cache key.
* @param {Blob} blob The WAV audio.
* @returns {Promise<void>}
*/
save_cached_audio(key, blob) {
return this.open_audio_store().then(db => new Promise((resolve, reject) => {
const tx = db.transaction('audio', 'readwrite');
tx.objectStore('audio').put({ key: key, blob: blob, created: Date.now(), size: blob.size });
tx.oncomplete = () => { db.close(); resolve(); };
tx.onerror = () => { db.close(); reject(tx.error); };
})).then(() => this.trim_cached_audio());
}
/**
* Removes the oldest cached audio entries beyond the cache size cap.
* @returns {Promise<void>}
*/
trim_cached_audio() {
return this.open_audio_store().then(db => new Promise((resolve, reject) => {
const tx = db.transaction('audio', 'readwrite');
const store = tx.objectStore('audio');
const entries = [];
const walked = new Promise((done) => {
store.openCursor().onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
entries.push(cursor.value);
cursor.continue();
} else {
done();
}
};
});
walked.then(() => {
let total = entries.reduce((sum, entry) => sum + (entry.size || 0), 0);
entries.sort((a, b) => a.created - b.created);
for (const entry of entries) {
if (total <= this.AUDIO_CACHE_MAX_BYTES) break;
store.delete(entry.key);
total -= entry.size || 0;
}
});
tx.oncomplete = () => { db.close(); resolve(); };
tx.onerror = () => { db.close(); reject(tx.error); };
}));
}
/**
* Removes all synthesized audio from the cache.
* @returns {Promise<void>}
*/
clear_cached_audio() {
return this.open_audio_store().then(db => new Promise((resolve, reject) => {
const tx = db.transaction('audio', 'readwrite');
tx.objectStore('audio').clear();
tx.oncomplete = () => { db.close(); resolve(); };
tx.onerror = () => { db.close(); reject(tx.error); };
}));
}
//endregion
}
export default Storage;
storage.js
×
Type: Web, text/plain
36.73 Kilobytes
Last Modified 2026-11-04 09:48:28
⬇ Download File
Type: Web, text/plain
36.73 Kilobytes
Last Modified 2026-11-04 09:48:28
⬇ Download File