Skip to content

Persist slide images outside localStorage#3

Merged
LRriver merged 5 commits into
mainfrom
dev
Jun 2, 2026
Merged

Persist slide images outside localStorage#3
LRriver merged 5 commits into
mainfrom
dev

Conversation

@LRriver

@LRriver LRriver commented Jun 2, 2026

Copy link
Copy Markdown
Owner

Summary

  • Store oversized slide images in IndexedDB when localStorage cannot hold the full project payload.
  • Restore slide images back into recovered projects so saved decks do not show permanent loading placeholders.
  • Show a clear missing-image state for legacy sessions whose images were already stripped.

Test Plan

  • npm run lint
  • npm run test -- --run
  • npm run build

Copilot AI review requested due to automatic review settings June 2, 2026 13:25
@LRriver LRriver merged commit 35be20e into main Jun 2, 2026
1 check passed

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces support for an OpenAI-compatible thinking mode, adds a fallback literal PDF text extractor, implements an IndexedDB-based storage fallback for large slide images, and updates the web UI with a new warm theme and an image lightbox component. Key feedback includes: only passing the thinking parameter in extra_body when explicitly enabled to prevent breaking standard OpenAI endpoints; avoiding bypassing onSelect in SlideCard to keep slide navigation functional; optimizing IndexedDB operations in StorageService by reusing a single connection and clearing the store instead of deleting the database; and removing the fragile fallback PDF extractor in favor of returning None when pypdf is unavailable.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread src/client.py
Comment on lines +103 to +104
if self.config.text_thinking in {"enabled", "disabled"}:
kwargs["extra_body"] = {"thinking": {"type": self.config.text_thinking}}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Passing extra_body with "thinking": {"type": "disabled"} by default when thinking is disabled will break compatibility with standard OpenAI-compatible endpoints that do not support the thinking parameter (resulting in a 400 Bad Request). We should only pass the thinking parameter in extra_body when it is explicitly set to "enabled".

Suggested change
if self.config.text_thinking in {"enabled", "disabled"}:
kwargs["extra_body"] = {"thinking": {"type": self.config.text_thinking}}
if self.config.text_thinking == "enabled":
kwargs["extra_body"] = {"thinking": {"type": "enabled"}}

Comment thread src/model_router.py
Comment on lines +37 to +38
if profile.thinking in {"enabled", "disabled"}:
kwargs["extra_body"] = {"thinking": {"type": profile.thinking}}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Similar to the client configuration, passing extra_body with "thinking": {"type": "disabled"} by default will break compatibility with standard OpenAI-compatible endpoints. We should only pass it when thinking is explicitly set to "enabled".

Suggested change
if profile.thinking in {"enabled", "disabled"}:
kwargs["extra_body"] = {"thinking": {"type": profile.thinking}}
if profile.thinking == "enabled":
kwargs["extra_body"] = {"thinking": {"type": "enabled"}}

