Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/merge-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,15 @@ jobs:
- name: Run CI test suite
run: npm run test:ci

- name: Upload Playwright report
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: |
playwright-report/
test-results/
retention-days: 5

- name: Build application
run: npm run build
3 changes: 2 additions & 1 deletion playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ export default defineConfig({
use: {
baseURL: "https://127.0.0.1:4173",
ignoreHTTPSErrors: true,
trace: "on-first-retry"
trace: "retain-on-failure"
},
reporter: process.env.CI ? [["list"], ["html", { open: "never" }]] : undefined,
projects: [
{
name: "chromium",
Expand Down
27 changes: 0 additions & 27 deletions src/components/static/CloudSync.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,6 @@ const syncDetails = document.querySelector<HTMLElement>("#sync-details");
const syncQuotaFill = document.querySelector<HTMLElement>("#sync-quota-fill");
const syncQuotaLabel = document.querySelector<HTMLElement>("#sync-quota-label");
const btnSyncNow = document.querySelector<HTMLButtonElement>("#btn-sync-now");
const btnSyncMediaMaintenance = document.querySelector<HTMLButtonElement>("#btn-sync-media-maintenance");
const btnSyncWipe = document.querySelector<HTMLButtonElement>("#btn-sync-wipe");
const syncUpgradeHint = document.querySelector<HTMLElement>("#sync-upgrade-hint");
const btnSyncForgotPassword = document.querySelector<HTMLButtonElement>("#btn-sync-forgot-password");
Expand Down Expand Up @@ -518,32 +517,6 @@ btnSyncNow?.addEventListener(
})()
);

btnSyncMediaMaintenance?.addEventListener(
"click",
() =>
void (async () => {
if (!syncService.isSyncActive()) {
danger({ title: "Sync not active", text: "Please unlock sync first." });
return;
}

btnSyncMediaMaintenance.disabled = true;
btnSyncMediaMaintenance.innerHTML =
'<span class="material-symbols-outlined">hourglass_top</span> Maintenance…';

try {
const ok = await runLegacyMediaMigrationFlowIfNeeded({ force: true });
if (ok) {
info({ title: "Maintenance complete", text: "Media payloads were checked and optimized." });
}
} finally {
btnSyncMediaMaintenance.disabled = false;
btnSyncMediaMaintenance.innerHTML =
'<span class="material-symbols-outlined">build</span> Media Maintenance';
}
})()
);

btnSyncWipe?.addEventListener(
"click",
() =>
Expand Down
7 changes: 0 additions & 7 deletions src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -960,9 +960,6 @@ <h3 class="settings-page-title">Data Management</h3>
</label>
<div class="cloud-sync-card settings-item">
<div class="cloud-sync-header">
<span class="material-symbols-outlined cloud-sync-icon"
>cloud_sync</span
>
<div class="cloud-sync-status">
<span id="sync-status-label">Disabled</span>
<span
Expand Down Expand Up @@ -997,10 +994,6 @@ <h3 class="settings-page-title">Data Management</h3>
Wipe Data
</button>
</div>
<button class="btn btn-secondary" id="btn-sync-media-maintenance">
<span class="material-symbols-outlined">build</span>
Media Maintenance
</button>
</div>
</div>
</div>
Expand Down
95 changes: 54 additions & 41 deletions src/services/Settings.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ if (

const UI_SCALE_VALUES = [0.5, 0.75, 1, 1.25, 1.5] as const;
const DEFAULT_UI_SCALE = 1;
let isLoadingSettings = false;

function getCurrentModelAccess() {
return {
Expand Down Expand Up @@ -348,51 +349,63 @@ export function initialize() {
}

export function loadSettings() {
geminiApiKeyInput.value = localStorage.getItem(SETTINGS_STORAGE_KEYS.API_KEY) || "";
openRouterApiKeyInput.value = localStorage.getItem(SETTINGS_STORAGE_KEYS.OPENROUTER_API_KEY) || "";
maxTokensInput.value = localStorage.getItem(SETTINGS_STORAGE_KEYS.MAX_TOKENS) || "1000";
temperatureInput.value = localStorage.getItem(SETTINGS_STORAGE_KEYS.TEMPERATURE) || "60";
modelSelect.value = getSelectedOrFallbackModel();
imageModelSelect.value = localStorage.getItem(SETTINGS_STORAGE_KEYS.IMAGE_MODEL) || "imagen-4.0-ultra-generate-001";
imageEditModelSelector.value = localStorage.getItem(SETTINGS_STORAGE_KEYS.IMAGE_EDIT_MODEL) || "qwen";
roleplaySuggestionModelSelect.value =
localStorage.getItem(SETTINGS_STORAGE_KEYS.ROLEPLAY_SUGGESTION_MODEL) || modelSelect.value;
autoscrollToggle.checked = localStorage.getItem(SETTINGS_STORAGE_KEYS.AUTOSCROLL)
? localStorage.getItem(SETTINGS_STORAGE_KEYS.AUTOSCROLL) === "true"
: true;
// Default ON when not set
streamResponsesToggle.checked = (localStorage.getItem(SETTINGS_STORAGE_KEYS.STREAM_RESPONSES) ?? "true") === "true";
rpgGroupChatsProgressAutomaticallyToggle.checked =
(localStorage.getItem(SETTINGS_STORAGE_KEYS.RPG_GROUP_CHATS_PROGRESS_AUTOMATICALLY) ?? "false") === "true";
allowPersonaPingingToggle.checked =
(localStorage.getItem(SETTINGS_STORAGE_KEYS.ALLOW_PERSONA_PINGING) ?? "true") === "true";
dynamicGroupChatPingOnlyToggle.checked =
(localStorage.getItem(SETTINGS_STORAGE_KEYS.DYNAMIC_GROUP_CHAT_PING_ONLY) ?? "false") === "true";
fullWidthChatToggle.checked = (localStorage.getItem(SETTINGS_STORAGE_KEYS.FULL_WIDTH_CHAT) ?? "false") === "true";
const enableThinkingStored = localStorage.getItem(SETTINGS_STORAGE_KEYS.ENABLE_THINKING);
const enableThinking = (enableThinkingStored ?? "true") === "true";
enableThinkingSelect.value = enableThinking ? "enabled" : "disabled";
const uiScale = getStoredUiScale();
uiScaleInput.value = getUiScaleInputValue(uiScale);
thinkingBudgetInput.value = localStorage.getItem(SETTINGS_STORAGE_KEYS.THINKING_BUDGET) || "500";
delimiterPresetSelect.value = getStoredDelimiterPreset();

const customDelimiterInstructions = getStoredCustomDelimiterInstructions();
customDialogueInstructionInput.value = customDelimiterInstructions.dialogue;
customActionInstructionInput.value = customDelimiterInstructions.action;
customThoughtInstructionInput.value = customDelimiterInstructions.thought;

updateDelimiterCustomizationVisibility();
updateDelimiterPreview();
applyUiScale(uiScale);
applyFullWidthChat(fullWidthChatToggle.checked);
isLoadingSettings = true;
try {
geminiApiKeyInput.value = localStorage.getItem(SETTINGS_STORAGE_KEYS.API_KEY) || "";
openRouterApiKeyInput.value = localStorage.getItem(SETTINGS_STORAGE_KEYS.OPENROUTER_API_KEY) || "";
maxTokensInput.value = localStorage.getItem(SETTINGS_STORAGE_KEYS.MAX_TOKENS) || "1000";
temperatureInput.value = localStorage.getItem(SETTINGS_STORAGE_KEYS.TEMPERATURE) || "60";
modelSelect.value = getSelectedOrFallbackModel();
imageModelSelect.value =
localStorage.getItem(SETTINGS_STORAGE_KEYS.IMAGE_MODEL) || "imagen-4.0-ultra-generate-001";
imageEditModelSelector.value = localStorage.getItem(SETTINGS_STORAGE_KEYS.IMAGE_EDIT_MODEL) || "qwen";
roleplaySuggestionModelSelect.value =
localStorage.getItem(SETTINGS_STORAGE_KEYS.ROLEPLAY_SUGGESTION_MODEL) || modelSelect.value;
autoscrollToggle.checked = localStorage.getItem(SETTINGS_STORAGE_KEYS.AUTOSCROLL)
? localStorage.getItem(SETTINGS_STORAGE_KEYS.AUTOSCROLL) === "true"
: true;
// Default ON when not set
streamResponsesToggle.checked =
(localStorage.getItem(SETTINGS_STORAGE_KEYS.STREAM_RESPONSES) ?? "true") === "true";
rpgGroupChatsProgressAutomaticallyToggle.checked =
(localStorage.getItem(SETTINGS_STORAGE_KEYS.RPG_GROUP_CHATS_PROGRESS_AUTOMATICALLY) ?? "false") === "true";
allowPersonaPingingToggle.checked =
(localStorage.getItem(SETTINGS_STORAGE_KEYS.ALLOW_PERSONA_PINGING) ?? "true") === "true";
dynamicGroupChatPingOnlyToggle.checked =
(localStorage.getItem(SETTINGS_STORAGE_KEYS.DYNAMIC_GROUP_CHAT_PING_ONLY) ?? "false") === "true";
fullWidthChatToggle.checked =
(localStorage.getItem(SETTINGS_STORAGE_KEYS.FULL_WIDTH_CHAT) ?? "false") === "true";
const enableThinkingStored = localStorage.getItem(SETTINGS_STORAGE_KEYS.ENABLE_THINKING);
const enableThinking = (enableThinkingStored ?? "true") === "true";
enableThinkingSelect.value = enableThinking ? "enabled" : "disabled";
const uiScale = getStoredUiScale();
uiScaleInput.value = getUiScaleInputValue(uiScale);
thinkingBudgetInput.value = localStorage.getItem(SETTINGS_STORAGE_KEYS.THINKING_BUDGET) || "500";
delimiterPresetSelect.value = getStoredDelimiterPreset();

const customDelimiterInstructions = getStoredCustomDelimiterInstructions();
customDialogueInstructionInput.value = customDelimiterInstructions.dialogue;
customActionInstructionInput.value = customDelimiterInstructions.action;
customThoughtInstructionInput.value = customDelimiterInstructions.thought;

// Trigger input events to update any UI components that depend on these values
temperatureInput.dispatchEvent(new Event("input", { bubbles: true }));
uiScaleInput.dispatchEvent(new Event("input", { bubbles: true }));
updateDelimiterCustomizationVisibility();
updateDelimiterPreview();
applyUiScale(uiScale);
applyFullWidthChat(fullWidthChatToggle.checked);

// Trigger input events to update dependent UI without treating the load as a user edit.
temperatureInput.dispatchEvent(new Event("input", { bubbles: true }));
uiScaleInput.dispatchEvent(new Event("input", { bubbles: true }));
} finally {
isLoadingSettings = false;
}
}

export function saveSettings() {
if (isLoadingSettings) {
return;
}

const prevGeminiKey = localStorage.getItem(SETTINGS_STORAGE_KEYS.API_KEY) || "";
const prevOpenRouterKey = localStorage.getItem(SETTINGS_STORAGE_KEYS.OPENROUTER_API_KEY) || "";

Expand Down
103 changes: 103 additions & 0 deletions src/services/Sync.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,16 @@
import { supabase, getCurrentUser, getSubscriptionTier, getUserSubscription } from "./Supabase.service";
import * as crypto from "./Crypto.service";
import * as blobStore from "./BlobStore.service";
import * as toastService from "./Toast.service";
import { db } from "./Db.service";
import { dispatchAppEvent } from "../events";
import type { SyncStatus } from "../events";
import type { ToastOptions } from "../types/Toast";
import type { DbChat } from "../types/Chat";
import type { Message, GeneratedImage } from "../types/Message";
import type { DbPersonality } from "../types/Personality";
import { isBlobReference, type BlobReference } from "../types/BlobReference";
import type { SubscriptionTier } from "../types/Supabase";
import { fileToBase64 } from "../utils/helpers";
import { resolveAttachmentFile, resolveGeneratedImageSrc, resolveThoughtSignature } from "../utils/blobResolver";
import { SETTINGS_STORAGE_KEYS, SYNCABLE_SETTINGS_KEYS } from "../constants/SettingsStorageKeys";
Expand Down Expand Up @@ -97,10 +100,12 @@ const MESSAGE_DELETE_MARK_BATCH_SIZE = 500;
const CHAT_DELETE_MARK_BATCH_SIZE = 500;
const MESSAGE_DECRYPT_CONCURRENCY = 4;
const MAX_OFFLINE_RETRIES = 5;
const QUOTA_TOAST_DEDUP_MS = 30_000;
/** Client-side page size for reading messages from Supabase. Independent of
* the server-side PostgREST max-rows setting (typically 1000 on hosted
* Supabase). Cursor-based paging handles lower server caps safely. */
const READ_PAGE_SIZE = 500;
let quotaToastShownAt = 0;

function areSyncHooksSuppressed(): boolean {
return suppressSyncHooksDepth > 0;
Expand Down Expand Up @@ -260,6 +265,96 @@ export async function fetchSyncQuota(): Promise<SyncQuota | null> {
return quota;
}

export function isSyncQuotaExceededError(error: unknown): boolean {
const parts: string[] = [];
if (error instanceof Error) {
parts.push(error.message, error.name);
}
if (typeof error === "string") {
parts.push(error);
}
if (error && typeof error === "object") {
const record = error as Record<string, unknown>;
for (const key of ["message", "details", "hint", "code", "name"]) {
const value = record[key];
if (typeof value === "string") {
parts.push(value);
}
}
}

const normalized = parts.join(" ").toLowerCase();
return normalized.includes("quota") && (normalized.includes("storage") || normalized.includes("sync"));
}

export function buildSyncQuotaExceededToastText(quota: SyncQuota | null): string {
const commitText = "Your latest change was saved on this device, but it was not committed to cloud sync.";
if (!quota || quota.quotaBytes <= 0) {
return `${commitText} Your cloud storage quota is full.`;
}

const percent = Math.min(999, Math.round((quota.usedBytes / quota.quotaBytes) * 100));
return `${commitText} Cloud storage is ${formatBytes(quota.usedBytes)} of ${formatBytes(quota.quotaBytes)} used (${percent}% filled).`;
}

export function buildSyncQuotaExceededToastOptions(
quota: SyncQuota | null,
tier: SubscriptionTier
): Omit<ToastOptions, "severity"> {
const actions =
tier === "max"
? []
: [
{
label: "See upgrade options",
onClick: () => {
document.querySelector<HTMLButtonElement>("#btn-show-subscription-options")?.click();
}
}
];

return {
title: "Cloud sync storage is full",
text: buildSyncQuotaExceededToastText(quota),
actions
};
}

async function showSyncQuotaExceededToast(): Promise<void> {
const now = Date.now();
if (now - quotaToastShownAt < QUOTA_TOAST_DEDUP_MS) {
return;
}
quotaToastShownAt = now;

const [quota, subscription] = await Promise.all([fetchSyncQuota(), getUserSubscription()]);
const tier = getSubscriptionTier(subscription);
toastService.warn(buildSyncQuotaExceededToastOptions(quota, tier));
}

async function notifyIfSyncQuotaExceeded(error: unknown): Promise<void> {
if (!isSyncQuotaExceededError(error)) {
return;
}
try {
await showSyncQuotaExceededToast();
} catch (toastError) {
console.error("Failed to show sync quota toast", toastError);
}
}

function formatBytes(bytes: number): string {
const units = ["B", "KB", "MB", "GB", "TB"];
let value = Math.max(0, bytes);
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex++;
}
const precision = value >= 10 || unitIndex === 0 ? 0 : 1;
return `${value.toFixed(precision)} ${units[unitIndex]}`;
}

// ── First-time setup ───────────────────────────────────────────────────────

/**
Expand Down Expand Up @@ -1226,13 +1321,15 @@ export async function pushChat(chat: DbChat, messageCountOverride?: number): Pro

if (error) {
console.error("pushChat failed (chat id=%s):", chat.id, JSON.stringify(error));
await notifyIfSyncQuotaExceeded(error);
enqueue({ table: "chats", operation: "upsert", entityId: chat.id });
return false;
}

return true;
} catch (err) {
console.error("pushChat error (chat id=%s):", chat.id, err);
await notifyIfSyncQuotaExceeded(err);
enqueue({ table: "chats", operation: "upsert", entityId: chat.id });
return false;
}
Expand Down Expand Up @@ -1273,6 +1370,7 @@ async function pushChatMessagesRange(chat: DbChat, startInclusive: number, endEx
rangeEnd,
JSON.stringify(error)
);
await notifyIfSyncQuotaExceeded(error);
failures += rows.length;
enqueue({ table: "chats", operation: "upsert", entityId: chat.id });
return false;
Expand Down Expand Up @@ -1324,6 +1422,7 @@ async function pushChatMessagesRange(chat: DbChat, startInclusive: number, endEx
currentBatchBytes += (rows.length > 1 ? 1 : 0) + rowBytes;
} catch (err) {
console.error("pushChatMessagesRange error (chat=%s idx=%s):", chat.id, messageIndex, err);
await notifyIfSyncQuotaExceeded(err);
// Clean up only blobs uploaded while serializing this message.
if (uploadedBlobIdsForMessage.length > 0) {
blobStore.deleteBlobsBatch(uploadedBlobIdsForMessage).catch(() => {});
Expand Down Expand Up @@ -1711,13 +1810,15 @@ export async function pushPersona(persona: DbPersonality): Promise<boolean> {

if (error) {
console.error("pushPersona failed:", error);
await notifyIfSyncQuotaExceeded(error);
enqueue({ table: "personas", operation: "upsert", entityId: persona.id });
return false;
}

return true;
} catch (err) {
console.error("pushPersona error:", err);
await notifyIfSyncQuotaExceeded(err);
enqueue({ table: "personas", operation: "upsert", entityId: persona.id });
return false;
}
Expand Down Expand Up @@ -1772,13 +1873,15 @@ export async function pushSettings(settings: Record<string, string>): Promise<bo

if (error) {
console.error("pushSettings failed:", error);
await notifyIfSyncQuotaExceeded(error);
enqueue({ table: "settings", operation: "upsert" });
return false;
}

return true;
} catch (err) {
console.error("pushSettings error:", err);
await notifyIfSyncQuotaExceeded(err);
enqueue({ table: "settings", operation: "upsert" });
return false;
}
Expand Down
Loading