AXERA-TECH/lite_webui
17
1// LocalStorage keys2const KEYS = {3 SETTINGS: 'lw_settings',4 CONVERSATIONS: 'lw_conversations',5 CURRENT_CONV: 'lw_current_conv',6 MODEL_CAPS: 'lw_model_caps',7 AVAILABLE_MODELS: 'lw_available_models',8 MODEL_SELECTIONS: 'lw_model_selections',9};10 11const DEFAULT_SETTINGS = {12 apiKey: '',13 baseUrl: 'http://127.0.0.1:8000',14 theme: 'dark',15 contextLimitTokens: 4096,16 contextResetThresholdPercent: 85,17};18 19export function normalizeBaseUrl(baseUrl) {20 const raw = String(baseUrl || '').trim();21 if (!raw) return DEFAULT_SETTINGS.baseUrl;22 return raw.replace(/\/+$/, '');23}24 25function isCapabilityRecord(value) {26 if (!value || typeof value !== 'object' || Array.isArray(value)) return false;27 return ['text', 'image', 'audio'].some((key) => key in value);28}29 30function isLegacyCapabilityMap(value) {31 if (!value || typeof value !== 'object' || Array.isArray(value)) return false;32 const entries = Object.values(value);33 return entries.length > 0 && entries.every(isCapabilityRecord);34}35 36function normalizeSettings(settings = {}) {37 const merged = { ...DEFAULT_SETTINGS, ...settings };38 const contextLimitTokens = Number(merged.contextLimitTokens);39 const contextResetThresholdPercent = Number(merged.contextResetThresholdPercent);40 41 merged.baseUrl = normalizeBaseUrl(merged.baseUrl);42 merged.contextLimitTokens = Number.isFinite(contextLimitTokens) && contextLimitTokens >= 102443 ? Math.round(contextLimitTokens)44 : DEFAULT_SETTINGS.contextLimitTokens;45 46 merged.contextResetThresholdPercent = Number.isFinite(contextResetThresholdPercent)47 ? Math.min(95, Math.max(50, Math.round(contextResetThresholdPercent)))48 : DEFAULT_SETTINGS.contextResetThresholdPercent;49 50 return merged;51}52 53function load(key, fallback) {54 try {55 const raw = localStorage.getItem(key);56 return raw ? JSON.parse(raw) : fallback;57 } catch {58 return fallback;59 }60}61 62function save(key, value) {63 try {64 localStorage.setItem(key, JSON.stringify(value));65 } catch (e) {66 if (e?.name === 'QuotaExceededError' || e?.code === 22) {67 console.warn('[store] localStorage quota exceeded — conversation not persisted');68 } else {69 throw e;70 }71 }72}73 74function uuid() {75 return crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2) + Date.now().toString(36);76}77 78export const store = {79 getSettings() {80 return normalizeSettings(load(KEYS.SETTINGS, {}));81 },82 83 saveSettings(settings) {84 save(KEYS.SETTINGS, normalizeSettings(settings));85 },86 87 getConversations() {88 return load(KEYS.CONVERSATIONS, []);89 },90 91 saveConversations(conversations) {92 save(KEYS.CONVERSATIONS, conversations);93 },94 95 getCurrentConversationId() {96 return localStorage.getItem(KEYS.CURRENT_CONV) || null;97 },98 99 setCurrentConversationId(id) {100 if (id) {101 localStorage.setItem(KEYS.CURRENT_CONV, id);102 } else {103 localStorage.removeItem(KEYS.CURRENT_CONV);104 }105 },106 107 getCurrentConversation() {108 const id = this.getCurrentConversationId();109 if (!id) return null;110 const convs = this.getConversations();111 return convs.find(c => c.id === id) || null;112 },113 114 getAvailableModels(baseUrl = this.getSettings().baseUrl) {115 const catalogs = load(KEYS.AVAILABLE_MODELS, {});116 const list = catalogs[normalizeBaseUrl(baseUrl)];117 return Array.isArray(list) ? [...new Set(list.filter(Boolean))].sort() : [];118 },119 120 saveAvailableModels(baseUrl, models) {121 const catalogs = load(KEYS.AVAILABLE_MODELS, {});122 catalogs[normalizeBaseUrl(baseUrl)] = Array.isArray(models)123 ? [...new Set(models.filter(Boolean))].sort()124 : [];125 save(KEYS.AVAILABLE_MODELS, catalogs);126 },127 128 getCurrentModel(baseUrl = this.getSettings().baseUrl) {129 const selections = load(KEYS.MODEL_SELECTIONS, {});130 const selected = selections[normalizeBaseUrl(baseUrl)];131 return typeof selected === 'string' ? selected : '';132 },133 134 setCurrentModel(baseUrl, modelId) {135 const selections = load(KEYS.MODEL_SELECTIONS, {});136 const normalizedUrl = normalizeBaseUrl(baseUrl);137 if (modelId) {138 selections[normalizedUrl] = modelId;139 } else {140 delete selections[normalizedUrl];141 }142 save(KEYS.MODEL_SELECTIONS, selections);143 },144 145 getModelCapabilities(baseUrl = this.getSettings().baseUrl) {146 const raw = load(KEYS.MODEL_CAPS, {});147 if (isLegacyCapabilityMap(raw)) return raw;148 149 const caps = raw[normalizeBaseUrl(baseUrl)];150 return caps && typeof caps === 'object' && !Array.isArray(caps) ? caps : {};151 },152 153 saveModelCapabilities(baseUrlOrCaps, maybeCaps) {154 if (maybeCaps === undefined) {155 save(KEYS.MODEL_CAPS, baseUrlOrCaps);156 return;157 }158 159 const raw = load(KEYS.MODEL_CAPS, {});160 const nested = isLegacyCapabilityMap(raw) ? {} : raw;161 nested[normalizeBaseUrl(baseUrlOrCaps)] = maybeCaps;162 save(KEYS.MODEL_CAPS, nested);163 },164 165 createConversation(model) {166 const conv = {167 id: uuid(),168 title: 'New Chat',169 model: model || '',170 messages: [],171 createdAt: new Date().toISOString(),172 updatedAt: new Date().toISOString(),173 };174 const convs = this.getConversations();175 convs.unshift(conv);176 this.saveConversations(convs);177 return conv;178 },179 180 addMessage(convId, message) {181 const convs = this.getConversations();182 const idx = convs.findIndex(c => c.id === convId);183 if (idx === -1) return;184 convs[idx].messages.push(message);185 convs[idx].updatedAt = new Date().toISOString();186 this.saveConversations(convs);187 },188 189 updateLastAssistantMessage(convId, content) {190 const convs = this.getConversations();191 const idx = convs.findIndex(c => c.id === convId);192 if (idx === -1) return;193 const msgs = convs[idx].messages;194 // Find last assistant message195 for (let i = msgs.length - 1; i >= 0; i--) {196 if (msgs[i].role === 'assistant') {197 msgs[i].content = content;198 msgs[i].timestamp = new Date().toISOString();199 break;200 }201 }202 convs[idx].updatedAt = new Date().toISOString();203 this.saveConversations(convs);204 },205 206 clearMessages(convId) {207 const convs = this.getConversations();208 const idx = convs.findIndex(c => c.id === convId);209 if (idx === -1) return;210 convs[idx].messages = [];211 convs[idx].updatedAt = new Date().toISOString();212 this.saveConversations(convs);213 },214 215 deleteConversation(convId) {216 let convs = this.getConversations();217 convs = convs.filter(c => c.id !== convId);218 this.saveConversations(convs);219 if (this.getCurrentConversationId() === convId) {220 this.setCurrentConversationId(convs[0]?.id || null);221 }222 },223 224 updateConversationTitle(convId, title) {225 const convs = this.getConversations();226 const idx = convs.findIndex(c => c.id === convId);227 if (idx === -1) return;228 convs[idx].title = title;229 this.saveConversations(convs);230 },231 232 updateConversationModel(convId, model) {233 const convs = this.getConversations();234 const idx = convs.findIndex(c => c.id === convId);235 if (idx === -1) return;236 convs[idx].model = model;237 this.saveConversations(convs);238 },239};240 