Comment on lines 26 to 32
const handleClick = () => {
onSelect(slide.id)
if (canEdit && onEdit) {
onEdit(slide.id)
} else {
onSelect(slide.id)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Bypassing onSelect when onEdit is provided breaks slide selection/navigation in the right panel gallery. It also makes the hover edit button redundant since clicking anywhere on the card would trigger the edit panel. Reverting this to only trigger onSelect ensures that selection works correctly and editing is initiated via the dedicated edit button.

  const handleClick = () => {
    onSelect(slide.id)
  }

Comment on lines +304 to +336
static async loadProjectWithImages(): Promise<PersistedState['currentProject']> {
const project = StorageService.loadProject()
if (!project) {
return null
}

if (StorageService.pendingImageSave) {
await StorageService.pendingImageSave
}

const slides = await Promise.all(project.slides.map(async (slide) => {
if (slide.imageBase64 || slide.imageUrl) {
return slide
}
if (!slide.imageStorageKey) {
return slide
}
const imageBase64 = await StorageService.loadSlideImage(slide.imageStorageKey)
if (!imageBase64) {
return slide
}
return {
...slide,
imageBase64,
imageUrl: `data:image/png;base64,${imageBase64}`
}
}))

return {
...project,
slides
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Calling StorageService.loadSlideImage inside Promise.all opens and closes a separate IndexedDB connection for every single slide image concurrently. This is highly inefficient and can cause performance bottlenecks or connection exhaustion. We should open the database connection once in loadProjectWithImages, perform all reads, and then close it.

  static async loadProjectWithImages(): Promise<PersistedState['currentProject']> {
    const project = StorageService.loadProject()
    if (!project) {
      return null
    }

    if (StorageService.pendingImageSave) {
      await StorageService.pendingImageSave
    }

    let db: IDBDatabase | null = null
    try {
      if (StorageService.canUseIndexedDb()) {
        db = await StorageService.openImageDb()
      }
    } catch (e) {
      console.error('Failed to open image DB:', e)
    }

    const slides = await Promise.all(project.slides.map(async (slide) => {
      if (slide.imageBase64 || slide.imageUrl) {
        return slide
      }
      if (!slide.imageStorageKey || !db) {
        return slide
      }
      const imageBase64 = await new Promise<string | null>((resolve) => {
        try {
          const transaction = db!.transaction(IMAGE_DB_STORE, 'readonly')
          const store = transaction.objectStore(IMAGE_DB_STORE)
          const request = store.get(slide.imageStorageKey!)
          request.onsuccess = () => resolve((request.result as SlideImageRecord | undefined)?.imageBase64 || null)
          request.onerror = () => resolve(null)
        } catch {
          resolve(null)
        }
      })
      if (!imageBase64) {
        return slide
      }
      return {
        ...slide,
        imageBase64,
        imageUrl: `data:image/png;base64,${imageBase64}`
      }
    }))

    if (db) {
      db.close()
    }

    return {
      ...project,
      slides
    }
  }

Comment on lines +182 to +190
private static clearImageStore(): void {
if (!StorageService.canUseIndexedDb()) {
return
}
const request = indexedDB.deleteDatabase(IMAGE_DB_NAME)
request.onerror = () => {
console.error('Failed to clear slide image store:', request.error)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Deleting the entire database with indexedDB.deleteDatabase can be blocked if there are other open connections (e.g., in other tabs or pending operations in the current tab). It is much safer and more robust to open the database and clear the object store using store.clear().

  private static clearImageStore(): void {
    if (!StorageService.canUseIndexedDb()) {
      return
    }
    StorageService.openImageDb().then((db) => {
      const transaction = db.transaction(IMAGE_DB_STORE, 'readwrite')
      const store = transaction.objectStore(IMAGE_DB_STORE)
      store.clear()
      transaction.oncomplete = () => db.close()
      transaction.onerror = () => db.close()
    }).catch((error) => {
      console.error('Failed to clear slide image store:', error)
    })
  }

Comment thread src/document_parser.py
Comment on lines 52 to +55
try:
from pypdf import PdfReader
except ImportError:
return None
return self._parse_literal_pdf_text(path)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The fallback literal PDF text extractor is extremely fragile. It does not handle FlateDecode stream compression (which is standard for almost all modern PDFs), hexadecimal strings, TJ operators, or escaped parentheses correctly. If pypdf is not available, it is much safer to return None rather than attempting a naive regex-based binary parse that will almost always fail or return garbage.

Suggested change
try:
from pypdf import PdfReader
except ImportError:
return None
return self._parse_literal_pdf_text(path)
try:
from pypdf import PdfReader
except ImportError:
return None

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR improves persistence and restoration of generated slide images by offloading oversized base64 payloads to IndexedDB when localStorage quota is exceeded, then rehydrating images during session restore so decks don’t remain stuck on placeholders. It also updates the UI/theme styling and introduces an OpenAI-compatible “thinking” mode flag that flows through both frontend model-profile requests and backend routing.

Changes:

  • Add IndexedDB-backed storage for slide image base64 data during quota fallback, plus restoration via loadProjectWithImages().
  • Introduce/propagate thinking: enabled|disabled across web config, model profiles, and backend OpenAI calls.
  • Refresh UI styling and add an image lightbox for viewing/downloading slide images.

Reviewed changes

Copilot reviewed 36 out of 39 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
web/src/utils/apiConfig.ts Updates default/full API config shape and migrates legacy thinkingLevel into new thinking mode.
web/src/types/index.ts Adds imageStorageKey on slides and replaces thinkingLevel with thinking in relevant config types.
web/src/services/storageService.ts Implements IndexedDB slide-image storage + project load that rehydrates images from IndexedDB.
web/src/services/modelProfileService.ts Sends thinking in prompt model profile payload to backend.
web/src/services/generateService.ts Sends thinking in generation config payload; formatting/typing cleanups.
web/src/index.css Major theme/style variable updates and new utility classes for cards/panels.
web/src/i18n.ts Adds/updates strings for theme mode, thinking mode, missing images, lightbox actions, and UI labels.
web/src/hooks/useStateRestore.ts Restores projects via loadProjectWithImages() and adds cancellation guard.
web/src/components/SlideList.tsx Styling tweaks for slide list layout.
web/src/components/SlideCard.tsx Adds missing-image state, preview lightbox entry point, and click behavior changes.
web/src/components/RightPanel.tsx Shifts to gallery-first presentation and updates empty/loading states.
web/src/components/LeftPanel.tsx Styling/layout adjustments.
web/src/components/Layout.tsx Replaces theme dropdown with a theme toggle switch and updates layout spacing.
web/src/components/ImageLightbox.tsx New component to preview and download slide images.
web/src/components/GenerationConfigForm.tsx Styling updates to match new theme primitives.
web/src/components/GenerateButton.tsx Styling updates for disabled/active states.
web/src/components/FileUpload.tsx Styling updates for dropzone and file info display.
web/src/components/FilePreview.tsx Styling updates for markdown-ish preview rendering.
web/src/components/ExportButton.tsx Styling updates for button + dropdown menu.
web/src/components/EditPanel.tsx Adds lightbox preview for original/edited images; updates layout and copy.
web/src/components/EditHistory.tsx Styling updates and improved layout for history items.
web/src/components/CenterPanel.tsx Styling/layout updates.
web/src/components/ApiConfigForm.tsx Updates UI to configure thinking mode and adjusts styling throughout.
web/src/components/tests/StatePersistence.property.test.tsx Extends persistence property test to cover IndexedDB image restoration.
web/package.json Adds fake-indexeddb dev dependency for tests.
web/package-lock.json Locks fake-indexeddb dependency resolution.
tests/test_model_router.py Adds backend unit test asserting thinking is passed via extra_body.
tests/test_config_writer.py Updates config writer tests to persist thinking for prompt model.
src/model_router.py Passes thinking into OpenAI calls via extra_body when configured.
src/model_profiles.py Adds thinking field to model profiles with validation/defaulting.
src/document_parser.py Adds regex-based literal PDF text extraction as a fallback when pypdf fails/unavailable.
src/config.py Adds text_thinking alongside legacy text_thinking_level config.
src/config_writer.py Persists thinking into config.yaml for prompt model profiles.
src/client.py Passes text_thinking into OpenAI calls via extra_body.
config.example.yaml Documents new thinking setting in example config.
api/profile_resolver.py Propagates thinking from request config into model profiles.
api/models.py Adds thinking to request schema and a get_thinking() helper.
Files not reviewed (1)
  • web/package-lock.json: Language not supported
Comments suppressed due to low confidence (1)

web/src/services/storageService.ts:260

  • When the initial writeState(newState, false) fails (quota), the code kicks off saveSlideImages() but does not wait for it before persisting the compact state with imageStorageKeys. If the user reloads/closes the tab immediately after saveProject() returns, localStorage may reference keys that were never committed to IndexedDB yet, resulting in missing images despite a “successful” save. Consider making the compaction path await the IndexedDB transaction (or otherwise guaranteeing durability before returning success).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 26 to +31
const handleClick = () => {
onSelect(slide.id)
if (canEdit && onEdit) {
onEdit(slide.id)
} else {
onSelect(slide.id)
}
Comment on lines +96 to +99
private static imageKey(fileName: string, slide: Slide): string {
const projectName = fileName || 'untitled'
return `${projectName}:${slide.id}`
}
Comment thread src/document_parser.py
Comment on lines +77 to +85
def _parse_literal_pdf_text(self, path: Path) -> Optional[ParsedDocument]:
fallback_text = self._extract_literal_pdf_text(path)
if not fallback_text:
return None
return ParsedDocument(
filename=path.name,
normalized_markdown=f"<!-- page: 1 -->\n{fallback_text}",
metadata={"parser": "pypdf", "extension": ".pdf", "pages": 1},
)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants