Initial release
31
.github/CLAUDE.md
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
# GitHub CI/CD
|
||||||
|
|
||||||
|
## Workflow: Android CI
|
||||||
|
|
||||||
|
File: `.github/workflows/android.yml`
|
||||||
|
|
||||||
|
### Triggers
|
||||||
|
|
||||||
|
- Push to `main` or `develop`
|
||||||
|
- Pull requests targeting `main`
|
||||||
|
|
||||||
|
### Pipeline Steps
|
||||||
|
|
||||||
|
1. Checkout code
|
||||||
|
2. Set up JDK 17 (Temurin)
|
||||||
|
3. Setup Gradle with caching (`~/.gradle/caches` and `~/.gradle/wrapper`)
|
||||||
|
4. `./gradlew lint` -- Android lint checks
|
||||||
|
5. `./gradlew test` -- unit tests across all modules
|
||||||
|
6. `./gradlew assembleDebug` -- build debug APK
|
||||||
|
7. Upload `app/build/outputs/apk/debug/app-debug.apk` as artifact (named `debug-apk`)
|
||||||
|
|
||||||
|
### Environment
|
||||||
|
|
||||||
|
- Runs on `ubuntu-latest`
|
||||||
|
- Gradle cache key based on `**/*.gradle*` and `gradle-wrapper.properties`
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- No release signing configured yet (debug builds only)
|
||||||
|
- No instrumented/UI tests in CI (only unit tests)
|
||||||
|
- APK artifact is available for download from the Actions run summary
|
||||||
50
.github/workflows/android.yml
vendored
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
name: Android CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main, develop ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ main ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up JDK 17
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
java-version: '17'
|
||||||
|
distribution: 'temurin'
|
||||||
|
|
||||||
|
- name: Setup Gradle
|
||||||
|
uses: gradle/actions/setup-gradle@v4
|
||||||
|
|
||||||
|
- name: Cache Gradle packages
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.gradle/caches
|
||||||
|
~/.gradle/wrapper
|
||||||
|
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||||
|
restore-keys: ${{ runner.os }}-gradle-
|
||||||
|
|
||||||
|
- name: Grant execute permission for gradlew
|
||||||
|
run: chmod +x gradlew
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: ./gradlew lint
|
||||||
|
|
||||||
|
- name: Unit Tests
|
||||||
|
run: ./gradlew test
|
||||||
|
|
||||||
|
- name: Build Debug APK
|
||||||
|
run: ./gradlew assembleDebug
|
||||||
|
|
||||||
|
- name: Upload APK
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: debug-apk
|
||||||
|
path: app/build/outputs/apk/debug/app-debug.apk
|
||||||
26
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
# Gradle
|
||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
!gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
local.properties
|
||||||
|
|
||||||
|
# Kotlin
|
||||||
|
.kotlin/
|
||||||
|
|
||||||
|
# Android
|
||||||
|
*.apk
|
||||||
|
*.aab
|
||||||
|
*.dex
|
||||||
|
*.class
|
||||||
|
|
||||||
|
# Keystore
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
50
CLAUDE.md
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
# LibreChat Android
|
||||||
|
|
||||||
|
Native Android client for LibreChat. Connects to existing LibreChat backend servers (no backend changes). Users specify server URL during onboarding.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **UI**: Jetpack Compose + Compose Navigation
|
||||||
|
- **DI**: Hilt
|
||||||
|
- **Network**: Ktor Client (OkHttp engine)
|
||||||
|
- **Serialization**: Kotlinx Serialization
|
||||||
|
- **Local Storage**: Room (cache), DataStore (prefs), EncryptedSharedPreferences (tokens)
|
||||||
|
- **Build**: Gradle 8.11.1, AGP 8.7.3, Kotlin 2.1.0, compileSdk 35, minSdk 26
|
||||||
|
|
||||||
|
## Module Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
app/ → Single Activity, adaptive navigation (phone/tablet)
|
||||||
|
build-logic/ → 7 convention plugins for consistent Gradle config
|
||||||
|
core/common/ → Result type, dispatcher DI, coroutine scopes, extensions
|
||||||
|
core/model/ → @Serializable data classes (pure Kotlin, no Android deps)
|
||||||
|
core/network/ → Ktor client, 16 API services, SSE client, auth interceptor
|
||||||
|
core/data/ → Room DB, DataStore, EncryptedSharedPrefs, repository impls
|
||||||
|
core/ui/ → Material 3 theme, shared composables
|
||||||
|
feature/auth/ → Server URL, Login, Register, 2FA, OAuth, Forgot Password
|
||||||
|
feature/chat/ → Real-time chat with SSE streaming, message rendering
|
||||||
|
feature/conversations/→ Paginated list, CRUD, tags, search, export/import
|
||||||
|
feature/settings/ → Account, appearance, about, danger zone
|
||||||
|
feature/agents/ → Agent marketplace with search and categories
|
||||||
|
feature/files/ → File upload, management, image viewer
|
||||||
|
```
|
||||||
|
|
||||||
|
Each module has its own `CLAUDE.md` with specific guidance.
|
||||||
|
|
||||||
|
## Architecture Rules
|
||||||
|
|
||||||
|
- Feature modules depend on `:core:*` only, never on each other
|
||||||
|
- Single Activity with Compose Navigation
|
||||||
|
- Unidirectional data flow: UI → ViewModel → Repository → API/Room
|
||||||
|
- Room is a read-through cache; server is source of truth
|
||||||
|
- Custom SSE parser over raw ByteReadChannel (not Ktor SSE plugin)
|
||||||
|
|
||||||
|
## Backend Quirks
|
||||||
|
|
||||||
|
- Mutation endpoints wrap body in `arg` field: `{ "arg": { ... } }`
|
||||||
|
- Two-phase SSE: POST → `{ streamId }`, then GET stream. `streamId === conversationId`
|
||||||
|
- `GET /api/config` drives feature availability — never hardcode
|
||||||
|
- ua-parser-js middleware rejects non-browser User-Agents with 403 (workaround: Chrome UA string)
|
||||||
|
- Refresh token sent via request body (not HTTP-only cookies)
|
||||||
|
|
||||||
|
@~/.claude/librechat-android-local.md
|
||||||
292
DISCOVERY.md
Normal file
|
|
@ -0,0 +1,292 @@
|
||||||
|
# LibreChat Android - Discovery & Context Document
|
||||||
|
|
||||||
|
## Project Goal
|
||||||
|
Build a native Android app (LibreChat-Android) with full feature parity to the LibreChat web application. The app must connect to an existing LibreChat backend server (no backend changes). Users specify the server URL during onboarding.
|
||||||
|
|
||||||
|
## Tech Stack (Mandated)
|
||||||
|
- **UI**: Jetpack Compose + Compose Navigation
|
||||||
|
- **DI**: Hilt
|
||||||
|
- **Network**: Ktor Client
|
||||||
|
- **Serialization**: Kotlinx Serialization
|
||||||
|
- **Min SDK**: TBD (recommend 26+)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## LibreChat Web Application Overview
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
- Monorepo: `/api` (Express/Node backend), `/client` (React/Vite frontend), `/packages` (shared libs)
|
||||||
|
- MongoDB database, optional Redis, optional Meilisearch
|
||||||
|
- JWT-based auth with refresh tokens
|
||||||
|
|
||||||
|
### Core Features (Full Parity Required)
|
||||||
|
|
||||||
|
#### 1. Authentication & Onboarding
|
||||||
|
- **Server URL entry** (Android-only: user specifies their LibreChat server)
|
||||||
|
- Local login (email/password)
|
||||||
|
- OAuth2 social logins (Google, GitHub, Discord, Facebook, Apple, OpenID, SAML)
|
||||||
|
- Registration with email verification
|
||||||
|
- Password reset flow
|
||||||
|
- Two-factor authentication (TOTP + backup codes)
|
||||||
|
- Terms of service acceptance
|
||||||
|
|
||||||
|
#### 2. Chat (Primary Experience)
|
||||||
|
- Conversation list in sidebar (cursor-paginated, sorted by updatedAt)
|
||||||
|
- Create new conversations
|
||||||
|
- Send messages, receive streaming AI responses via SSE
|
||||||
|
- Message tree with parent/child relationships and branching
|
||||||
|
- Sibling message navigation (e.g., "1/3" switcher for regenerated responses)
|
||||||
|
- Edit user messages (creates new branch)
|
||||||
|
- Regenerate AI responses
|
||||||
|
- Continue incomplete responses
|
||||||
|
- Stop generation mid-stream
|
||||||
|
- Fork conversations from any message
|
||||||
|
- Duplicate conversations
|
||||||
|
- Markdown rendering with syntax highlighting
|
||||||
|
- LaTeX/math rendering
|
||||||
|
- Code blocks with copy button and language badge
|
||||||
|
- Image display (inline + expandable)
|
||||||
|
- File attachments in messages
|
||||||
|
- Tool call display (expandable cards showing input/output)
|
||||||
|
- Message feedback (thumbs up/down with optional comment)
|
||||||
|
- Typing/streaming indicator
|
||||||
|
- Time-based greeting on landing ("Good morning", etc.)
|
||||||
|
- Custom welcome messages from server config
|
||||||
|
|
||||||
|
#### 3. Model/Endpoint Selection
|
||||||
|
- Multiple AI providers: OpenAI, Anthropic, Azure, Google, Groq, Mistral, Ollama, custom
|
||||||
|
- Model selector dropdown with search
|
||||||
|
- Endpoint icons (branded)
|
||||||
|
- Model parameters (temperature, top_p, frequency_penalty, etc.)
|
||||||
|
- Model specs from server config
|
||||||
|
|
||||||
|
#### 4. Agents & Assistants
|
||||||
|
- Agent marketplace (grid of cards with avatar, name, description, category)
|
||||||
|
- Agent chat with tool calling
|
||||||
|
- OpenAI Assistants integration
|
||||||
|
- Agent/assistant selection UI
|
||||||
|
|
||||||
|
#### 5. File Management
|
||||||
|
- Upload files (images, PDFs, documents, code)
|
||||||
|
- Drag-and-drop (not applicable on Android, use file picker)
|
||||||
|
- File preview in messages
|
||||||
|
- File download
|
||||||
|
- Image generation display
|
||||||
|
- Audio recording for voice input (STT)
|
||||||
|
- Text-to-speech playback (TTS)
|
||||||
|
|
||||||
|
#### 6. Conversation Management
|
||||||
|
- Rename conversations (inline)
|
||||||
|
- Archive/unarchive
|
||||||
|
- Delete (single + bulk)
|
||||||
|
- Share (generate public link)
|
||||||
|
- Export (JSON, markdown)
|
||||||
|
- Import conversations
|
||||||
|
- Tags for organization
|
||||||
|
- Bookmarks/favorites
|
||||||
|
- Search conversations (full-text via Meilisearch)
|
||||||
|
|
||||||
|
#### 7. Presets
|
||||||
|
- Save chat configurations as presets
|
||||||
|
- Load presets
|
||||||
|
- Delete presets
|
||||||
|
|
||||||
|
#### 8. Prompts Library
|
||||||
|
- Create/edit/delete prompts
|
||||||
|
- Share prompts
|
||||||
|
- Prompt versioning
|
||||||
|
- @mentions for prompts in chat input
|
||||||
|
- Prompt variables
|
||||||
|
|
||||||
|
#### 9. User Settings
|
||||||
|
- Theme (dark/light)
|
||||||
|
- Language selection (46+ languages)
|
||||||
|
- Font size
|
||||||
|
- Speech settings
|
||||||
|
- Data export
|
||||||
|
- Account management (profile, delete account)
|
||||||
|
- Balance/credits display
|
||||||
|
|
||||||
|
#### 10. Artifacts
|
||||||
|
- Split-pane code preview/editor
|
||||||
|
- Tabs: Preview, Code, Info
|
||||||
|
- Live editing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Endpoints (Key Routes)
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
```
|
||||||
|
POST /api/auth/login → { token, user } or { twoFAPending, tempToken }
|
||||||
|
POST /api/auth/register → { status, message }
|
||||||
|
POST /api/auth/logout → { status }
|
||||||
|
POST /api/auth/refresh → { token, user } (refresh token in cookies)
|
||||||
|
POST /api/auth/requestPasswordReset
|
||||||
|
POST /api/auth/resetPassword
|
||||||
|
GET /api/auth/2fa/enable → { secret, qrCode }
|
||||||
|
POST /api/auth/2fa/verify
|
||||||
|
POST /api/auth/2fa/confirm → { confirmed, backupCodes }
|
||||||
|
POST /api/auth/2fa/disable
|
||||||
|
POST /api/auth/2fa/verify-temp → { token, user }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
```
|
||||||
|
GET /api/config → startup config (models, features, auth methods, interface config)
|
||||||
|
GET /api/endpoints → available AI providers and models
|
||||||
|
```
|
||||||
|
|
||||||
|
### Conversations
|
||||||
|
```
|
||||||
|
GET /api/convos → { conversations, nextCursor }
|
||||||
|
GET /api/convos/:conversationId → single conversation
|
||||||
|
POST /api/convos/update → update title
|
||||||
|
POST /api/convos/archive → archive/unarchive
|
||||||
|
DELETE /api/convos → delete conversation(s)
|
||||||
|
POST /api/convos/fork → fork from message
|
||||||
|
POST /api/convos/duplicate → duplicate conversation
|
||||||
|
POST /api/convos/import → import (multipart)
|
||||||
|
GET /api/convos/gen_title/:conversationId → generated title
|
||||||
|
```
|
||||||
|
|
||||||
|
### Messages
|
||||||
|
```
|
||||||
|
GET /api/messages/:conversationId → messages for conversation
|
||||||
|
GET /api/messages/:conversationId/:messageId
|
||||||
|
POST /api/messages/:conversationId → save message
|
||||||
|
PUT /api/messages/:conversationId/:messageId → update message
|
||||||
|
DELETE /api/messages/:conversationId/:messageId
|
||||||
|
PUT /api/messages/:conversationId/:messageId/feedback
|
||||||
|
```
|
||||||
|
|
||||||
|
### Chat (Streaming)
|
||||||
|
```
|
||||||
|
POST /api/agents/chat → { streamId } (start generation)
|
||||||
|
GET /api/agents/chat/stream/:streamId?resume=true → SSE stream
|
||||||
|
POST /api/agents/chat/abort → abort generation
|
||||||
|
GET /api/agents/chat/active → { activeJobIds }
|
||||||
|
GET /api/agents/chat/status/:conversationId → job status
|
||||||
|
```
|
||||||
|
|
||||||
|
### Files
|
||||||
|
```
|
||||||
|
GET /api/files → user's files
|
||||||
|
POST /api/files → upload (multipart)
|
||||||
|
GET /api/files/download/:userId/:file_id
|
||||||
|
DELETE /api/files → delete file(s)
|
||||||
|
POST /api/files/speech/stt → speech-to-text
|
||||||
|
POST /api/files/speech/tts → text-to-speech
|
||||||
|
```
|
||||||
|
|
||||||
|
### Other
|
||||||
|
```
|
||||||
|
GET/POST/DELETE /api/presets
|
||||||
|
GET/POST/PUT/DELETE /api/prompts
|
||||||
|
GET/POST/DELETE /api/tags
|
||||||
|
GET/POST/PATCH/DELETE /api/share
|
||||||
|
GET /api/balance
|
||||||
|
GET /api/search
|
||||||
|
GET /api/user
|
||||||
|
GET /api/banner
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Models
|
||||||
|
|
||||||
|
### User
|
||||||
|
- id, name, email, username, avatar, role, provider
|
||||||
|
- emailVerified, twoFactorEnabled
|
||||||
|
- favorites, termsAccepted
|
||||||
|
|
||||||
|
### Conversation
|
||||||
|
- conversationId (UUID), title, user, endpoint, model
|
||||||
|
- agent_id, assistant_id, tags, isArchived
|
||||||
|
- Model parameters (temperature, top_p, etc.)
|
||||||
|
|
||||||
|
### Message
|
||||||
|
- messageId (UUID), conversationId, parentMessageId
|
||||||
|
- user, sender, text, content (structured parts)
|
||||||
|
- isCreatedByUser, model, endpoint
|
||||||
|
- files, attachments, feedback
|
||||||
|
- error, unfinished, finish_reason, tokenCount
|
||||||
|
|
||||||
|
### File
|
||||||
|
- file_id, filename, filepath, type, bytes, source
|
||||||
|
- user, conversationId, messageId
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SSE Streaming Protocol
|
||||||
|
|
||||||
|
### Flow
|
||||||
|
1. POST to `/api/agents/chat` with message payload → returns `{ streamId }`
|
||||||
|
2. Connect SSE to `GET /api/agents/chat/stream/:streamId`
|
||||||
|
3. Receive events: message, step, created, attachment, final, sync, error
|
||||||
|
4. On disconnect: reconnect with `?resume=true` for sync event
|
||||||
|
|
||||||
|
### SSE Event Format
|
||||||
|
```
|
||||||
|
event: message
|
||||||
|
data: {"type":"content","chunk":"...","status":"streaming"}
|
||||||
|
|
||||||
|
event: message
|
||||||
|
data: {"type":"tool_call","toolName":"...","input":{...}}
|
||||||
|
|
||||||
|
event: message
|
||||||
|
data: {"sync":true,"resumeState":{"runSteps":[...],"aggregatedContent":[...]}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reconnection
|
||||||
|
- Exponential backoff: 1s, 2s, 4s, 8s... max 30s
|
||||||
|
- Max 5 retries
|
||||||
|
- Resume preserves state via sync event
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentication Details
|
||||||
|
|
||||||
|
### Token Management
|
||||||
|
- JWT access token in `Authorization: Bearer <token>` header
|
||||||
|
- Refresh token stored as HTTP-only cookie (for web; Android should store securely)
|
||||||
|
- Access token expiry: ~15 minutes
|
||||||
|
- Refresh token expiry: ~24 hours
|
||||||
|
- Auto-refresh on 401 response
|
||||||
|
|
||||||
|
### OAuth Flow (Android)
|
||||||
|
- Open browser/Custom Chrome Tab for OAuth provider
|
||||||
|
- Callback redirect to app via deep link
|
||||||
|
- Exchange code for token
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UI/UX Reference (from Web App)
|
||||||
|
|
||||||
|
### Theme
|
||||||
|
- Light: White bg (#fff), text #212121, surface hover #e3e3e3
|
||||||
|
- Dark: #0d0d0d bg, text #ececf1, surface hover #424242
|
||||||
|
- Material 3 equivalents should be used on Android
|
||||||
|
|
||||||
|
### Key Screens
|
||||||
|
1. **Server URL Entry** (Android-only onboarding)
|
||||||
|
2. **Login/Register** with social login buttons
|
||||||
|
3. **Chat List** (sidebar on web → drawer or dedicated screen on Android)
|
||||||
|
4. **Chat View** (messages + input)
|
||||||
|
5. **Landing/New Chat** (greeting + model icon)
|
||||||
|
6. **Model Selector** (dropdown → bottom sheet on Android)
|
||||||
|
7. **Settings** (tabbed → Material 3 navigation)
|
||||||
|
8. **Agent Marketplace** (grid of cards)
|
||||||
|
9. **Search** (full-text search)
|
||||||
|
10. **File Viewer/Picker**
|
||||||
|
|
||||||
|
### Navigation Patterns (Android Adaptation)
|
||||||
|
- Web sidebar → Navigation drawer or bottom navigation
|
||||||
|
- Web modals → Bottom sheets or new screens
|
||||||
|
- Web dropdowns → Material 3 menus or bottom sheets
|
||||||
|
- Web hover actions → Long-press menus or always-visible icons
|
||||||
|
|
||||||
|
### Responsive Behavior
|
||||||
|
- Single-pane on phones (chat list or chat view, not both)
|
||||||
|
- Potential dual-pane on tablets
|
||||||
|
- Bottom navigation for primary actions
|
||||||
38
KNOWN_ISSUES.md
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
# Known Issues
|
||||||
|
|
||||||
|
## Tablet back navigation doesn't dismiss sidebar first
|
||||||
|
|
||||||
|
**Status**: Known issue, to be fixed later
|
||||||
|
|
||||||
|
**Symptoms**: In tablet mode with the sidebar open, pressing back navigates away from the current chat instead of dismissing the sidebar first. The sidebar remains covering the screen.
|
||||||
|
|
||||||
|
**Root cause**: The `BackHandler` in `TabletLayout.kt` is present but the system navigation's back handler takes priority. Needs investigation into back handler dispatch ordering relative to the NavHost.
|
||||||
|
|
||||||
|
**Workaround**: Tap the hamburger menu button to toggle the sidebar closed.
|
||||||
|
|
||||||
|
## Chat search: sub-message scroll precision
|
||||||
|
|
||||||
|
**Status**: Requires live debugging session
|
||||||
|
|
||||||
|
**Symptoms**: When navigating search results (prev/next) in the chat search feature, the view scrolls to the correct message but not to the specific text occurrence within long messages. The highlighted orange match can be off-screen if the message is tall.
|
||||||
|
|
||||||
|
**What works**: Match counting per-occurrence, yellow/orange highlighting, dark mode contrast, keyboard dismiss — all work correctly. The only issue is the scroll target precision within a message.
|
||||||
|
|
||||||
|
**Investigation so far** (2 rounds of fixes attempted):
|
||||||
|
1. **BringIntoViewRequester approach** (`122419c`): Attached a `BringIntoViewRequester` to the `HighlightedTextSegment` containing the focused occurrence. Called `bringIntoView()` after `animateScrollToItem()`. Didn't work because the `LazyColumn` treats each `MessageBubble` as a single item — `bringIntoView()` degrades to `scrollToItem()` and can't scroll within an item.
|
||||||
|
2. **onGloballyPositioned + animateScrollBy approach** (`c75a76a`): Replaced `BringIntoViewRequester` with a callback chain. The focused `HighlightedTextSegment` reports its pixel coordinates via `Modifier.onGloballyPositioned`. `MessageList` compares the segment's `boundsInRoot()` to the viewport and calls `animateScrollBy()` with the delta. Still not working — likely a timing issue where `onGloballyPositioned` fires before the initial `animateScrollToItem()` completes, or coordinate space mismatches between the segment and the viewport.
|
||||||
|
|
||||||
|
**Key files**:
|
||||||
|
- `feature/chat/.../components/MessageList.kt` — scroll logic, `pendingFineTuneScroll` flag
|
||||||
|
- `feature/chat/.../components/MarkdownContent.kt` — `HighlightedTextSegment` with `onGloballyPositioned`
|
||||||
|
- `feature/chat/.../components/ContentPartRenderer.kt` — forwards `onFocusedOccurrencePositioned` callback
|
||||||
|
- `feature/chat/.../components/MessageBubble.kt` — forwards callback when `isCurrentSearchMatch`
|
||||||
|
|
||||||
|
**What's needed**: On-device debugging session with Logcat/Timber to trace:
|
||||||
|
- Whether `onGloballyPositioned` fires at the right time (after `animateScrollToItem` completes)
|
||||||
|
- The actual coordinate values from `boundsInRoot()` vs viewport bounds
|
||||||
|
- Whether `animateScrollBy()` is called with the correct delta
|
||||||
|
- Whether the `pendingFineTuneScroll` guard allows or blocks the fine-tune scroll
|
||||||
|
|
||||||
|
**Workaround**: User can manually scroll within the message to find the orange-highlighted occurrence after the search navigates to the correct message.
|
||||||
|
|
||||||
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
67
README.md
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
# LibreChat Android
|
||||||
|
|
||||||
|
Native Android client for [LibreChat](https://www.librechat.ai/). Connects to any self-hosted LibreChat server — no backend modifications required.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Chat** — Real-time streaming (SSE), message branching & sibling navigation, stop/regenerate/continue, markdown with syntax highlighting, LaTeX math rendering, code blocks with copy, image display, file attachments, tool call progress cards
|
||||||
|
- **Model Selection** — Searchable bottom sheet grouped by endpoint, model comparison mode
|
||||||
|
- **Agents** — Marketplace with search and categories, MCP server configuration
|
||||||
|
- **Conversations** — Paginated list with date grouping, tags, search, rename, archive, delete, share, fork, duplicate, export/import
|
||||||
|
- **Presets & Prompts** — Save/load chat presets, prompts library with @mention insertion
|
||||||
|
- **Authentication** — Login, registration, forgot password, two-factor (TOTP + backup codes), OAuth (Google, GitHub, Discord, Facebook, Apple, OpenID)
|
||||||
|
- **Files** — Upload, list, delete, inline image rendering with pinch-to-zoom
|
||||||
|
- **Voice** — Speech-to-text input, text-to-speech playback (device and server engines)
|
||||||
|
- **Settings** — Theme (system/light/dark), account management, data controls
|
||||||
|
- **Tablet** — Adaptive dual-pane layout (600dp+) with persistent sidebar
|
||||||
|
- **Accessibility** — Semantic headings, content descriptions, 48dp touch targets, live regions
|
||||||
|
|
||||||
|
## Server Setup
|
||||||
|
|
||||||
|
The app works with any standard LibreChat server. During onboarding, you'll enter your server URL (e.g., `https://chat.example.com` or `http://192.168.1.100:3080`).
|
||||||
|
|
||||||
|
### Required Configuration
|
||||||
|
|
||||||
|
Add the following to your LibreChat server's `.env` file:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Safety net for native app clients.
|
||||||
|
# The app sends a browser User-Agent to pass the uaParser middleware,
|
||||||
|
# but if it ever fails to parse, this prevents ban point accumulation.
|
||||||
|
NON_BROWSER_VIOLATION_SCORE=0
|
||||||
|
```
|
||||||
|
|
||||||
|
Without this setting, the server's violation system may accumulate ban points against the Android client if the User-Agent check fails, eventually locking the account out.
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- **Registration** — The app respects your server's registration settings. If registration is disabled server-side, only the login form is shown.
|
||||||
|
|
||||||
|
## Building from Source
|
||||||
|
|
||||||
|
**Requirements:** Android Studio, JDK 17+
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew assembleDebug
|
||||||
|
```
|
||||||
|
|
||||||
|
The debug APK will be at `app/build/outputs/apk/debug/app-debug.apk`.
|
||||||
|
|
||||||
|
For a release build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew assembleRelease
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tech Stack
|
||||||
|
|
||||||
|
- Jetpack Compose + Compose Navigation
|
||||||
|
- Hilt (dependency injection)
|
||||||
|
- Ktor Client with OkHttp engine
|
||||||
|
- Kotlinx Serialization
|
||||||
|
- Room (cache), DataStore (preferences), EncryptedSharedPreferences (tokens)
|
||||||
|
- Kotlin 2.1.0, compileSdk 35, minSdk 26
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under the [MIT License](LICENSE).
|
||||||
71
app/CLAUDE.md
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
# App Module
|
||||||
|
|
||||||
|
Single Activity architecture. `MainActivity` is the sole entry point, annotated `@AndroidEntryPoint`.
|
||||||
|
|
||||||
|
## Navigation
|
||||||
|
|
||||||
|
`LibreChatNavHost` is the root composable. It uses adaptive layout based on `WindowSizeClass`:
|
||||||
|
|
||||||
|
- **Phone**: `ModalNavigationDrawer` as primary navigation (sidebar-first pattern matching the web frontend). No bottom navigation bar. Drawer opens via hamburger button in chat header or swipe gesture.
|
||||||
|
- **Tablet** (600dp+ width): Persistent side panel with full conversation list. No `NavigationRail` or bottom bar.
|
||||||
|
|
||||||
|
## Sidebar / Drawer Content
|
||||||
|
|
||||||
|
`DrawerContent` is the rich sidebar matching the web's left panel:
|
||||||
|
- "New Chat" button at top
|
||||||
|
- Search bar with debounce filtering
|
||||||
|
- Conversation items grouped by date (Today, Yesterday, Previous 7 Days, etc.)
|
||||||
|
- Each item shows: endpoint icon, title, model name, relative time
|
||||||
|
- Active conversation highlighted with left border indicator and tinted background
|
||||||
|
- Footer: links to Agents, Files, Settings
|
||||||
|
- Sign out button at bottom
|
||||||
|
|
||||||
|
## Top-Level Destinations
|
||||||
|
|
||||||
|
Defined in `TopLevelDestination` enum: Chat, Conversations, Agents, Files, Settings.
|
||||||
|
Conversations are integrated into the drawer body. Agents, Files, and Settings are accessible via drawer footer links. All routes remain registered in the NavHost.
|
||||||
|
|
||||||
|
## Active Conversation Tracking
|
||||||
|
|
||||||
|
`NavHostViewModel.activeConversationId` tracks the currently viewed conversation. Updated automatically from the navigation back stack when the chat route changes. Used by `DrawerContent` to highlight the active conversation.
|
||||||
|
|
||||||
|
## Auth & Session
|
||||||
|
|
||||||
|
- `NavHostViewModel` observes auth state via `isLoggedIn` flow
|
||||||
|
- Start destination is `CHAT_GRAPH_ROUTE` if logged in, `AUTH_GRAPH_ROUTE` otherwise
|
||||||
|
- `sessionExpired` flow triggers nav to auth graph with full backstack clear
|
||||||
|
- Drawer gestures are hidden during auth flow
|
||||||
|
|
||||||
|
## Deep Linking
|
||||||
|
|
||||||
|
- Scheme: `librechat://conversation/{conversationId}`
|
||||||
|
- Handled in `MainActivity.handleDeepLink()` and forwarded to `LibreChatNavHost`
|
||||||
|
- `onNewIntent` handles deep links when app is already running
|
||||||
|
|
||||||
|
## Connectivity
|
||||||
|
|
||||||
|
- `ConnectivityObserver` injected into `MainActivity`
|
||||||
|
- Offline banner animates in/out at top of screen when connection is lost
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
This module depends on all `:core:*` and all `:feature:*` modules.
|
||||||
|
It applies convention plugins: `librechat.android.application`, `librechat.android.compose`, `librechat.android.hilt`.
|
||||||
|
|
||||||
|
### Server-Synced Favorites
|
||||||
|
- `NavHostViewModel` exposes `favorites: StateFlow<Set<String>>` and `toggleFavorite()`
|
||||||
|
- Currently backed by `SettingsDataStore` (local) — server sync via `UserApi.getFavorites()/updateFavorites()` available but needs wiring
|
||||||
|
- `DrawerContent` shows star icon per conversation (filled = bookmarked)
|
||||||
|
- Favorites state passed through `LibreChatNavHost` and `TabletLayout`
|
||||||
|
|
||||||
|
### Server Banners
|
||||||
|
- `NavHostViewModel` fetches banners from `BannerRepository` on init, filters expired via `displayFrom`/`displayTo` with `Instant.parse()`
|
||||||
|
- Dismissed banner IDs tracked in-memory via `dismissedBannerIds: StateFlow<Set<String>>` (session-scoped, not persisted)
|
||||||
|
- `BannerDisplay` composable shown at top of content in both `LibreChatNavHost` (phone) and `TabletLayout`
|
||||||
|
- Banner types: "info" (blue), "warning" (amber), "error" (red) — defaults to "info" if type is null
|
||||||
|
- **Gotcha**: `displayFrom`/`displayTo` are ISO 8601 strings parsed with `Instant.parse()` — wrap in `runCatching` since the format from the server is not guaranteed
|
||||||
|
|
||||||
|
### Preset Navigation
|
||||||
|
- `PRESET_MANAGER_ROUTE` registered in `SettingsNavigation.kt`
|
||||||
|
- `PresetManagerScreen` accessible from Settings → Chat section
|
||||||
|
- Navigation wired in both `LibreChatNavHost` and `TabletLayout`
|
||||||
42
app/build.gradle.kts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
plugins {
|
||||||
|
id("librechat.android.application")
|
||||||
|
id("librechat.android.compose")
|
||||||
|
id("librechat.android.hilt")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.librechat.android"
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "com.librechat.android"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":core:ui"))
|
||||||
|
implementation(project(":core:data"))
|
||||||
|
implementation(project(":core:network"))
|
||||||
|
implementation(project(":core:model"))
|
||||||
|
implementation(project(":core:common"))
|
||||||
|
implementation(project(":feature:auth"))
|
||||||
|
implementation(project(":feature:chat"))
|
||||||
|
implementation(project(":feature:conversations"))
|
||||||
|
implementation(project(":feature:settings"))
|
||||||
|
implementation(project(":feature:agents"))
|
||||||
|
implementation(project(":feature:files"))
|
||||||
|
|
||||||
|
implementation(libs.activity.compose)
|
||||||
|
implementation(libs.navigation.compose)
|
||||||
|
implementation(libs.hilt.navigation.compose)
|
||||||
|
implementation(libs.compose.material3.wsc)
|
||||||
|
implementation(libs.bundles.lifecycle)
|
||||||
|
implementation(libs.coil.compose)
|
||||||
|
implementation(libs.timber)
|
||||||
|
|
||||||
|
androidTestImplementation(libs.compose.ui.test)
|
||||||
|
debugImplementation(libs.compose.ui.test.manifest)
|
||||||
|
}
|
||||||
17
app/proguard-rules.pro
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
# LibreChat Android ProGuard Rules
|
||||||
|
|
||||||
|
# Keep Kotlin serialization
|
||||||
|
-keepattributes *Annotation*, InnerClasses
|
||||||
|
-dontnote kotlinx.serialization.AnnotationsKt
|
||||||
|
-keepclassmembers class kotlinx.serialization.json.** { *** Companion; }
|
||||||
|
-keepclasseswithmembers class kotlinx.serialization.json.** { kotlinx.serialization.KSerializer serializer(...); }
|
||||||
|
-keep,includedescriptorclasses class com.librechat.android.**$$serializer { *; }
|
||||||
|
-keepclassmembers class com.librechat.android.** { *** Companion; }
|
||||||
|
-keepclasseswithmembers class com.librechat.android.** { kotlinx.serialization.KSerializer serializer(...); }
|
||||||
|
|
||||||
|
# Keep Ktor
|
||||||
|
-keep class io.ktor.** { *; }
|
||||||
|
-dontwarn io.ktor.**
|
||||||
|
|
||||||
|
# Keep Room entities
|
||||||
|
-keep class com.librechat.android.core.data.db.entity.** { *; }
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
package com.librechat.android
|
||||||
|
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.assertIsEnabled
|
||||||
|
import androidx.compose.ui.test.assertIsNotEnabled
|
||||||
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.performTextInput
|
||||||
|
import com.librechat.android.core.ui.theme.LibreChatTheme
|
||||||
|
import com.librechat.android.feature.auth.screen.ServerUrlScreen
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class AuthFlowTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val composeTestRule = createComposeRule()
|
||||||
|
|
||||||
|
private fun setUpServerUrlScreen() {
|
||||||
|
composeTestRule.setContent {
|
||||||
|
LibreChatTheme {
|
||||||
|
ServerUrlScreen(
|
||||||
|
onServerValidated = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun serverUrlScreen_displaysCorrectly() {
|
||||||
|
setUpServerUrlScreen()
|
||||||
|
composeTestRule.onNodeWithText("Connect to LibreChat").assertIsDisplayed()
|
||||||
|
composeTestRule.onNodeWithText("Server URL").assertIsDisplayed()
|
||||||
|
composeTestRule.onNodeWithText("Connect").assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun serverUrlScreen_connectButtonDisabledWhenEmpty() {
|
||||||
|
setUpServerUrlScreen()
|
||||||
|
composeTestRule.onNodeWithText("Connect").assertIsNotEnabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun serverUrlScreen_enablesConnectOnInput() {
|
||||||
|
setUpServerUrlScreen()
|
||||||
|
composeTestRule.onNodeWithText("Server URL").performTextInput("https://example.com")
|
||||||
|
composeTestRule.onNodeWithText("Connect").assertIsEnabled()
|
||||||
|
}
|
||||||
|
}
|
||||||
89
app/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
|
android:maxSdkVersion="28" />
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
|
||||||
|
<uses-feature
|
||||||
|
android:name="android.hardware.camera"
|
||||||
|
android:required="false" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:name=".LibreChatApplication"
|
||||||
|
android:allowBackup="false"
|
||||||
|
android:enableOnBackInvokedCallback="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="LibreChat"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.LibreChat">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:launchMode="singleTask"
|
||||||
|
android:windowSoftInputMode="adjustResize"
|
||||||
|
android:theme="@style/Theme.LibreChat">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- Deep links -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="librechat" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- Receive shared text -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.SEND" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:mimeType="text/plain" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- Receive shared images -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.SEND" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:mimeType="image/*" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- Receive shared files -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.SEND" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:mimeType="*/*" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- Receive multiple shared items -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:mimeType="image/*" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:mimeType="*/*" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_provider_paths" />
|
||||||
|
</provider>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
package com.librechat.android
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import coil.ImageLoader
|
||||||
|
import coil.ImageLoaderFactory
|
||||||
|
import coil.disk.DiskCache
|
||||||
|
import coil.memory.MemoryCache
|
||||||
|
import com.librechat.android.core.network.client.TokenManager
|
||||||
|
import dagger.hilt.EntryPoint
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.EntryPointAccessors
|
||||||
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
@HiltAndroidApp
|
||||||
|
class LibreChatApplication : Application(), ImageLoaderFactory {
|
||||||
|
|
||||||
|
@EntryPoint
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
interface ImageLoaderEntryPoint {
|
||||||
|
fun tokenManager(): TokenManager
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
Timber.plant(Timber.DebugTree())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun newImageLoader(): ImageLoader {
|
||||||
|
val entryPoint = EntryPointAccessors.fromApplication(
|
||||||
|
this, ImageLoaderEntryPoint::class.java
|
||||||
|
)
|
||||||
|
val tokenManager = entryPoint.tokenManager()
|
||||||
|
|
||||||
|
val okHttpClient = OkHttpClient.Builder()
|
||||||
|
.addInterceptor { chain ->
|
||||||
|
val token = runBlocking { tokenManager.getAccessToken() }
|
||||||
|
val requestBuilder = chain.request().newBuilder()
|
||||||
|
// The backend's uaParser middleware (ua-parser-js) rejects requests
|
||||||
|
// without a recognized browser User-Agent with 403. This must match
|
||||||
|
// the UA string used by the Ktor HttpClient.
|
||||||
|
.header(
|
||||||
|
"User-Agent",
|
||||||
|
"Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 " +
|
||||||
|
"(KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36",
|
||||||
|
)
|
||||||
|
if (token != null) {
|
||||||
|
requestBuilder.addHeader("Authorization", "Bearer $token")
|
||||||
|
}
|
||||||
|
val request = requestBuilder.build()
|
||||||
|
Timber.d("Coil image request: %s", request.url)
|
||||||
|
val response = chain.proceed(request)
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
Timber.w(
|
||||||
|
"Coil image request failed: %s -> %d %s",
|
||||||
|
request.url,
|
||||||
|
response.code,
|
||||||
|
response.message,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
response
|
||||||
|
}
|
||||||
|
.build()
|
||||||
|
|
||||||
|
return ImageLoader.Builder(this)
|
||||||
|
.okHttpClient(okHttpClient)
|
||||||
|
.memoryCache {
|
||||||
|
MemoryCache.Builder(this)
|
||||||
|
.maxSizePercent(0.25)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
.diskCache {
|
||||||
|
DiskCache.Builder()
|
||||||
|
.directory(cacheDir.resolve("image_cache"))
|
||||||
|
.maxSizeBytes(250L * 1024 * 1024) // 250 MB
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
.crossfade(true)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
192
app/src/main/kotlin/com/librechat/android/MainActivity.kt
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
package com.librechat.android
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.expandVertically
|
||||||
|
import androidx.compose.animation.shrinkVertically
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
|
||||||
|
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.view.WindowCompat
|
||||||
|
import com.librechat.android.core.common.network.ConnectivityObserver
|
||||||
|
import com.librechat.android.core.data.datastore.ThemeDataStore
|
||||||
|
import com.librechat.android.core.data.datastore.ThemeMode
|
||||||
|
import com.librechat.android.core.ui.theme.LibreChatTheme
|
||||||
|
import com.librechat.android.feature.chat.ShareIntentConsumer
|
||||||
|
import com.librechat.android.feature.chat.SharedContent
|
||||||
|
import com.librechat.android.navigation.LibreChatNavHost
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
@Inject lateinit var connectivityObserver: ConnectivityObserver
|
||||||
|
@Inject lateinit var themeDataStore: ThemeDataStore
|
||||||
|
|
||||||
|
private var deepLinkUri by mutableStateOf<android.net.Uri?>(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Incremented each time a share intent arrives.
|
||||||
|
* The NavHost observes this and either:
|
||||||
|
* - Navigates to NEW_CHAT_ROUTE if the user is not on a chat screen, or
|
||||||
|
* - Lets the active ChatViewModel consume the shared content in-place if already on a chat screen.
|
||||||
|
*/
|
||||||
|
private var shareNavigationTrigger by mutableStateOf(0)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
enableEdgeToEdge()
|
||||||
|
|
||||||
|
handleIntent(intent)
|
||||||
|
setContent {
|
||||||
|
val windowSizeClass = calculateWindowSizeClass(this)
|
||||||
|
val isConnected by connectivityObserver.isConnected.collectAsStateWithLifecycle(initialValue = true)
|
||||||
|
val themeMode by themeDataStore.themeMode.collectAsStateWithLifecycle(initialValue = ThemeMode.SYSTEM)
|
||||||
|
val darkTheme = when (themeMode) {
|
||||||
|
ThemeMode.LIGHT -> false
|
||||||
|
ThemeMode.DARK -> true
|
||||||
|
ThemeMode.SYSTEM -> isSystemInDarkTheme()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update system bar icon colors to match the app's resolved theme.
|
||||||
|
// When light theme: dark icons on light background (isAppearanceLight = true).
|
||||||
|
// When dark theme: light icons on dark background (isAppearanceLight = false).
|
||||||
|
// This ensures correct visibility even when the user overrides the system theme.
|
||||||
|
SideEffect {
|
||||||
|
val insetsController = WindowCompat.getInsetsController(window, window.decorView)
|
||||||
|
insetsController.isAppearanceLightStatusBars = !darkTheme
|
||||||
|
insetsController.isAppearanceLightNavigationBars = !darkTheme
|
||||||
|
}
|
||||||
|
|
||||||
|
LibreChatTheme(darkTheme = darkTheme) {
|
||||||
|
Surface(modifier = Modifier.fillMaxSize()) {
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = !isConnected,
|
||||||
|
enter = expandVertically(),
|
||||||
|
exit = shrinkVertically(),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.errorContainer)
|
||||||
|
.padding(vertical = 6.dp, horizontal = 16.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.no_connection),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LibreChatNavHost(
|
||||||
|
windowSizeClass = windowSizeClass,
|
||||||
|
deepLinkUri = deepLinkUri,
|
||||||
|
onDeepLinkConsumed = { deepLinkUri = null },
|
||||||
|
shareNavigationTrigger = shareNavigationTrigger,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
handleIntent(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleIntent(intent: Intent?) {
|
||||||
|
if (intent == null) return
|
||||||
|
|
||||||
|
when (intent.action) {
|
||||||
|
Intent.ACTION_SEND -> handleShareIntent(intent)
|
||||||
|
Intent.ACTION_SEND_MULTIPLE -> handleShareMultipleIntent(intent)
|
||||||
|
else -> handleDeepLink(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleDeepLink(intent: Intent) {
|
||||||
|
intent.data?.let { uri ->
|
||||||
|
if (uri.scheme == "librechat" && uri.host in KNOWN_DEEP_LINK_HOSTS) {
|
||||||
|
deepLinkUri = uri
|
||||||
|
} else if (uri.scheme == "librechat") {
|
||||||
|
Timber.w("Ignoring deep link with unknown host: %s", uri.host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** Hosts accepted by the librechat:// deep link scheme. */
|
||||||
|
private val KNOWN_DEEP_LINK_HOSTS = setOf("conversation", "oauth")
|
||||||
|
|
||||||
|
/** MongoDB ObjectID: exactly 24 lowercase hex characters. */
|
||||||
|
val CONVERSATION_ID_REGEX = Regex("^[a-f0-9]{24}$")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleShareIntent(intent: Intent) {
|
||||||
|
val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT)
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val sharedUri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
|
||||||
|
} else {
|
||||||
|
intent.getParcelableExtra(Intent.EXTRA_STREAM)
|
||||||
|
}
|
||||||
|
|
||||||
|
val fileUris = if (sharedUri != null) listOf(sharedUri) else emptyList()
|
||||||
|
|
||||||
|
if (sharedText != null || fileUris.isNotEmpty()) {
|
||||||
|
Timber.d("Share intent received: text=%s, uris=%d", sharedText != null, fileUris.size)
|
||||||
|
ShareIntentConsumer.setPendingShare(
|
||||||
|
SharedContent(text = sharedText, fileUris = fileUris),
|
||||||
|
)
|
||||||
|
shareNavigationTrigger++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleShareMultipleIntent(intent: Intent) {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val sharedUris = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)
|
||||||
|
} else {
|
||||||
|
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sharedUris.isNullOrEmpty()) {
|
||||||
|
Timber.d("Share multiple intent received: uris=%d", sharedUris.size)
|
||||||
|
ShareIntentConsumer.setPendingShare(
|
||||||
|
SharedContent(fileUris = sharedUris),
|
||||||
|
)
|
||||||
|
shareNavigationTrigger++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,520 @@
|
||||||
|
package com.librechat.android.navigation
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Folder
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material.icons.filled.SmartToy
|
||||||
|
import androidx.compose.material.icons.filled.Star
|
||||||
|
import androidx.compose.material.icons.filled.StarBorder
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.semantics.heading
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.librechat.android.core.model.EModelEndpoint
|
||||||
|
import com.librechat.android.core.ui.components.isMonochromeIcon
|
||||||
|
import com.librechat.android.core.ui.components.toIconRes
|
||||||
|
import com.librechat.android.R
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight snapshot of fields DrawerConversationItem actually renders.
|
||||||
|
* Avoids passing the full 28-field Conversation through composition.
|
||||||
|
*/
|
||||||
|
@Immutable
|
||||||
|
data class DrawerConversationDisplayData(
|
||||||
|
val conversationId: String,
|
||||||
|
val title: String,
|
||||||
|
val model: String?,
|
||||||
|
val endpoint: EModelEndpoint?,
|
||||||
|
val relativeTime: String,
|
||||||
|
val isActive: Boolean,
|
||||||
|
val isFavorite: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pre-computed shapes to avoid creating new ones per item per frame
|
||||||
|
private val ItemShape = RoundedCornerShape(8.dp)
|
||||||
|
private val ActiveIndicatorShape = RoundedCornerShape(2.dp)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stateful DrawerContent that collects its own state from the ViewModel.
|
||||||
|
* State changes only recompose inside this composable — not the parent
|
||||||
|
* PhoneLayout/TabletLayout, which avoids recomposing the NavHost/main content.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun DrawerContent(
|
||||||
|
viewModel: NavHostViewModel,
|
||||||
|
onNewChat: () -> Unit,
|
||||||
|
onConversationClick: (String) -> Unit,
|
||||||
|
onSettingsClick: () -> Unit,
|
||||||
|
onAgentsClick: () -> Unit,
|
||||||
|
onFilesClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.drawerUiState.collectAsStateWithLifecycle()
|
||||||
|
DrawerContent(
|
||||||
|
uiState = uiState,
|
||||||
|
onSearchQueryChanged = viewModel::onSearchQueryChanged,
|
||||||
|
onNewChat = onNewChat,
|
||||||
|
onConversationClick = onConversationClick,
|
||||||
|
onSettingsClick = onSettingsClick,
|
||||||
|
onAgentsClick = onAgentsClick,
|
||||||
|
onFilesClick = onFilesClick,
|
||||||
|
onToggleFavorite = viewModel::toggleFavorite,
|
||||||
|
onRefresh = viewModel::refreshConversations,
|
||||||
|
onLoadMore = viewModel::loadMoreConversations,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun DrawerContent(
|
||||||
|
uiState: DrawerUiState,
|
||||||
|
onSearchQueryChanged: (String) -> Unit,
|
||||||
|
onNewChat: () -> Unit,
|
||||||
|
onConversationClick: (String) -> Unit,
|
||||||
|
onSettingsClick: () -> Unit,
|
||||||
|
onAgentsClick: () -> Unit,
|
||||||
|
onFilesClick: () -> Unit,
|
||||||
|
onToggleFavorite: (String) -> Unit = {},
|
||||||
|
onRefresh: () -> Unit = {},
|
||||||
|
onLoadMore: () -> Unit = {},
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxHeight()
|
||||||
|
.width(300.dp)
|
||||||
|
.statusBarsPadding()
|
||||||
|
.padding(top = 16.dp),
|
||||||
|
) {
|
||||||
|
// "New Chat" button at top
|
||||||
|
Surface(
|
||||||
|
onClick = onNewChat,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp),
|
||||||
|
shape = ItemShape,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Add,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.new_chat),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
// Search bar
|
||||||
|
OutlinedTextField(
|
||||||
|
value = uiState.searchQuery,
|
||||||
|
onValueChange = onSearchQueryChanged,
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Search,
|
||||||
|
contentDescription = stringResource(R.string.cd_search),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingIcon = {
|
||||||
|
if (uiState.searchQuery.isNotEmpty()) {
|
||||||
|
IconButton(onClick = { onSearchQueryChanged("") }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = stringResource(R.string.cd_clear_search),
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
placeholder = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.search_conversations_placeholder),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
singleLine = true,
|
||||||
|
shape = ItemShape,
|
||||||
|
textStyle = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Conversation list with favorites section and date groups
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
|
// Detect when scrolled near the end to trigger load-more
|
||||||
|
val shouldLoadMore = remember {
|
||||||
|
derivedStateOf {
|
||||||
|
val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
|
||||||
|
val totalItems = listState.layoutInfo.totalItemsCount
|
||||||
|
lastVisibleItem >= totalItems - 8 && totalItems > 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(shouldLoadMore.value) {
|
||||||
|
if (shouldLoadMore.value && uiState.hasMore && !uiState.isLoadingMore) {
|
||||||
|
onLoadMore()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PullToRefreshBox(
|
||||||
|
isRefreshing = uiState.isRefreshing,
|
||||||
|
onRefresh = onRefresh,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
// Favorites section
|
||||||
|
if (uiState.favoriteConversations.isNotEmpty() && uiState.searchQuery.isEmpty()) {
|
||||||
|
item(key = "favorites_header") {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(
|
||||||
|
start = 16.dp,
|
||||||
|
end = 16.dp,
|
||||||
|
top = 8.dp,
|
||||||
|
bottom = 4.dp,
|
||||||
|
),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Star,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(14.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.favorites),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.semantics { heading() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items(
|
||||||
|
items = uiState.favoriteConversations,
|
||||||
|
key = { "fav_${it.conversationId}" },
|
||||||
|
contentType = { "conversation" },
|
||||||
|
) { data ->
|
||||||
|
DrawerConversationItem(
|
||||||
|
data = data,
|
||||||
|
onClick = { onConversationClick(data.conversationId) },
|
||||||
|
onToggleFavorite = { onToggleFavorite(data.conversationId) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item(key = "favorites_divider") {
|
||||||
|
HorizontalDivider(
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
color = MaterialTheme.colorScheme.outlineVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uiState.groupedConversations.isEmpty() && uiState.searchQuery.isNotEmpty()) {
|
||||||
|
item(key = "empty_search") {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.no_conversations_found),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 24.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uiState.groupedConversations.forEach { (dateGroup, displayItems) ->
|
||||||
|
// Date group header
|
||||||
|
item(key = "header_$dateGroup") {
|
||||||
|
Text(
|
||||||
|
text = dateGroup,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(
|
||||||
|
start = 16.dp,
|
||||||
|
end = 16.dp,
|
||||||
|
top = 12.dp,
|
||||||
|
bottom = 4.dp,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conversation items in this group
|
||||||
|
items(
|
||||||
|
items = displayItems,
|
||||||
|
key = { it.conversationId },
|
||||||
|
contentType = { "conversation" },
|
||||||
|
) { data ->
|
||||||
|
DrawerConversationItem(
|
||||||
|
data = data,
|
||||||
|
onClick = { onConversationClick(data.conversationId) },
|
||||||
|
onToggleFavorite = { onToggleFavorite(data.conversationId) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading indicator at the bottom when loading more
|
||||||
|
if (uiState.isLoadingMore) {
|
||||||
|
item(key = "loading_more") {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom section: divider + footer links + sign out
|
||||||
|
HorizontalDivider(
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp),
|
||||||
|
color = MaterialTheme.colorScheme.outlineVariant,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Footer navigation links
|
||||||
|
DrawerFooterItem(
|
||||||
|
icon = Icons.Default.SmartToy,
|
||||||
|
label = stringResource(R.string.agents),
|
||||||
|
onClick = onAgentsClick,
|
||||||
|
)
|
||||||
|
DrawerFooterItem(
|
||||||
|
icon = Icons.Default.Folder,
|
||||||
|
label = stringResource(R.string.files),
|
||||||
|
onClick = onFilesClick,
|
||||||
|
)
|
||||||
|
DrawerFooterItem(
|
||||||
|
icon = Icons.Default.Settings,
|
||||||
|
label = stringResource(R.string.settings),
|
||||||
|
onClick = onSettingsClick,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DrawerConversationItem(
|
||||||
|
data: DrawerConversationDisplayData,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onToggleFavorite: () -> Unit = {},
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val endpointIconRes = remember(data.endpoint) {
|
||||||
|
data.endpoint?.toIconRes()
|
||||||
|
}
|
||||||
|
|
||||||
|
val backgroundColor = if (data.isActive) {
|
||||||
|
MaterialTheme.colorScheme.secondaryContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surface
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.padding(horizontal = 4.dp, vertical = 1.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(backgroundColor, ItemShape)
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
// Active indicator (left border simulation)
|
||||||
|
if (data.isActive) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(3.dp)
|
||||||
|
.height(24.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.primary, ActiveIndicatorShape),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Endpoint icon or star for favorites
|
||||||
|
if (data.isFavorite) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Star,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
} else if (endpointIconRes != null) {
|
||||||
|
val isMonochrome = data.endpoint?.isMonochromeIcon() == true
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = endpointIconRes),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
tint = if (isMonochrome) {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
} else {
|
||||||
|
Color.Unspecified
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.SmartToy,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
tint = if (data.isActive) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(10.dp))
|
||||||
|
|
||||||
|
// Title and metadata
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = data.title,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = if (data.isActive) {
|
||||||
|
MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
},
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Model and time on second line
|
||||||
|
val subtitle = remember(data.model, data.relativeTime) {
|
||||||
|
buildString {
|
||||||
|
data.model?.let { model ->
|
||||||
|
append(model.take(20))
|
||||||
|
}
|
||||||
|
if (data.relativeTime.isNotEmpty()) {
|
||||||
|
if (isNotEmpty()) append(" \u00B7 ")
|
||||||
|
append(data.relativeTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (subtitle.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = subtitle,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bookmark toggle — lightweight clickable icon instead of IconButton
|
||||||
|
Icon(
|
||||||
|
imageVector = if (data.isFavorite) Icons.Default.Star else Icons.Default.StarBorder,
|
||||||
|
contentDescription = if (data.isFavorite) stringResource(R.string.remove_bookmark) else stringResource(R.string.bookmark),
|
||||||
|
modifier = Modifier
|
||||||
|
.size(32.dp)
|
||||||
|
.clickable(onClick = onToggleFavorite)
|
||||||
|
.padding(8.dp),
|
||||||
|
tint = if (data.isFavorite) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DrawerFooterItem(
|
||||||
|
icon: ImageVector,
|
||||||
|
label: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = label,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,428 @@
|
||||||
|
package com.librechat.android.navigation
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import com.librechat.android.MainActivity
|
||||||
|
import timber.log.Timber
|
||||||
|
import androidx.compose.animation.AnimatedContentTransitionScope
|
||||||
|
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||||
|
import androidx.compose.animation.core.LinearEasing
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.scaleIn
|
||||||
|
import androidx.compose.animation.scaleOut
|
||||||
|
import androidx.compose.animation.slideOut
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.DrawerValue
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalDrawerSheet
|
||||||
|
import androidx.compose.material3.ModalNavigationDrawer
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.rememberDrawerState
|
||||||
|
import androidx.compose.material3.windowsizeclass.WindowSizeClass
|
||||||
|
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.navigation.compose.NavHost
|
||||||
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import com.librechat.android.feature.agents.navigation.AGENT_EDITOR_CREATE_ROUTE
|
||||||
|
import com.librechat.android.feature.agents.navigation.agentsGraph
|
||||||
|
import com.librechat.android.feature.auth.navigation.AUTH_GRAPH_ROUTE
|
||||||
|
import com.librechat.android.feature.auth.navigation.authGraph
|
||||||
|
import com.librechat.android.feature.chat.navigation.CHAT_GRAPH_ROUTE
|
||||||
|
import com.librechat.android.feature.chat.navigation.CHAT_ROUTE
|
||||||
|
import com.librechat.android.feature.chat.navigation.NEW_CHAT_ROUTE
|
||||||
|
import com.librechat.android.feature.chat.navigation.chatGraph
|
||||||
|
import com.librechat.android.feature.chat.navigation.navigateToChat
|
||||||
|
import com.librechat.android.feature.conversations.navigation.conversationsGraph
|
||||||
|
import com.librechat.android.feature.files.navigation.filesGraph
|
||||||
|
import com.librechat.android.feature.settings.navigation.API_KEYS_ROUTE
|
||||||
|
import com.librechat.android.feature.settings.navigation.PRESET_MANAGER_ROUTE
|
||||||
|
import com.librechat.android.core.ui.components.BannerDisplay
|
||||||
|
import com.librechat.android.feature.settings.navigation.SETTINGS_ACCOUNT_ROUTE
|
||||||
|
import com.librechat.android.feature.settings.navigation.SETTINGS_CHAT_ROUTE
|
||||||
|
import com.librechat.android.feature.settings.navigation.SETTINGS_DATA_ROUTE
|
||||||
|
import com.librechat.android.feature.settings.navigation.SETTINGS_GENERAL_ROUTE
|
||||||
|
import com.librechat.android.feature.settings.navigation.SETTINGS_TABBED_ROUTE
|
||||||
|
import com.librechat.android.feature.settings.navigation.SHARED_LINKS_ROUTE
|
||||||
|
import com.librechat.android.feature.settings.navigation.settingsGraph
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import com.librechat.android.R
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
|
||||||
|
/** Maps a [SettingsCategory] to its corresponding navigation route. */
|
||||||
|
fun SettingsCategory.toRoute(): String = when (this) {
|
||||||
|
SettingsCategory.GENERAL -> SETTINGS_GENERAL_ROUTE
|
||||||
|
SettingsCategory.CHAT -> SETTINGS_CHAT_ROUTE
|
||||||
|
SettingsCategory.ACCOUNT -> SETTINGS_ACCOUNT_ROUTE
|
||||||
|
SettingsCategory.DATA -> SETTINGS_DATA_ROUTE
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LibreChatNavHost(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
windowSizeClass: WindowSizeClass? = null,
|
||||||
|
deepLinkUri: Uri? = null,
|
||||||
|
onDeepLinkConsumed: () -> Unit = {},
|
||||||
|
shareNavigationTrigger: Int = 0,
|
||||||
|
navHostViewModel: NavHostViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val navController = rememberNavController()
|
||||||
|
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||||
|
val currentDestination = navBackStackEntry?.destination
|
||||||
|
|
||||||
|
val isLoggedIn by navHostViewModel.isLoggedIn.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
val isInAuthFlow = currentDestination?.route == AUTH_GRAPH_ROUTE ||
|
||||||
|
currentDestination?.parent?.route == AUTH_GRAPH_ROUTE
|
||||||
|
|
||||||
|
// Track active conversation from nav back stack
|
||||||
|
LaunchedEffect(navBackStackEntry) {
|
||||||
|
val route = navBackStackEntry?.destination?.route
|
||||||
|
val conversationId = if (route == CHAT_ROUTE) {
|
||||||
|
navBackStackEntry?.arguments?.getString("conversationId")
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
navHostViewModel.setActiveConversation(conversationId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle session expiry
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
navHostViewModel.sessionExpired.collect {
|
||||||
|
navController.navigate(AUTH_GRAPH_ROUTE) {
|
||||||
|
popUpTo(0) { inclusive = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val startDestination = if (isLoggedIn) CHAT_GRAPH_ROUTE else AUTH_GRAPH_ROUTE
|
||||||
|
|
||||||
|
val isTablet = windowSizeClass?.widthSizeClass?.let {
|
||||||
|
it >= WindowWidthSizeClass.Medium
|
||||||
|
} ?: false
|
||||||
|
|
||||||
|
if (isTablet) {
|
||||||
|
TabletLayout(
|
||||||
|
navController = navController,
|
||||||
|
navHostViewModel = navHostViewModel,
|
||||||
|
startDestination = startDestination,
|
||||||
|
isInAuthFlow = isInAuthFlow,
|
||||||
|
deepLinkUri = deepLinkUri,
|
||||||
|
onDeepLinkConsumed = onDeepLinkConsumed,
|
||||||
|
shareNavigationTrigger = shareNavigationTrigger,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
PhoneLayout(
|
||||||
|
navController = navController,
|
||||||
|
navHostViewModel = navHostViewModel,
|
||||||
|
startDestination = startDestination,
|
||||||
|
isInAuthFlow = isInAuthFlow,
|
||||||
|
deepLinkUri = deepLinkUri,
|
||||||
|
onDeepLinkConsumed = onDeepLinkConsumed,
|
||||||
|
shareNavigationTrigger = shareNavigationTrigger,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Version mismatch warning dialog (shown over any layout)
|
||||||
|
val versionMismatch by navHostViewModel.versionMismatch.collectAsStateWithLifecycle()
|
||||||
|
versionMismatch?.let { mismatch ->
|
||||||
|
VersionMismatchDialog(
|
||||||
|
supportedVersion = mismatch.supportedVersion,
|
||||||
|
backendVersion = mismatch.backendVersion,
|
||||||
|
onDismiss = navHostViewModel::dismissVersionWarning,
|
||||||
|
onDismissPermanently = navHostViewModel::dismissVersionWarningPermanently,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dialog shown when the backend server version does not match the version
|
||||||
|
* this app was built for. Offers "Dismiss" (session-only) and
|
||||||
|
* "Don't warn again" (persisted per backend version) options.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun VersionMismatchDialog(
|
||||||
|
supportedVersion: String,
|
||||||
|
backendVersion: String,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onDismissPermanently: () -> Unit,
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.version_mismatch_title),
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.version_mismatch_message, supportedVersion, backendVersion),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.dismiss))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismissPermanently) {
|
||||||
|
Text(stringResource(R.string.dont_warn_again))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PhoneLayout(
|
||||||
|
navController: androidx.navigation.NavHostController,
|
||||||
|
navHostViewModel: NavHostViewModel,
|
||||||
|
startDestination: String,
|
||||||
|
isInAuthFlow: Boolean,
|
||||||
|
deepLinkUri: Uri?,
|
||||||
|
onDeepLinkConsumed: () -> Unit,
|
||||||
|
shareNavigationTrigger: Int,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
// Banner state only -- drawer state is collected inside DrawerContent itself
|
||||||
|
val banners by navHostViewModel.banners.collectAsStateWithLifecycle()
|
||||||
|
val dismissedBannerIds by navHostViewModel.dismissedBannerIds.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
// Handle deep links
|
||||||
|
LaunchedEffect(deepLinkUri) {
|
||||||
|
deepLinkUri?.let { uri ->
|
||||||
|
if (uri.scheme == "librechat" && uri.host == "conversation") {
|
||||||
|
uri.lastPathSegment?.let { conversationId ->
|
||||||
|
if (MainActivity.CONVERSATION_ID_REGEX.matches(conversationId)) {
|
||||||
|
navController.navigateToChat(conversationId)
|
||||||
|
} else {
|
||||||
|
Timber.w("Ignoring deep link with invalid conversation ID: %s", conversationId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onDeepLinkConsumed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle share intent: if already on a chat screen, let the active ChatViewModel
|
||||||
|
// consume the shared content reactively. Only navigate to new chat when on a
|
||||||
|
// non-chat screen (e.g. settings, agents, files).
|
||||||
|
LaunchedEffect(shareNavigationTrigger) {
|
||||||
|
if (shareNavigationTrigger > 0) {
|
||||||
|
val currentRoute = navController.currentBackStackEntry?.destination?.route
|
||||||
|
val isOnChatScreen = currentRoute == CHAT_ROUTE || currentRoute == NEW_CHAT_ROUTE
|
||||||
|
if (!isOnChatScreen) {
|
||||||
|
navController.navigate(NEW_CHAT_ROUTE) {
|
||||||
|
popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false }
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If already on a chat screen, SharedIntentConsumer.shareAvailable
|
||||||
|
// will notify the active ChatViewModel to consume the content.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset sidebar mode to Conversations when the drawer closes
|
||||||
|
LaunchedEffect(drawerState.isClosed) {
|
||||||
|
if (drawerState.isClosed) {
|
||||||
|
navHostViewModel.setSidebarMode(SidebarMode.Conversations)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ModalNavigationDrawer(
|
||||||
|
drawerState = drawerState,
|
||||||
|
gesturesEnabled = !isInAuthFlow,
|
||||||
|
drawerContent = {
|
||||||
|
ModalDrawerSheet {
|
||||||
|
SidebarScaffold(
|
||||||
|
viewModel = navHostViewModel,
|
||||||
|
onNewChat = {
|
||||||
|
scope.launch { drawerState.close() }
|
||||||
|
// Skip navigation if already on the new chat screen
|
||||||
|
val currentRoute = navController.currentBackStackEntry?.destination?.route
|
||||||
|
if (currentRoute != NEW_CHAT_ROUTE) {
|
||||||
|
navController.navigate(NEW_CHAT_ROUTE) {
|
||||||
|
popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false }
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onConversationClick = { conversationId ->
|
||||||
|
scope.launch { drawerState.close() }
|
||||||
|
navController.navigateToChat(conversationId)
|
||||||
|
},
|
||||||
|
onSettingsClick = {
|
||||||
|
scope.launch { drawerState.close() }
|
||||||
|
navController.navigate(SETTINGS_TABBED_ROUTE) {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSettingsCategorySelected = { category ->
|
||||||
|
// Navigate to the settings sub-page but keep the drawer open
|
||||||
|
navController.navigate(category.toRoute()) {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onAgentsClick = {
|
||||||
|
scope.launch { drawerState.close() }
|
||||||
|
navController.navigate(TopLevelDestination.AGENTS.route) {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onFilesClick = {
|
||||||
|
scope.launch { drawerState.close() }
|
||||||
|
navController.navigate(TopLevelDestination.FILES.route) {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
// No bottom bar -- drawer is the primary navigation
|
||||||
|
Column(modifier = modifier.fillMaxSize()) {
|
||||||
|
if (!isInAuthFlow && banners.isNotEmpty()) {
|
||||||
|
BannerDisplay(
|
||||||
|
banners = banners,
|
||||||
|
dismissedIds = dismissedBannerIds,
|
||||||
|
onDismiss = navHostViewModel::dismissBanner,
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
NavHost(
|
||||||
|
navController = navController,
|
||||||
|
startDestination = startDestination,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
enterTransition = {
|
||||||
|
slideIntoContainer(
|
||||||
|
towards = AnimatedContentTransitionScope.SlideDirection.Start,
|
||||||
|
animationSpec = tween(300),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
exitTransition = {
|
||||||
|
fadeOut(animationSpec = tween(150))
|
||||||
|
},
|
||||||
|
popEnterTransition = {
|
||||||
|
fadeIn(
|
||||||
|
initialAlpha = 0.5f,
|
||||||
|
animationSpec = tween(300, easing = LinearEasing),
|
||||||
|
) + scaleIn(
|
||||||
|
initialScale = 0.92f,
|
||||||
|
animationSpec = tween(300, easing = LinearEasing),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
popExitTransition = {
|
||||||
|
slideOut(
|
||||||
|
targetOffset = { IntOffset((it.width * 0.15f).toInt(), 0) },
|
||||||
|
animationSpec = tween(300, easing = FastOutSlowInEasing),
|
||||||
|
) + scaleOut(
|
||||||
|
targetScale = 0.92f,
|
||||||
|
animationSpec = tween(300, easing = LinearEasing),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
authGraph(
|
||||||
|
navController = navController,
|
||||||
|
onAuthComplete = {
|
||||||
|
navHostViewModel.onAuthComplete()
|
||||||
|
navController.navigate(CHAT_GRAPH_ROUTE) {
|
||||||
|
popUpTo(AUTH_GRAPH_ROUTE) { inclusive = true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
chatGraph(
|
||||||
|
navController = navController,
|
||||||
|
onOpenDrawer = {
|
||||||
|
scope.launch { drawerState.open() }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
conversationsGraph(
|
||||||
|
onConversationClick = { conversationId ->
|
||||||
|
navController.navigateToChat(conversationId)
|
||||||
|
},
|
||||||
|
onNavigateToArchived = {
|
||||||
|
navController.navigate("conversations/archived")
|
||||||
|
},
|
||||||
|
onNavigateBackFromArchived = {
|
||||||
|
navController.popBackStack()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
agentsGraph(
|
||||||
|
onAgentClick = { agentId ->
|
||||||
|
navController.navigate("agents/$agentId")
|
||||||
|
},
|
||||||
|
onBack = { navController.popBackStack() },
|
||||||
|
onStartChat = { agentId ->
|
||||||
|
navController.navigate(NEW_CHAT_ROUTE) {
|
||||||
|
popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false }
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onCreateAgent = {
|
||||||
|
navController.navigate(AGENT_EDITOR_CREATE_ROUTE)
|
||||||
|
},
|
||||||
|
onEditAgent = { agentId ->
|
||||||
|
navController.navigate("agents/editor/$agentId")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
filesGraph()
|
||||||
|
settingsGraph(
|
||||||
|
onLogout = {
|
||||||
|
navHostViewModel.logout()
|
||||||
|
navController.navigate(AUTH_GRAPH_ROUTE) {
|
||||||
|
popUpTo(0) { inclusive = true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onNavigateBack = { navController.popBackStack() },
|
||||||
|
onNavigateToArchived = {
|
||||||
|
navController.navigate("conversations/archived") {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onNavigateToSharedLinks = {
|
||||||
|
navController.navigate(SHARED_LINKS_ROUTE) {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onNavigateBackFromSharedLinks = {
|
||||||
|
navController.popBackStack()
|
||||||
|
},
|
||||||
|
onNavigateToPresets = {
|
||||||
|
navController.navigate(PRESET_MANAGER_ROUTE) {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onNavigateBackFromPresets = {
|
||||||
|
navController.popBackStack()
|
||||||
|
},
|
||||||
|
onNavigateToApiKeys = {
|
||||||
|
navController.navigate(API_KEYS_ROUTE) {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onNavigateBackFromApiKeys = {
|
||||||
|
navController.popBackStack()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,449 @@
|
||||||
|
package com.librechat.android.navigation
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.librechat.android.core.data.repository.AuthRepository
|
||||||
|
import com.librechat.android.core.data.repository.BannerRepository
|
||||||
|
import com.librechat.android.core.data.repository.ConfigRepository
|
||||||
|
import com.librechat.android.core.data.repository.ConversationRepository
|
||||||
|
import com.librechat.android.core.data.repository.VersionCheckResult
|
||||||
|
import com.librechat.android.core.common.result.Result
|
||||||
|
import com.librechat.android.core.common.extensions.toInstantOrNull
|
||||||
|
import com.librechat.android.core.common.extensions.toRelativeDateGroup
|
||||||
|
import com.librechat.android.core.model.Banner
|
||||||
|
import com.librechat.android.core.model.Conversation
|
||||||
|
import com.librechat.android.core.model.EModelEndpoint
|
||||||
|
import com.librechat.android.core.network.client.TokenManager
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.temporal.ChronoUnit
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combined UI state for the drawer sidebar. A single emission replaces
|
||||||
|
* 10+ individual StateFlow collections in the parent composable, preventing
|
||||||
|
* unnecessary recomposition of the entire PhoneLayout/TabletLayout tree.
|
||||||
|
*/
|
||||||
|
@Immutable
|
||||||
|
data class DrawerUiState(
|
||||||
|
val groupedConversations: List<Pair<String, List<DrawerConversationDisplayData>>> = emptyList(),
|
||||||
|
val favoriteConversations: List<DrawerConversationDisplayData> = emptyList(),
|
||||||
|
val searchQuery: String = "",
|
||||||
|
val isRefreshing: Boolean = false,
|
||||||
|
val isLoadingMore: Boolean = false,
|
||||||
|
val hasMore: Boolean = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI state for a backend version mismatch warning.
|
||||||
|
*/
|
||||||
|
@Immutable
|
||||||
|
data class VersionMismatchState(
|
||||||
|
val supportedVersion: String,
|
||||||
|
val backendVersion: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class NavHostViewModel @Inject constructor(
|
||||||
|
private val authRepository: AuthRepository,
|
||||||
|
private val bannerRepository: BannerRepository,
|
||||||
|
private val configRepository: ConfigRepository,
|
||||||
|
private val conversationRepository: ConversationRepository,
|
||||||
|
private val tokenManager: TokenManager,
|
||||||
|
private val settingsDataStore: com.librechat.android.core.data.datastore.SettingsDataStore,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _isLoggedIn = MutableStateFlow(false)
|
||||||
|
val isLoggedIn: StateFlow<Boolean> = _isLoggedIn.asStateFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Non-null when a version mismatch has been detected and should be shown to the user.
|
||||||
|
* Set to null after the user dismisses the dialog (for this session or permanently).
|
||||||
|
*/
|
||||||
|
private val _versionMismatch = MutableStateFlow<VersionMismatchState?>(null)
|
||||||
|
val versionMismatch: StateFlow<VersionMismatchState?> = _versionMismatch.asStateFlow()
|
||||||
|
|
||||||
|
private val _recentConversations = MutableStateFlow<List<Conversation>>(emptyList())
|
||||||
|
|
||||||
|
/** Conversations grouped by date (internal, feeds into drawerUiState). */
|
||||||
|
private val _groupedConversations = MutableStateFlow<List<Pair<String, List<Conversation>>>>(emptyList())
|
||||||
|
|
||||||
|
/** The ID of the currently viewed conversation (for active highlight). */
|
||||||
|
private val _activeConversationId = MutableStateFlow<String?>(null)
|
||||||
|
|
||||||
|
/** Drawer search query. */
|
||||||
|
private val _searchQuery = MutableStateFlow("")
|
||||||
|
|
||||||
|
/** Server-synced bookmarked conversation IDs. */
|
||||||
|
private val _favorites = MutableStateFlow<Set<String>>(emptySet())
|
||||||
|
|
||||||
|
/** Bookmarked/favorite conversations (internal, feeds into drawerUiState). */
|
||||||
|
private val _favoriteConversations = MutableStateFlow<List<Conversation>>(emptyList())
|
||||||
|
|
||||||
|
/** Pagination cursor for loading more conversations from the server. */
|
||||||
|
private var nextCursor: String? = null
|
||||||
|
|
||||||
|
/** Whether there are more conversations to load from the server. */
|
||||||
|
private val _hasMore = MutableStateFlow(true)
|
||||||
|
|
||||||
|
/** Whether a load-more request is in progress. */
|
||||||
|
private val _isLoadingMore = MutableStateFlow(false)
|
||||||
|
|
||||||
|
/** Whether a pull-to-refresh is in progress. */
|
||||||
|
private val _isRefreshing = MutableStateFlow(false)
|
||||||
|
|
||||||
|
/** Active banners from the server, filtered to non-expired. */
|
||||||
|
private val _banners = MutableStateFlow<List<Banner>>(emptyList())
|
||||||
|
val banners: StateFlow<List<Banner>> = _banners.asStateFlow()
|
||||||
|
|
||||||
|
/** Banner IDs dismissed by the user during this session. */
|
||||||
|
private val _dismissedBannerIds = MutableStateFlow<Set<String>>(emptySet())
|
||||||
|
val dismissedBannerIds: StateFlow<Set<String>> = _dismissedBannerIds.asStateFlow()
|
||||||
|
|
||||||
|
val sessionExpired: SharedFlow<Unit> = tokenManager.sessionExpiredFlow
|
||||||
|
|
||||||
|
/** Persisted sidebar open/close state for tablet mode.
|
||||||
|
* The initial value is read synchronously so that first composition
|
||||||
|
* sees the real persisted state instead of a hardcoded default,
|
||||||
|
* avoiding a visible open-then-close flicker on cold start. */
|
||||||
|
val tabletSidebarOpen: StateFlow<Boolean> = settingsDataStore.tabletSidebarOpen
|
||||||
|
.stateIn(
|
||||||
|
viewModelScope,
|
||||||
|
SharingStarted.Eagerly,
|
||||||
|
runBlocking { settingsDataStore.tabletSidebarOpen.first() },
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Whether the swipe gesture to open/close the tablet sidebar is enabled. */
|
||||||
|
val tabletSidebarGestureEnabled: StateFlow<Boolean> = settingsDataStore.tabletSidebarGestureEnabled
|
||||||
|
.stateIn(viewModelScope, SharingStarted.Eagerly, true)
|
||||||
|
|
||||||
|
/** Current sidebar mode: Conversations (default) or Settings. */
|
||||||
|
private val _sidebarMode = MutableStateFlow<SidebarMode>(SidebarMode.Conversations)
|
||||||
|
val sidebarMode: StateFlow<SidebarMode> = _sidebarMode.asStateFlow()
|
||||||
|
|
||||||
|
/** Currently selected settings category in the sidebar nav menu. */
|
||||||
|
private val _selectedSettingsCategory = MutableStateFlow<SettingsCategory?>(null)
|
||||||
|
val selectedSettingsCategory: StateFlow<SettingsCategory?> = _selectedSettingsCategory.asStateFlow()
|
||||||
|
|
||||||
|
fun setSidebarMode(mode: SidebarMode) {
|
||||||
|
_sidebarMode.value = mode
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectSettingsCategory(category: SettingsCategory) {
|
||||||
|
_selectedSettingsCategory.value = category
|
||||||
|
}
|
||||||
|
|
||||||
|
private var searchJob: Job? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single combined drawer UI state. All display-data mapping (Conversation →
|
||||||
|
* DrawerConversationDisplayData) happens here in the ViewModel, not in composition.
|
||||||
|
* Collected inside DrawerContent so state changes only recompose the drawer,
|
||||||
|
* not the entire PhoneLayout/TabletLayout.
|
||||||
|
*/
|
||||||
|
val drawerUiState: StateFlow<DrawerUiState> = combine(
|
||||||
|
combine(_groupedConversations, _activeConversationId, _favorites, _favoriteConversations, _searchQuery) { grouped, activeId, favIds, favConvos, query ->
|
||||||
|
DrawerDataSnapshot(grouped, activeId, favIds, favConvos, query)
|
||||||
|
},
|
||||||
|
combine(_isRefreshing, _isLoadingMore, _hasMore) { refreshing, loadingMore, hasMore ->
|
||||||
|
Triple(refreshing, loadingMore, hasMore)
|
||||||
|
},
|
||||||
|
) { data, (refreshing, loadingMore, hasMore) ->
|
||||||
|
DrawerUiState(
|
||||||
|
groupedConversations = data.grouped.map { (group, convos) ->
|
||||||
|
group to convos.map { it.toDrawerDisplayData(data.activeId, data.favIds) }
|
||||||
|
},
|
||||||
|
favoriteConversations = data.favConvos.map { it.toDrawerDisplayData(data.activeId, data.favIds) },
|
||||||
|
searchQuery = data.query,
|
||||||
|
isRefreshing = refreshing,
|
||||||
|
isLoadingMore = loadingMore,
|
||||||
|
hasMore = hasMore,
|
||||||
|
)
|
||||||
|
}.stateIn(viewModelScope, SharingStarted.Eagerly, DrawerUiState())
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val loggedIn = authRepository.isLoggedIn()
|
||||||
|
_isLoggedIn.value = loggedIn
|
||||||
|
// Check backend version on cold start for returning users
|
||||||
|
if (loggedIn) {
|
||||||
|
checkBackendVersion()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
observeConversations()
|
||||||
|
observeBookmarks()
|
||||||
|
fetchBanners()
|
||||||
|
loadInitialConversations()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeConversations() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
conversationRepository.observeConversations().collect { result ->
|
||||||
|
if (result is Result.Success) {
|
||||||
|
_recentConversations.value = result.data
|
||||||
|
// Only regroup if not in search mode
|
||||||
|
if (_searchQuery.value.isBlank()) {
|
||||||
|
_groupedConversations.value = groupConversationsByDate(result.data)
|
||||||
|
}
|
||||||
|
updateFavoriteConversations()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadInitialConversations() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result = conversationRepository.loadNextPage(cursor = null)
|
||||||
|
if (result is Result.Success) {
|
||||||
|
nextCursor = result.data
|
||||||
|
_hasMore.value = result.data != null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadMoreConversations() {
|
||||||
|
if (_isLoadingMore.value || !_hasMore.value) return
|
||||||
|
_isLoadingMore.value = true
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result = conversationRepository.loadNextPage(cursor = nextCursor)
|
||||||
|
if (result is Result.Success) {
|
||||||
|
nextCursor = result.data
|
||||||
|
_hasMore.value = result.data != null
|
||||||
|
} else {
|
||||||
|
Timber.w("Failed to load more conversations")
|
||||||
|
}
|
||||||
|
_isLoadingMore.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onAuthComplete() {
|
||||||
|
_isLoggedIn.value = true
|
||||||
|
loadInitialConversations()
|
||||||
|
fetchBanners()
|
||||||
|
checkBackendVersion()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refreshConversations() {
|
||||||
|
_isRefreshing.value = true
|
||||||
|
viewModelScope.launch {
|
||||||
|
nextCursor = null
|
||||||
|
_hasMore.value = true
|
||||||
|
val result = conversationRepository.loadNextPage(cursor = null)
|
||||||
|
if (result is Result.Success) {
|
||||||
|
nextCursor = result.data
|
||||||
|
_hasMore.value = result.data != null
|
||||||
|
}
|
||||||
|
_isRefreshing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setActiveConversation(conversationId: String?) {
|
||||||
|
_activeConversationId.value = conversationId
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onSearchQueryChanged(query: String) {
|
||||||
|
_searchQuery.value = query
|
||||||
|
searchJob?.cancel()
|
||||||
|
|
||||||
|
if (query.isBlank()) {
|
||||||
|
// Reset to full grouped list from cached conversations
|
||||||
|
_groupedConversations.value = groupConversationsByDate(_recentConversations.value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
searchJob = viewModelScope.launch {
|
||||||
|
delay(300) // debounce
|
||||||
|
val filtered = _recentConversations.value.filter { conversation ->
|
||||||
|
conversation.title?.contains(query, ignoreCase = true) == true
|
||||||
|
}
|
||||||
|
_groupedConversations.value = groupConversationsByDate(filtered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeBookmarks() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
settingsDataStore.bookmarkedConversationIds.collect { bookmarkedIds ->
|
||||||
|
_favorites.value = bookmarkedIds
|
||||||
|
updateFavoriteConversations()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleFavorite(conversationId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
settingsDataStore.toggleBookmark(conversationId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateFavoriteConversations() {
|
||||||
|
val bookmarkedIds = _favorites.value
|
||||||
|
val allConversations = _recentConversations.value
|
||||||
|
_favoriteConversations.value = allConversations.filter { conversation ->
|
||||||
|
conversation.conversationId in bookmarkedIds ||
|
||||||
|
conversation.tags.any {
|
||||||
|
it.equals("favorite", ignoreCase = true) ||
|
||||||
|
it.equals("bookmark", ignoreCase = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetchBanners() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result = bannerRepository.getBanners()
|
||||||
|
if (result is Result.Success) {
|
||||||
|
val now = Instant.now()
|
||||||
|
_banners.value = result.data.filter { banner ->
|
||||||
|
val from = banner.displayFrom?.let { runCatching { Instant.parse(it) }.getOrNull() }
|
||||||
|
val to = banner.displayTo?.let { runCatching { Instant.parse(it) }.getOrNull() }
|
||||||
|
val afterStart = from == null || !now.isBefore(from)
|
||||||
|
val beforeEnd = to == null || now.isBefore(to)
|
||||||
|
afterStart && beforeEnd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setTabletSidebarOpen(open: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
settingsDataStore.setTabletSidebarOpen(open)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissBanner(bannerId: String) {
|
||||||
|
_dismissedBannerIds.value = _dismissedBannerIds.value + bannerId
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs an async, non-blocking version check against the backend.
|
||||||
|
* If the versions are incompatible and the user hasn't dismissed the warning
|
||||||
|
* for this specific backend version, sets [versionMismatch] to trigger the dialog.
|
||||||
|
*/
|
||||||
|
private fun checkBackendVersion() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result = configRepository.checkBackendVersion()
|
||||||
|
if (result is Result.Success) {
|
||||||
|
val checkResult = result.data
|
||||||
|
val detectedVersion = checkResult.backendVersion
|
||||||
|
if (!checkResult.isCompatible && detectedVersion != null) {
|
||||||
|
// Check if the user has already dismissed the warning for this version
|
||||||
|
val dismissedVersion = settingsDataStore.dismissedVersionWarning.first()
|
||||||
|
if (dismissedVersion != detectedVersion) {
|
||||||
|
_versionMismatch.value = VersionMismatchState(
|
||||||
|
supportedVersion = checkResult.supportedVersion,
|
||||||
|
backendVersion = detectedVersion,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Timber.d(
|
||||||
|
"Version mismatch (backend=%s, supported=%s) but user dismissed for this version",
|
||||||
|
detectedVersion,
|
||||||
|
checkResult.supportedVersion,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// On error, silently ignore -- the version check is non-critical
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dismiss the version mismatch dialog for this session only. */
|
||||||
|
fun dismissVersionWarning() {
|
||||||
|
_versionMismatch.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dismiss the version mismatch dialog and suppress it for this backend version permanently. */
|
||||||
|
fun dismissVersionWarningPermanently() {
|
||||||
|
val backendVersion = _versionMismatch.value?.backendVersion
|
||||||
|
_versionMismatch.value = null
|
||||||
|
if (backendVersion != null) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
settingsDataStore.setDismissedVersionWarning(backendVersion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun logout() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
authRepository.logout()
|
||||||
|
_isLoggedIn.value = false
|
||||||
|
_recentConversations.value = emptyList()
|
||||||
|
_groupedConversations.value = emptyList()
|
||||||
|
_activeConversationId.value = null
|
||||||
|
_searchQuery.value = ""
|
||||||
|
_favorites.value = emptySet()
|
||||||
|
_favoriteConversations.value = emptyList()
|
||||||
|
_sidebarMode.value = SidebarMode.Conversations
|
||||||
|
_selectedSettingsCategory.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun groupConversationsByDate(
|
||||||
|
conversations: List<Conversation>,
|
||||||
|
): List<Pair<String, List<Conversation>>> {
|
||||||
|
if (conversations.isEmpty()) return emptyList()
|
||||||
|
return conversations
|
||||||
|
.groupBy { conversation ->
|
||||||
|
conversation.updatedAt
|
||||||
|
?.toInstantOrNull()
|
||||||
|
?.toRelativeDateGroup()
|
||||||
|
?: "Unknown"
|
||||||
|
}
|
||||||
|
.toList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Intermediate holder for the 5-argument combine. */
|
||||||
|
private data class DrawerDataSnapshot(
|
||||||
|
val grouped: List<Pair<String, List<Conversation>>>,
|
||||||
|
val activeId: String?,
|
||||||
|
val favIds: Set<String>,
|
||||||
|
val favConvos: List<Conversation>,
|
||||||
|
val query: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun Conversation.toDrawerDisplayData(
|
||||||
|
activeConversationId: String?,
|
||||||
|
favoriteIds: Set<String>,
|
||||||
|
): DrawerConversationDisplayData {
|
||||||
|
val convId = conversationId ?: ""
|
||||||
|
return DrawerConversationDisplayData(
|
||||||
|
conversationId = convId,
|
||||||
|
title = title ?: "New Chat",
|
||||||
|
model = model,
|
||||||
|
endpoint = endpoint,
|
||||||
|
relativeTime = updatedAt?.toInstantOrNull()?.toRelativeTimeString() ?: "",
|
||||||
|
isActive = convId == activeConversationId,
|
||||||
|
isFavorite = convId in favoriteIds,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Instant.toRelativeTimeString(): String {
|
||||||
|
val now = Instant.now()
|
||||||
|
val minutes = ChronoUnit.MINUTES.between(this, now)
|
||||||
|
val hours = ChronoUnit.HOURS.between(this, now)
|
||||||
|
val days = ChronoUnit.DAYS.between(this, now)
|
||||||
|
|
||||||
|
return when {
|
||||||
|
minutes < 1 -> "Just now"
|
||||||
|
minutes < 60 -> "${minutes}m ago"
|
||||||
|
hours < 24 -> "${hours}h ago"
|
||||||
|
days < 7 -> "${days}d ago"
|
||||||
|
else -> atZone(ZoneId.systemDefault())
|
||||||
|
.format(DateTimeFormatter.ofPattern("MMM d"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,151 @@
|
||||||
|
package com.librechat.android.navigation
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.Chat
|
||||||
|
import androidx.compose.material.icons.filled.AccountCircle
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material.icons.filled.Storage
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.semantics.heading
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.librechat.android.R
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settings sidebar content shown when the sidebar mode is [SidebarMode.Settings].
|
||||||
|
* Displays a simple vertical list of settings categories. Tapping a category
|
||||||
|
* navigates to its sub-page in the main content area.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SettingsSidebarContent(
|
||||||
|
selectedCategory: SettingsCategory?,
|
||||||
|
onBackToConversations: () -> Unit,
|
||||||
|
onCategorySelected: (SettingsCategory) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxHeight()
|
||||||
|
.width(300.dp)
|
||||||
|
.statusBarsPadding()
|
||||||
|
.padding(top = 8.dp),
|
||||||
|
) {
|
||||||
|
// Header with back arrow
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 4.dp, vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
IconButton(onClick = onBackToConversations) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.cd_back_to_conversations),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.settings),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.semantics { heading() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.padding(top = 8.dp))
|
||||||
|
|
||||||
|
// Category list
|
||||||
|
SettingsCategoryItem(
|
||||||
|
icon = Icons.Default.Settings,
|
||||||
|
category = SettingsCategory.GENERAL,
|
||||||
|
isSelected = selectedCategory == SettingsCategory.GENERAL,
|
||||||
|
onClick = { onCategorySelected(SettingsCategory.GENERAL) },
|
||||||
|
)
|
||||||
|
SettingsCategoryItem(
|
||||||
|
icon = Icons.AutoMirrored.Filled.Chat,
|
||||||
|
category = SettingsCategory.CHAT,
|
||||||
|
isSelected = selectedCategory == SettingsCategory.CHAT,
|
||||||
|
onClick = { onCategorySelected(SettingsCategory.CHAT) },
|
||||||
|
)
|
||||||
|
SettingsCategoryItem(
|
||||||
|
icon = Icons.Default.AccountCircle,
|
||||||
|
category = SettingsCategory.ACCOUNT,
|
||||||
|
isSelected = selectedCategory == SettingsCategory.ACCOUNT,
|
||||||
|
onClick = { onCategorySelected(SettingsCategory.ACCOUNT) },
|
||||||
|
)
|
||||||
|
SettingsCategoryItem(
|
||||||
|
icon = Icons.Default.Storage,
|
||||||
|
category = SettingsCategory.DATA,
|
||||||
|
isSelected = selectedCategory == SettingsCategory.DATA,
|
||||||
|
onClick = { onCategorySelected(SettingsCategory.DATA) },
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SettingsCategoryItem(
|
||||||
|
icon: ImageVector,
|
||||||
|
category: SettingsCategory,
|
||||||
|
isSelected: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val backgroundColor = if (isSelected) {
|
||||||
|
MaterialTheme.colorScheme.secondaryContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surface
|
||||||
|
}
|
||||||
|
val contentColor = if (isSelected) {
|
||||||
|
MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 2.dp)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(backgroundColor, RoundedCornerShape(12.dp))
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
tint = contentColor,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
Text(
|
||||||
|
text = category.label,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = contentColor,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
package com.librechat.android.navigation
|
||||||
|
|
||||||
|
sealed interface SidebarMode {
|
||||||
|
data object Conversations : SidebarMode
|
||||||
|
data object Settings : SidebarMode
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settings categories shown as a navigation menu in the sidebar.
|
||||||
|
* Each maps to a dedicated settings sub-page in the main content area.
|
||||||
|
*/
|
||||||
|
enum class SettingsCategory(val label: String) {
|
||||||
|
GENERAL("General"),
|
||||||
|
CHAT("Chat"),
|
||||||
|
ACCOUNT("Account"),
|
||||||
|
DATA("Data"),
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
package com.librechat.android.navigation
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedContent
|
||||||
|
import androidx.compose.animation.slideInHorizontally
|
||||||
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
|
import androidx.compose.animation.togetherWith
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sidebar scaffold that switches between Conversations and Settings modes.
|
||||||
|
* Uses [AnimatedContent] for a smooth horizontal slide transition.
|
||||||
|
*
|
||||||
|
* The Settings footer button now navigates directly to the tabbed settings
|
||||||
|
* screen via [onSettingsClick], rather than switching sidebar mode.
|
||||||
|
* The mode-switching infrastructure is preserved for potential future use.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SidebarScaffold(
|
||||||
|
viewModel: NavHostViewModel,
|
||||||
|
onNewChat: () -> Unit,
|
||||||
|
onConversationClick: (String) -> Unit,
|
||||||
|
onSettingsClick: () -> Unit,
|
||||||
|
onSettingsCategorySelected: (SettingsCategory) -> Unit,
|
||||||
|
onAgentsClick: () -> Unit,
|
||||||
|
onFilesClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val sidebarMode by viewModel.sidebarMode.collectAsStateWithLifecycle()
|
||||||
|
val selectedCategory by viewModel.selectedSettingsCategory.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = sidebarMode,
|
||||||
|
modifier = modifier,
|
||||||
|
transitionSpec = {
|
||||||
|
if (targetState is SidebarMode.Settings) {
|
||||||
|
// Slide in from right when switching to Settings
|
||||||
|
slideInHorizontally { fullWidth -> fullWidth } togetherWith
|
||||||
|
slideOutHorizontally { fullWidth -> -fullWidth }
|
||||||
|
} else {
|
||||||
|
// Slide in from left when switching back to Conversations
|
||||||
|
slideInHorizontally { fullWidth -> -fullWidth } togetherWith
|
||||||
|
slideOutHorizontally { fullWidth -> fullWidth }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = "SidebarModeTransition",
|
||||||
|
) { mode ->
|
||||||
|
when (mode) {
|
||||||
|
is SidebarMode.Conversations -> {
|
||||||
|
DrawerContent(
|
||||||
|
viewModel = viewModel,
|
||||||
|
onNewChat = onNewChat,
|
||||||
|
onConversationClick = onConversationClick,
|
||||||
|
onSettingsClick = onSettingsClick,
|
||||||
|
onAgentsClick = onAgentsClick,
|
||||||
|
onFilesClick = onFilesClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is SidebarMode.Settings -> {
|
||||||
|
SettingsSidebarContent(
|
||||||
|
selectedCategory = selectedCategory,
|
||||||
|
onBackToConversations = {
|
||||||
|
viewModel.setSidebarMode(SidebarMode.Conversations)
|
||||||
|
},
|
||||||
|
onCategorySelected = { category ->
|
||||||
|
viewModel.selectSettingsCategory(category)
|
||||||
|
onSettingsCategorySelected(category)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,442 @@
|
||||||
|
package com.librechat.android.navigation
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import com.librechat.android.MainActivity
|
||||||
|
import timber.log.Timber
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.animation.AnimatedContentTransitionScope
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||||
|
import androidx.compose.animation.core.LinearEasing
|
||||||
|
import androidx.compose.animation.core.Spring
|
||||||
|
import androidx.compose.animation.core.spring
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.scaleIn
|
||||||
|
import androidx.compose.animation.scaleOut
|
||||||
|
import androidx.compose.animation.slideOut
|
||||||
|
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.ui.layout.Layout
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.VerticalDivider
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.input.pointer.util.VelocityTracker
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
|
import androidx.navigation.compose.NavHost
|
||||||
|
import com.librechat.android.core.ui.components.BannerDisplay
|
||||||
|
import com.librechat.android.feature.agents.navigation.AGENT_EDITOR_CREATE_ROUTE
|
||||||
|
import com.librechat.android.feature.agents.navigation.agentsGraph
|
||||||
|
import com.librechat.android.feature.auth.navigation.AUTH_GRAPH_ROUTE
|
||||||
|
import com.librechat.android.feature.auth.navigation.authGraph
|
||||||
|
import com.librechat.android.feature.chat.navigation.CHAT_GRAPH_ROUTE
|
||||||
|
import com.librechat.android.feature.chat.navigation.CHAT_ROUTE
|
||||||
|
import com.librechat.android.feature.chat.navigation.NEW_CHAT_ROUTE
|
||||||
|
import com.librechat.android.feature.chat.navigation.chatGraph
|
||||||
|
import com.librechat.android.feature.chat.navigation.navigateToChat
|
||||||
|
import com.librechat.android.feature.conversations.navigation.conversationsGraph
|
||||||
|
import com.librechat.android.feature.files.navigation.filesGraph
|
||||||
|
import com.librechat.android.feature.settings.navigation.API_KEYS_ROUTE
|
||||||
|
import com.librechat.android.feature.settings.navigation.PRESET_MANAGER_ROUTE
|
||||||
|
import com.librechat.android.feature.settings.navigation.SETTINGS_TABBED_ROUTE
|
||||||
|
import com.librechat.android.feature.settings.navigation.SHARED_LINKS_ROUTE
|
||||||
|
import com.librechat.android.feature.settings.navigation.settingsGraph
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
private val SidebarWidth = 320.dp
|
||||||
|
|
||||||
|
/** Velocity (px/s) above which a fling commits the gesture regardless of position. */
|
||||||
|
private const val FLING_VELOCITY_THRESHOLD = 800f
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TabletLayout(
|
||||||
|
navController: NavHostController,
|
||||||
|
navHostViewModel: NavHostViewModel,
|
||||||
|
startDestination: String,
|
||||||
|
isInAuthFlow: Boolean,
|
||||||
|
deepLinkUri: Uri?,
|
||||||
|
onDeepLinkConsumed: () -> Unit,
|
||||||
|
shareNavigationTrigger: Int = 0,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
// Banner state only -- drawer state is collected inside DrawerContent itself
|
||||||
|
val banners by navHostViewModel.banners.collectAsStateWithLifecycle()
|
||||||
|
val dismissedBannerIds by navHostViewModel.dismissedBannerIds.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
// Persisted sidebar state from DataStore -- single source of truth in the ViewModel.
|
||||||
|
// The ViewModel's StateFlow initial value is read synchronously from DataStore,
|
||||||
|
// so the first composition already has the correct persisted state (no flicker).
|
||||||
|
val isSidebarOpen by navHostViewModel.tabletSidebarOpen.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
// Whether swipe gesture is enabled (from settings)
|
||||||
|
val gestureEnabled by navHostViewModel.tabletSidebarGestureEnabled.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val sidebarWidthPx = with(density) { SidebarWidth.toPx() }
|
||||||
|
|
||||||
|
// Animatable tracks sidebar reveal in pixels: 0 = closed, sidebarWidthPx = open.
|
||||||
|
// During a drag it follows the finger; on release it animates to 0 or sidebarWidthPx.
|
||||||
|
val sidebarOffset = remember { Animatable(if (isSidebarOpen) sidebarWidthPx else 0f) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
// Back press closes sidebar before navigating away
|
||||||
|
BackHandler(enabled = isSidebarOpen) {
|
||||||
|
navHostViewModel.setTabletSidebarOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle deep links
|
||||||
|
LaunchedEffect(deepLinkUri) {
|
||||||
|
deepLinkUri?.let { uri ->
|
||||||
|
if (uri.scheme == "librechat" && uri.host == "conversation") {
|
||||||
|
uri.lastPathSegment?.let { conversationId ->
|
||||||
|
if (MainActivity.CONVERSATION_ID_REGEX.matches(conversationId)) {
|
||||||
|
navController.navigateToChat(conversationId)
|
||||||
|
} else {
|
||||||
|
Timber.w("Ignoring deep link with invalid conversation ID: %s", conversationId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onDeepLinkConsumed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle share intent: if already on a chat screen, let the active ChatViewModel
|
||||||
|
// consume the shared content reactively. Only navigate to new chat when on a
|
||||||
|
// non-chat screen (e.g. settings, agents, files).
|
||||||
|
LaunchedEffect(shareNavigationTrigger) {
|
||||||
|
if (shareNavigationTrigger > 0) {
|
||||||
|
val currentRoute = navController.currentBackStackEntry?.destination?.route
|
||||||
|
val isOnChatScreen = currentRoute == CHAT_ROUTE || currentRoute == NEW_CHAT_ROUTE
|
||||||
|
if (!isOnChatScreen) {
|
||||||
|
navController.navigate(NEW_CHAT_ROUTE) {
|
||||||
|
popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false }
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync: when ViewModel state changes (e.g. hamburger button, back press),
|
||||||
|
// animate the sidebar to match.
|
||||||
|
LaunchedEffect(isSidebarOpen) {
|
||||||
|
val target = if (isSidebarOpen) sidebarWidthPx else 0f
|
||||||
|
if (sidebarOffset.value != target) {
|
||||||
|
sidebarOffset.animateTo(
|
||||||
|
targetValue = target,
|
||||||
|
animationSpec = spring(
|
||||||
|
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||||
|
stiffness = Spring.StiffnessMediumLow,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isInAuthFlow) {
|
||||||
|
val swipeModifier = if (gestureEnabled) {
|
||||||
|
Modifier.pointerInput(Unit) {
|
||||||
|
val velocityTracker = VelocityTracker()
|
||||||
|
detectHorizontalDragGestures(
|
||||||
|
onDragStart = {
|
||||||
|
velocityTracker.resetTracking()
|
||||||
|
},
|
||||||
|
onDragEnd = {
|
||||||
|
val velocity = velocityTracker.calculateVelocity().x
|
||||||
|
val currentOffset = sidebarOffset.value
|
||||||
|
// Decide target: fling velocity wins, otherwise use 50% threshold
|
||||||
|
val shouldOpen = when {
|
||||||
|
velocity > FLING_VELOCITY_THRESHOLD -> true
|
||||||
|
velocity < -FLING_VELOCITY_THRESHOLD -> false
|
||||||
|
else -> currentOffset > sidebarWidthPx * 0.5f
|
||||||
|
}
|
||||||
|
scope.launch {
|
||||||
|
sidebarOffset.animateTo(
|
||||||
|
targetValue = if (shouldOpen) sidebarWidthPx else 0f,
|
||||||
|
animationSpec = spring(
|
||||||
|
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||||
|
stiffness = Spring.StiffnessMediumLow,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
navHostViewModel.setTabletSidebarOpen(shouldOpen)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDragCancel = {
|
||||||
|
// Snap back to the current committed state
|
||||||
|
val target = if (isSidebarOpen) sidebarWidthPx else 0f
|
||||||
|
scope.launch {
|
||||||
|
sidebarOffset.animateTo(
|
||||||
|
targetValue = target,
|
||||||
|
animationSpec = spring(
|
||||||
|
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||||
|
stiffness = Spring.StiffnessMediumLow,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onHorizontalDrag = { change, dragAmount ->
|
||||||
|
change.consume()
|
||||||
|
velocityTracker.addPosition(
|
||||||
|
change.uptimeMillis,
|
||||||
|
change.position,
|
||||||
|
)
|
||||||
|
scope.launch {
|
||||||
|
val newOffset = (sidebarOffset.value + dragAmount)
|
||||||
|
.coerceIn(0f, sidebarWidthPx)
|
||||||
|
sidebarOffset.snapTo(newOffset)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
}
|
||||||
|
|
||||||
|
Layout(
|
||||||
|
content = {
|
||||||
|
// Slot 0: Sidebar -- always 320dp, slides from off-screen left to x=0
|
||||||
|
Row(modifier = Modifier.fillMaxHeight()) {
|
||||||
|
SidebarScaffold(
|
||||||
|
viewModel = navHostViewModel,
|
||||||
|
onNewChat = {
|
||||||
|
// Skip navigation if already on the new chat screen
|
||||||
|
val currentRoute = navController.currentBackStackEntry?.destination?.route
|
||||||
|
if (currentRoute != NEW_CHAT_ROUTE) {
|
||||||
|
navController.navigate(NEW_CHAT_ROUTE) {
|
||||||
|
popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false }
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onConversationClick = { conversationId ->
|
||||||
|
navController.navigateToChat(conversationId)
|
||||||
|
},
|
||||||
|
onSettingsClick = {
|
||||||
|
navController.navigate(SETTINGS_TABBED_ROUTE) {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSettingsCategorySelected = { category ->
|
||||||
|
navController.navigate(category.toRoute()) {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onAgentsClick = {
|
||||||
|
navController.navigate(TopLevelDestination.AGENTS.route) {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onFilesClick = {
|
||||||
|
navController.navigate(TopLevelDestination.FILES.route) {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.width(SidebarWidth)
|
||||||
|
.fillMaxHeight(),
|
||||||
|
)
|
||||||
|
VerticalDivider(
|
||||||
|
color = MaterialTheme.colorScheme.outlineVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Slot 1: Main content -- resizes to fill remaining space
|
||||||
|
MainContent(
|
||||||
|
navController = navController,
|
||||||
|
navHostViewModel = navHostViewModel,
|
||||||
|
startDestination = startDestination,
|
||||||
|
isInAuthFlow = false,
|
||||||
|
banners = banners,
|
||||||
|
dismissedBannerIds = dismissedBannerIds,
|
||||||
|
onToggleDrawer = {
|
||||||
|
navHostViewModel.setTabletSidebarOpen(!isSidebarOpen)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = modifier.fillMaxSize().then(swipeModifier).clipToBounds(),
|
||||||
|
) { measurables, constraints ->
|
||||||
|
val sidebarPx = SidebarWidth.roundToPx()
|
||||||
|
val animatedPx = sidebarOffset.value.toInt()
|
||||||
|
|
||||||
|
// Sidebar: always measured at full 320dp width
|
||||||
|
val sidebarPlaceable = measurables[0].measure(
|
||||||
|
constraints.copy(minWidth = sidebarPx, maxWidth = sidebarPx),
|
||||||
|
)
|
||||||
|
// Main content: resizes to fill screen minus the visible sidebar portion
|
||||||
|
val mainWidth = (constraints.maxWidth - animatedPx).coerceAtLeast(0)
|
||||||
|
val mainPlaceable = measurables[1].measure(
|
||||||
|
constraints.copy(minWidth = mainWidth, maxWidth = mainWidth),
|
||||||
|
)
|
||||||
|
|
||||||
|
layout(constraints.maxWidth, constraints.maxHeight) {
|
||||||
|
// Sidebar slides: -320px (off-screen) -> 0px (fully visible)
|
||||||
|
sidebarPlaceable.placeRelative(animatedPx - sidebarPx, 0)
|
||||||
|
// Main content starts right after the visible sidebar portion
|
||||||
|
mainPlaceable.placeRelative(animatedPx, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Auth flow -- no sidebar, just main content
|
||||||
|
MainContent(
|
||||||
|
navController = navController,
|
||||||
|
navHostViewModel = navHostViewModel,
|
||||||
|
startDestination = startDestination,
|
||||||
|
isInAuthFlow = true,
|
||||||
|
banners = banners,
|
||||||
|
dismissedBannerIds = dismissedBannerIds,
|
||||||
|
onToggleDrawer = {},
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MainContent(
|
||||||
|
navController: NavHostController,
|
||||||
|
navHostViewModel: NavHostViewModel,
|
||||||
|
startDestination: String,
|
||||||
|
isInAuthFlow: Boolean,
|
||||||
|
banners: List<com.librechat.android.core.model.Banner>,
|
||||||
|
dismissedBannerIds: Set<String>,
|
||||||
|
onToggleDrawer: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
if (!isInAuthFlow && banners.isNotEmpty()) {
|
||||||
|
BannerDisplay(
|
||||||
|
banners = banners,
|
||||||
|
dismissedIds = dismissedBannerIds,
|
||||||
|
onDismiss = navHostViewModel::dismissBanner,
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
NavHost(
|
||||||
|
navController = navController,
|
||||||
|
startDestination = startDestination,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
enterTransition = {
|
||||||
|
slideIntoContainer(
|
||||||
|
towards = AnimatedContentTransitionScope.SlideDirection.Start,
|
||||||
|
animationSpec = tween(300),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
exitTransition = {
|
||||||
|
fadeOut(animationSpec = tween(150))
|
||||||
|
},
|
||||||
|
popEnterTransition = {
|
||||||
|
fadeIn(
|
||||||
|
initialAlpha = 0.5f,
|
||||||
|
animationSpec = tween(300, easing = LinearEasing),
|
||||||
|
) + scaleIn(
|
||||||
|
initialScale = 0.92f,
|
||||||
|
animationSpec = tween(300, easing = LinearEasing),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
popExitTransition = {
|
||||||
|
slideOut(
|
||||||
|
targetOffset = { IntOffset((it.width * 0.15f).toInt(), 0) },
|
||||||
|
animationSpec = tween(300, easing = FastOutSlowInEasing),
|
||||||
|
) + scaleOut(
|
||||||
|
targetScale = 0.92f,
|
||||||
|
animationSpec = tween(300, easing = LinearEasing),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
authGraph(
|
||||||
|
navController = navController,
|
||||||
|
onAuthComplete = {
|
||||||
|
navHostViewModel.onAuthComplete()
|
||||||
|
navController.navigate(CHAT_GRAPH_ROUTE) {
|
||||||
|
popUpTo(AUTH_GRAPH_ROUTE) { inclusive = true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
chatGraph(
|
||||||
|
navController = navController,
|
||||||
|
onOpenDrawer = onToggleDrawer,
|
||||||
|
)
|
||||||
|
conversationsGraph(
|
||||||
|
onConversationClick = { conversationId ->
|
||||||
|
navController.navigateToChat(conversationId)
|
||||||
|
},
|
||||||
|
onNavigateToArchived = {
|
||||||
|
navController.navigate("conversations/archived")
|
||||||
|
},
|
||||||
|
onNavigateBackFromArchived = {
|
||||||
|
navController.popBackStack()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
agentsGraph(
|
||||||
|
onAgentClick = { agentId ->
|
||||||
|
navController.navigate("agents/$agentId")
|
||||||
|
},
|
||||||
|
onBack = { navController.popBackStack() },
|
||||||
|
onStartChat = { agentId ->
|
||||||
|
navController.navigate(NEW_CHAT_ROUTE) {
|
||||||
|
popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false }
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onCreateAgent = {
|
||||||
|
navController.navigate(AGENT_EDITOR_CREATE_ROUTE)
|
||||||
|
},
|
||||||
|
onEditAgent = { agentId ->
|
||||||
|
navController.navigate("agents/editor/$agentId")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
filesGraph()
|
||||||
|
settingsGraph(
|
||||||
|
onLogout = {
|
||||||
|
navHostViewModel.logout()
|
||||||
|
navController.navigate(AUTH_GRAPH_ROUTE) {
|
||||||
|
popUpTo(0) { inclusive = true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onNavigateBack = { navController.popBackStack() },
|
||||||
|
onNavigateToArchived = {
|
||||||
|
navController.navigate("conversations/archived") {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onNavigateToSharedLinks = {
|
||||||
|
navController.navigate(SHARED_LINKS_ROUTE) {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onNavigateBackFromSharedLinks = {
|
||||||
|
navController.popBackStack()
|
||||||
|
},
|
||||||
|
onNavigateToPresets = {
|
||||||
|
navController.navigate(PRESET_MANAGER_ROUTE) {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onNavigateBackFromPresets = {
|
||||||
|
navController.popBackStack()
|
||||||
|
},
|
||||||
|
onNavigateToApiKeys = {
|
||||||
|
navController.navigate(API_KEYS_ROUTE) {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onNavigateBackFromApiKeys = {
|
||||||
|
navController.popBackStack()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
package com.librechat.android.navigation
|
||||||
|
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Chat
|
||||||
|
import androidx.compose.material.icons.filled.Folder
|
||||||
|
import androidx.compose.material.icons.filled.Forum
|
||||||
|
import androidx.compose.material.icons.filled.SmartToy
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destinations accessible from the navigation drawer footer.
|
||||||
|
* The primary navigation is now sidebar-first (drawer with conversation list),
|
||||||
|
* matching the web frontend pattern. Bottom nav has been removed entirely.
|
||||||
|
*
|
||||||
|
* Conversations are integrated into the drawer body (not a separate destination).
|
||||||
|
* Agents and Files are accessible via drawer footer links.
|
||||||
|
* Settings are accessed through the sidebar settings nav menu (see [SettingsCategory]).
|
||||||
|
*/
|
||||||
|
enum class TopLevelDestination(
|
||||||
|
val route: String,
|
||||||
|
val icon: ImageVector,
|
||||||
|
val label: String,
|
||||||
|
) {
|
||||||
|
CHAT("chat_graph", Icons.Default.Chat, "Chat"),
|
||||||
|
CONVERSATIONS("conversations", Icons.Default.Forum, "Conversations"),
|
||||||
|
AGENTS("agents", Icons.Default.SmartToy, "Agents"),
|
||||||
|
FILES("files", Icons.Default.Folder, "Files"),
|
||||||
|
}
|
||||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 9 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 9 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
10
app/src/main/res/values-night/themes.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="Theme.LibreChat" parent="android:Theme.Material.NoActionBar">
|
||||||
|
<item name="android:windowBackground">@color/splash_background</item>
|
||||||
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<color name="splash_background">#0D0D0D</color>
|
||||||
|
</resources>
|
||||||
4
app/src/main/res/values/ic_launcher_background.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#1A0A3E</color>
|
||||||
|
</resources>
|
||||||
31
app/src/main/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">LibreChat</string>
|
||||||
|
|
||||||
|
<!-- Connectivity -->
|
||||||
|
<string name="no_connection">No internet connection</string>
|
||||||
|
|
||||||
|
<!-- Drawer -->
|
||||||
|
<string name="new_chat">New chat</string>
|
||||||
|
<string name="favorites">Favorites</string>
|
||||||
|
<string name="no_conversations_found">No conversations found</string>
|
||||||
|
<string name="remove_bookmark">Remove bookmark</string>
|
||||||
|
<string name="bookmark">Bookmark</string>
|
||||||
|
<string name="agents">Agents</string>
|
||||||
|
<string name="files">Files</string>
|
||||||
|
<string name="settings">Settings</string>
|
||||||
|
|
||||||
|
<!-- Content descriptions -->
|
||||||
|
<string name="cd_search">Search</string>
|
||||||
|
<string name="cd_clear_search">Clear search</string>
|
||||||
|
<string name="cd_back_to_conversations">Back to conversations</string>
|
||||||
|
|
||||||
|
<!-- Search -->
|
||||||
|
<string name="search_conversations_placeholder">Search conversations\u2026</string>
|
||||||
|
|
||||||
|
<!-- Version mismatch dialog -->
|
||||||
|
<string name="version_mismatch_title">Backend Version Mismatch</string>
|
||||||
|
<string name="version_mismatch_message">This app was built for LibreChat v%1$s, but your server is running v%2$s. Some features may not work correctly.</string>
|
||||||
|
<string name="dismiss">Dismiss</string>
|
||||||
|
<string name="dont_warn_again">Don\'t warn again</string>
|
||||||
|
</resources>
|
||||||
10
app/src/main/res/values/themes.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="Theme.LibreChat" parent="android:Theme.Material.Light.NoActionBar">
|
||||||
|
<item name="android:windowBackground">@color/splash_background</item>
|
||||||
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<color name="splash_background">#FFFFFF</color>
|
||||||
|
</resources>
|
||||||
6
app/src/main/res/xml/file_provider_paths.xml
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths>
|
||||||
|
<cache-path name="artifacts" path="artifacts/" />
|
||||||
|
<cache-path name="shared_images" path="shared_images/" />
|
||||||
|
<cache-path name="camera_photos" path="camera_photos/" />
|
||||||
|
</paths>
|
||||||
18
app/src/main/res/xml/network_security_config.xml
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<network-security-config>
|
||||||
|
<!--
|
||||||
|
Cleartext (HTTP) traffic is intentionally permitted because LibreChat is a
|
||||||
|
self-hosted server that users may run on their local network over plain HTTP
|
||||||
|
(e.g. http://192.168.x.x:3080 or http://localhost:3080).
|
||||||
|
|
||||||
|
Android's network_security_config is static and cannot be toggled at runtime,
|
||||||
|
so we cannot restrict cleartext to specific domains since the server URL is
|
||||||
|
user-configured. Instead, the app displays an HTTP warning dialog at server
|
||||||
|
setup time (ServerUrlScreen) when the user enters an HTTP URL, informing them
|
||||||
|
that their connection will not be encrypted. The user must explicitly
|
||||||
|
acknowledge this risk before proceeding.
|
||||||
|
|
||||||
|
HTTPS is always preferred and no warning is shown for HTTPS URLs.
|
||||||
|
-->
|
||||||
|
<base-config cleartextTrafficPermitted="true" />
|
||||||
|
</network-security-config>
|
||||||
37
build-logic/CLAUDE.md
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
# Build Logic
|
||||||
|
|
||||||
|
Convention plugins that apply consistent Gradle configuration across all modules.
|
||||||
|
|
||||||
|
## Convention Plugins (7 total)
|
||||||
|
|
||||||
|
| Plugin ID | Class | What it does |
|
||||||
|
|-----------|-------|-------------|
|
||||||
|
| `librechat.android.application` | `AndroidApplicationConventionPlugin` | AGP application plugin, compileSdk 35, minSdk 26, JDK 17 |
|
||||||
|
| `librechat.android.library` | `AndroidLibraryConventionPlugin` | AGP library plugin, same SDK/JDK config |
|
||||||
|
| `librechat.android.compose` | `AndroidComposeConventionPlugin` | Compose compiler, Compose BOM, Material 3 |
|
||||||
|
| `librechat.android.hilt` | `AndroidHiltConventionPlugin` | Hilt + KSP annotation processing |
|
||||||
|
| `librechat.android.feature` | `AndroidFeatureConventionPlugin` | Auto-applies: library + compose + hilt + serialization |
|
||||||
|
| `librechat.android.room` | `AndroidRoomConventionPlugin` | Room + KSP, schema export config |
|
||||||
|
| `librechat.kotlin.serialization` | `KotlinSerializationConventionPlugin` | Kotlinx Serialization plugin + JSON dependency |
|
||||||
|
|
||||||
|
## Critical: apply() Signature
|
||||||
|
|
||||||
|
Convention plugin `apply()` MUST use:
|
||||||
|
```kotlin
|
||||||
|
override fun apply(target: Project) {
|
||||||
|
with(target) { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Do NOT use `override fun apply(target: Project) = with(target) { ... }` -- that returns
|
||||||
|
the `with` result instead of `Unit`, which causes a Gradle error.
|
||||||
|
|
||||||
|
## Version Catalog
|
||||||
|
|
||||||
|
All dependency versions live in `gradle/libs.versions.toml`. Never hardcode versions in
|
||||||
|
build.gradle.kts files. Use `libs.` accessors (e.g., `libs.hilt.android`, `libs.ktor.client.core`).
|
||||||
|
|
||||||
|
## Feature Module Convention
|
||||||
|
|
||||||
|
Feature modules use `id("librechat.android.feature")` which auto-applies library, compose,
|
||||||
|
hilt, and serialization. They only need to add feature-specific dependencies.
|
||||||
|
Feature modules depend on `:core:*` only, never on other feature modules.
|
||||||
44
build-logic/convention/build.gradle.kts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
plugins {
|
||||||
|
`kotlin-dsl`
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
compileOnly(libs.android.gradlePlugin)
|
||||||
|
compileOnly(libs.kotlin.gradlePlugin)
|
||||||
|
compileOnly(libs.compose.gradlePlugin)
|
||||||
|
compileOnly(libs.ksp.gradlePlugin)
|
||||||
|
compileOnly(libs.room.gradlePlugin)
|
||||||
|
}
|
||||||
|
|
||||||
|
gradlePlugin {
|
||||||
|
plugins {
|
||||||
|
register("androidApplication") {
|
||||||
|
id = "librechat.android.application"
|
||||||
|
implementationClass = "AndroidApplicationConventionPlugin"
|
||||||
|
}
|
||||||
|
register("androidLibrary") {
|
||||||
|
id = "librechat.android.library"
|
||||||
|
implementationClass = "AndroidLibraryConventionPlugin"
|
||||||
|
}
|
||||||
|
register("androidCompose") {
|
||||||
|
id = "librechat.android.compose"
|
||||||
|
implementationClass = "AndroidComposeConventionPlugin"
|
||||||
|
}
|
||||||
|
register("androidHilt") {
|
||||||
|
id = "librechat.android.hilt"
|
||||||
|
implementationClass = "AndroidHiltConventionPlugin"
|
||||||
|
}
|
||||||
|
register("androidFeature") {
|
||||||
|
id = "librechat.android.feature"
|
||||||
|
implementationClass = "AndroidFeatureConventionPlugin"
|
||||||
|
}
|
||||||
|
register("androidRoom") {
|
||||||
|
id = "librechat.android.room"
|
||||||
|
implementationClass = "AndroidRoomConventionPlugin"
|
||||||
|
}
|
||||||
|
register("kotlinSerialization") {
|
||||||
|
id = "librechat.kotlin.serialization"
|
||||||
|
implementationClass = "KotlinSerializationConventionPlugin"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
3
build-logic/convention/gradle.properties
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# build-logic is a standalone project; mirror root settings
|
||||||
|
org.gradle.parallel=true
|
||||||
|
org.gradle.caching=true
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
import com.android.build.api.dsl.ApplicationExtension
|
||||||
|
import org.gradle.api.Plugin
|
||||||
|
import org.gradle.api.Project
|
||||||
|
import org.gradle.kotlin.dsl.configure
|
||||||
|
|
||||||
|
class AndroidApplicationConventionPlugin : Plugin<Project> {
|
||||||
|
override fun apply(target: Project) {
|
||||||
|
with(target) {
|
||||||
|
pluginManager.apply("com.android.application")
|
||||||
|
pluginManager.apply("org.jetbrains.kotlin.android")
|
||||||
|
|
||||||
|
extensions.configure<ApplicationExtension> {
|
||||||
|
compileSdk = 35
|
||||||
|
defaultConfig {
|
||||||
|
minSdk = 26
|
||||||
|
targetSdk = 35
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "0.1.0"
|
||||||
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
}
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = org.gradle.api.JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = org.gradle.api.JavaVersion.VERSION_17
|
||||||
|
isCoreLibraryDesugaringEnabled = true
|
||||||
|
}
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = true
|
||||||
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies.add("coreLibraryDesugaring", libs.findLibrary("desugar-jdk").get())
|
||||||
|
dependencies.add("testImplementation", libs.findBundle("testing").get())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val Project.libs
|
||||||
|
get() = extensions.getByType(org.gradle.api.artifacts.VersionCatalogsExtension::class.java)
|
||||||
|
.named("libs")
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
import org.gradle.api.Plugin
|
||||||
|
import org.gradle.api.Project
|
||||||
|
import org.gradle.kotlin.dsl.dependencies
|
||||||
|
import org.gradle.kotlin.dsl.withType
|
||||||
|
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
||||||
|
|
||||||
|
class AndroidComposeConventionPlugin : Plugin<Project> {
|
||||||
|
override fun apply(target: Project) {
|
||||||
|
with(target) {
|
||||||
|
pluginManager.apply("org.jetbrains.kotlin.plugin.compose")
|
||||||
|
|
||||||
|
// Stability configuration — marks core model classes as @Stable so Compose
|
||||||
|
// can skip recomposition when parameters haven't changed.
|
||||||
|
val stabilityConfig = rootProject.file("compose-stability.conf")
|
||||||
|
if (stabilityConfig.exists()) {
|
||||||
|
tasks.withType<KotlinCompile>().configureEach {
|
||||||
|
compilerOptions {
|
||||||
|
freeCompilerArgs.addAll(
|
||||||
|
"-P", "plugin:androidx.compose.compiler.plugins.kotlin:stabilityConfigurationPath=${stabilityConfig.absolutePath}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compose compiler metrics/reports (opt-in via -PenableComposeCompilerReports=true)
|
||||||
|
if (providers.gradleProperty("enableComposeCompilerReports").orNull == "true") {
|
||||||
|
val reportsDir = layout.buildDirectory.dir("compose_metrics").get().asFile.absolutePath
|
||||||
|
tasks.withType<KotlinCompile>().configureEach {
|
||||||
|
compilerOptions {
|
||||||
|
freeCompilerArgs.addAll(
|
||||||
|
"-P", "plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=$reportsDir",
|
||||||
|
"-P", "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=$reportsDir",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extensions.findByType(com.android.build.gradle.LibraryExtension::class.java)?.apply {
|
||||||
|
buildFeatures.compose = true
|
||||||
|
}
|
||||||
|
extensions.findByType(com.android.build.api.dsl.ApplicationExtension::class.java)?.apply {
|
||||||
|
buildFeatures.compose = true
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
val bom = libs.findLibrary("compose-bom").get()
|
||||||
|
add("implementation", platform(bom))
|
||||||
|
add("implementation", libs.findBundle("compose").get())
|
||||||
|
add("debugImplementation", libs.findLibrary("compose-ui-tooling").get())
|
||||||
|
add("androidTestImplementation", platform(bom))
|
||||||
|
add("androidTestImplementation", libs.findLibrary("compose-ui-test").get())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val Project.libs
|
||||||
|
get() = extensions.getByType(org.gradle.api.artifacts.VersionCatalogsExtension::class.java)
|
||||||
|
.named("libs")
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
import org.gradle.api.Plugin
|
||||||
|
import org.gradle.api.Project
|
||||||
|
import org.gradle.kotlin.dsl.dependencies
|
||||||
|
|
||||||
|
class AndroidFeatureConventionPlugin : Plugin<Project> {
|
||||||
|
override fun apply(target: Project) {
|
||||||
|
with(target) {
|
||||||
|
pluginManager.apply("librechat.android.library")
|
||||||
|
pluginManager.apply("librechat.android.compose")
|
||||||
|
pluginManager.apply("librechat.android.hilt")
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
add("implementation", project(":core:ui"))
|
||||||
|
add("implementation", project(":core:data"))
|
||||||
|
add("implementation", project(":core:model"))
|
||||||
|
add("implementation", project(":core:common"))
|
||||||
|
add("implementation", libs.findLibrary("hilt-navigation-compose").get())
|
||||||
|
add("implementation", libs.findLibrary("navigation-compose").get())
|
||||||
|
add("implementation", libs.findBundle("lifecycle").get())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val Project.libs
|
||||||
|
get() = extensions.getByType(org.gradle.api.artifacts.VersionCatalogsExtension::class.java)
|
||||||
|
.named("libs")
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
import org.gradle.api.Plugin
|
||||||
|
import org.gradle.api.Project
|
||||||
|
import org.gradle.kotlin.dsl.dependencies
|
||||||
|
|
||||||
|
class AndroidHiltConventionPlugin : Plugin<Project> {
|
||||||
|
override fun apply(target: Project) {
|
||||||
|
with(target) {
|
||||||
|
pluginManager.apply("com.google.dagger.hilt.android")
|
||||||
|
pluginManager.apply("com.google.devtools.ksp")
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
add("implementation", libs.findLibrary("hilt-android").get())
|
||||||
|
add("ksp", libs.findLibrary("hilt-compiler").get())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val Project.libs
|
||||||
|
get() = extensions.getByType(org.gradle.api.artifacts.VersionCatalogsExtension::class.java)
|
||||||
|
.named("libs")
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
import com.android.build.gradle.LibraryExtension
|
||||||
|
import org.gradle.api.Plugin
|
||||||
|
import org.gradle.api.Project
|
||||||
|
import org.gradle.kotlin.dsl.configure
|
||||||
|
|
||||||
|
class AndroidLibraryConventionPlugin : Plugin<Project> {
|
||||||
|
override fun apply(target: Project) {
|
||||||
|
with(target) {
|
||||||
|
pluginManager.apply("com.android.library")
|
||||||
|
pluginManager.apply("org.jetbrains.kotlin.android")
|
||||||
|
|
||||||
|
extensions.configure<LibraryExtension> {
|
||||||
|
compileSdk = 35
|
||||||
|
defaultConfig {
|
||||||
|
minSdk = 26
|
||||||
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
}
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = org.gradle.api.JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = org.gradle.api.JavaVersion.VERSION_17
|
||||||
|
isCoreLibraryDesugaringEnabled = true
|
||||||
|
}
|
||||||
|
testOptions {
|
||||||
|
unitTests.isReturnDefaultValues = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies.add("coreLibraryDesugaring", libs.findLibrary("desugar-jdk").get())
|
||||||
|
dependencies.add("testImplementation", libs.findBundle("testing").get())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val Project.libs
|
||||||
|
get() = extensions.getByType(org.gradle.api.artifacts.VersionCatalogsExtension::class.java)
|
||||||
|
.named("libs")
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
import androidx.room.gradle.RoomExtension
|
||||||
|
import org.gradle.api.Plugin
|
||||||
|
import org.gradle.api.Project
|
||||||
|
import org.gradle.kotlin.dsl.configure
|
||||||
|
import org.gradle.kotlin.dsl.dependencies
|
||||||
|
|
||||||
|
class AndroidRoomConventionPlugin : Plugin<Project> {
|
||||||
|
override fun apply(target: Project) {
|
||||||
|
with(target) {
|
||||||
|
pluginManager.apply("androidx.room")
|
||||||
|
pluginManager.apply("com.google.devtools.ksp")
|
||||||
|
|
||||||
|
extensions.configure<RoomExtension> {
|
||||||
|
schemaDirectory("$projectDir/schemas")
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
add("implementation", libs.findBundle("room").get())
|
||||||
|
add("ksp", libs.findLibrary("room-compiler").get())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val Project.libs
|
||||||
|
get() = extensions.getByType(org.gradle.api.artifacts.VersionCatalogsExtension::class.java)
|
||||||
|
.named("libs")
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
import org.gradle.api.Plugin
|
||||||
|
import org.gradle.api.Project
|
||||||
|
import org.gradle.kotlin.dsl.dependencies
|
||||||
|
|
||||||
|
class KotlinSerializationConventionPlugin : Plugin<Project> {
|
||||||
|
override fun apply(target: Project) {
|
||||||
|
with(target) {
|
||||||
|
pluginManager.apply("org.jetbrains.kotlin.plugin.serialization")
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
add("implementation", libs.findLibrary("kotlinx-serialization-json").get())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val Project.libs
|
||||||
|
get() = extensions.getByType(org.gradle.api.artifacts.VersionCatalogsExtension::class.java)
|
||||||
|
.named("libs")
|
||||||
2
build-logic/gradle.properties
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
org.gradle.parallel=true
|
||||||
|
org.gradle.caching=true
|
||||||
15
build-logic/settings.gradle.kts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
versionCatalogs {
|
||||||
|
create("libs") {
|
||||||
|
from(files("../gradle/libs.versions.toml"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "build-logic"
|
||||||
|
include(":convention")
|
||||||
10
build.gradle.kts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application) apply false
|
||||||
|
alias(libs.plugins.android.library) apply false
|
||||||
|
alias(libs.plugins.kotlin.android) apply false
|
||||||
|
alias(libs.plugins.kotlin.compose) apply false
|
||||||
|
alias(libs.plugins.kotlin.serialization) apply false
|
||||||
|
alias(libs.plugins.ksp) apply false
|
||||||
|
alias(libs.plugins.hilt) apply false
|
||||||
|
alias(libs.plugins.room) apply false
|
||||||
|
}
|
||||||
44
compose-stability.conf
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
// Compose Compiler Stability Configuration
|
||||||
|
// Classes listed here are treated as @Stable by the Compose compiler,
|
||||||
|
// enabling skip optimizations for composable parameters of these types.
|
||||||
|
// See: https://developer.android.com/develop/ui/compose/performance/stability/fix#configuration-file
|
||||||
|
|
||||||
|
// Core model classes (all are data classes with val-only fields, never mutated after creation)
|
||||||
|
com.librechat.android.core.model.Message
|
||||||
|
com.librechat.android.core.model.Conversation
|
||||||
|
com.librechat.android.core.model.MessageContentPart
|
||||||
|
com.librechat.android.core.model.ToolCall
|
||||||
|
com.librechat.android.core.model.ToolCallFunction
|
||||||
|
com.librechat.android.core.model.FileObject
|
||||||
|
com.librechat.android.core.model.FileReference
|
||||||
|
com.librechat.android.core.model.Attachment
|
||||||
|
com.librechat.android.core.model.Feedback
|
||||||
|
com.librechat.android.core.model.EndpointConfig
|
||||||
|
com.librechat.android.core.model.Preset
|
||||||
|
com.librechat.android.core.model.ConversationTag
|
||||||
|
com.librechat.android.core.model.Agent
|
||||||
|
com.librechat.android.core.model.PromptGroup
|
||||||
|
com.librechat.android.core.model.Prompt
|
||||||
|
com.librechat.android.core.model.mcp.McpServer
|
||||||
|
com.librechat.android.core.model.mcp.McpTool
|
||||||
|
com.librechat.android.core.model.mcp.McpApiKeyConfig
|
||||||
|
com.librechat.android.core.model.mcp.McpOAuthConfig
|
||||||
|
com.librechat.android.core.model.mcp.McpConnectionStatusResponse
|
||||||
|
com.librechat.android.core.model.mcp.McpServerStatus
|
||||||
|
com.librechat.android.core.model.Banner
|
||||||
|
com.librechat.android.core.model.speech.TtsVoice
|
||||||
|
com.librechat.android.core.model.ApiKey
|
||||||
|
com.librechat.android.core.model.SharedLink
|
||||||
|
com.librechat.android.core.model.AgentTool
|
||||||
|
com.librechat.android.core.model.AgentAction
|
||||||
|
com.librechat.android.core.model.AgentCategory
|
||||||
|
com.librechat.android.core.model.UserFavorite
|
||||||
|
com.librechat.android.core.model.Memory
|
||||||
|
com.librechat.android.core.model.StartupConfig
|
||||||
|
com.librechat.android.core.model.ModelSpecs
|
||||||
|
com.librechat.android.core.model.InterfaceConfig
|
||||||
|
|
||||||
|
// Kotlin stdlib collections (we only use immutable instances in practice)
|
||||||
|
kotlin.collections.List
|
||||||
|
kotlin.collections.Map
|
||||||
|
kotlin.collections.Set
|
||||||
41
core/common/CLAUDE.md
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
# core:common
|
||||||
|
|
||||||
|
Pure Kotlin utilities shared by all modules. This is the lowest layer -- no other `:core:*` module is a dependency.
|
||||||
|
|
||||||
|
## What This Module Provides
|
||||||
|
|
||||||
|
- **Result sealed class** (`result/Result.kt`): `Success<T>`, `Error(exception, message)`, `Loading`. Used by repositories and ViewModels to propagate outcomes.
|
||||||
|
- **Dispatcher DI** (`di/DispatcherModule.kt`): `@Dispatcher(IO)`, `@Dispatcher(Default)`, `@Dispatcher(Main)` qualifiers for Hilt injection. Always inject dispatchers -- never hardcode `Dispatchers.IO`.
|
||||||
|
- **CoroutineScope DI** (`di/CoroutineScopeModule.kt`): `@ApplicationScope` for work that outlives ViewModels.
|
||||||
|
- **Extensions** (`extensions/`): `StringExt`, `DateExt`, `FlowExt` (includes `retryWithBackoff`, `throttleFirst`).
|
||||||
|
- **ConnectivityObserver**: Wraps Android `ConnectivityManager.NetworkCallback` to detect network changes. Used by SSE reconnection logic.
|
||||||
|
|
||||||
|
## safeApiCall Pattern
|
||||||
|
|
||||||
|
All repository implementations use this to wrap network calls:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
suspend fun <T> safeApiCall(block: suspend () -> T): Result<T> =
|
||||||
|
try {
|
||||||
|
Result.Success(block())
|
||||||
|
} catch (e: ClientRequestException) { // 4xx
|
||||||
|
Result.Error(e, parseErrorMessage(e.response.bodyAsText()))
|
||||||
|
} catch (e: ServerResponseException) { // 5xx
|
||||||
|
Result.Error(e, "Server error. Please try again.")
|
||||||
|
} catch (e: HttpRequestTimeoutException) {
|
||||||
|
Result.Error(e, "Request timed out.")
|
||||||
|
} catch (e: IOException) {
|
||||||
|
Result.Error(e, "Network unavailable. Check your connection.")
|
||||||
|
} catch (e: SerializationException) {
|
||||||
|
Result.Error(e, "Unexpected response format.")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This lives here (not in `:core:network`) because repositories in `:core:data` call it.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- **Pure Kotlin preferred.** Minimal Android dependencies (only what ConnectivityObserver and Hilt require).
|
||||||
|
- **No network or data dependencies.** This module must not depend on `:core:network`, `:core:data`, or `:core:model`.
|
||||||
|
- Dependencies: `coroutines-core`, `coroutines-android`, Hilt.
|
||||||
|
- Convention plugin: `librechat.android.library` + `librechat.android.hilt`.
|
||||||
13
core/common/build.gradle.kts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
plugins {
|
||||||
|
id("librechat.android.library")
|
||||||
|
id("librechat.android.hilt")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.librechat.android.core.common"
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(libs.coroutines.core)
|
||||||
|
implementation(libs.coroutines.android)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
package com.librechat.android.core.common
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backend version compatibility constants and comparison utilities.
|
||||||
|
*
|
||||||
|
* The LibreChat backend does not currently expose a version endpoint.
|
||||||
|
* The version is determined by:
|
||||||
|
* 1. A `version` field in the `/api/config` response (if the backend adds one in the future)
|
||||||
|
* 2. Parsing the `customFooter` field for a `LibreChat vX.Y.Z` pattern
|
||||||
|
* (when customFooter is null, the web frontend displays the default version from Constants.VERSION)
|
||||||
|
*
|
||||||
|
* If the version cannot be determined, the check is silently skipped.
|
||||||
|
*/
|
||||||
|
object BackendVersion {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The LibreChat backend version this app was built and tested against.
|
||||||
|
* Matches the VERSION constant from the official LibreChat repo's
|
||||||
|
* `packages/data-provider/src/config.ts` and `package.json`.
|
||||||
|
*/
|
||||||
|
const val SUPPORTED_BACKEND_VERSION = "0.8.2"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a parsed semantic version (major.minor.patch).
|
||||||
|
*/
|
||||||
|
data class SemanticVersion(
|
||||||
|
val major: Int,
|
||||||
|
val minor: Int,
|
||||||
|
val patch: Int,
|
||||||
|
) {
|
||||||
|
override fun toString(): String = "$major.$minor.$patch"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a version string like "0.8.2", "v0.8.2", "0.8", or "v0.8" into a [SemanticVersion].
|
||||||
|
* Returns null if the string cannot be parsed.
|
||||||
|
*/
|
||||||
|
fun parse(version: String): SemanticVersion? {
|
||||||
|
val cleaned = version.trimStart('v', 'V').trim()
|
||||||
|
val parts = cleaned.split('.')
|
||||||
|
if (parts.size < 2) return null
|
||||||
|
val major = parts[0].toIntOrNull() ?: return null
|
||||||
|
val minor = parts[1].toIntOrNull() ?: return null
|
||||||
|
val patch = if (parts.size >= 3) parts[2].toIntOrNull() ?: 0 else 0
|
||||||
|
return SemanticVersion(major, minor, patch)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether two versions are compatible.
|
||||||
|
* A mismatch in major or minor version is considered incompatible.
|
||||||
|
* Patch differences are ignored (considered compatible).
|
||||||
|
*
|
||||||
|
* @return true if the versions are compatible (same major and minor), false otherwise.
|
||||||
|
* Returns true if either version cannot be parsed (fail-open).
|
||||||
|
*/
|
||||||
|
fun isCompatible(supported: String, actual: String): Boolean {
|
||||||
|
val supportedVersion = parse(supported) ?: return true
|
||||||
|
val actualVersion = parse(actual) ?: return true
|
||||||
|
return supportedVersion.major == actualVersion.major &&
|
||||||
|
supportedVersion.minor == actualVersion.minor
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts a version string from a LibreChat customFooter value.
|
||||||
|
* The default footer format is: `[LibreChat vX.Y.Z](https://librechat.ai) - ...`
|
||||||
|
* Also handles variations like `LibreChat v0.8.2` without markdown links.
|
||||||
|
*
|
||||||
|
* @return The extracted version string (without 'v' prefix), or null if not found.
|
||||||
|
*/
|
||||||
|
fun extractVersionFromFooter(footer: String?): String? {
|
||||||
|
if (footer == null) return null
|
||||||
|
val regex = Regex("""LibreChat\s+v?(\d+\.\d+(?:\.\d+)?)""", RegexOption.IGNORE_CASE)
|
||||||
|
return regex.find(footer)?.groupValues?.get(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
package com.librechat.android.core.common
|
||||||
|
|
||||||
|
object EndpointConstants {
|
||||||
|
const val AGENTS = "agents"
|
||||||
|
}
|
||||||
|
|
||||||
|
object ToolConstants {
|
||||||
|
const val WEB_SEARCH = "web_search"
|
||||||
|
const val CODE_INTERPRETER = "code_interpreter"
|
||||||
|
const val FILE_SEARCH = "file_search"
|
||||||
|
const val EXECUTE_CODE = "execute_code"
|
||||||
|
}
|
||||||
|
|
||||||
|
object ChatLayoutConstants {
|
||||||
|
const val THREAD = "thread"
|
||||||
|
const val TWO_SIDED = "two_sided"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
package com.librechat.android.core.common.di
|
||||||
|
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import javax.inject.Qualifier
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Qualifier
|
||||||
|
@Retention(AnnotationRetention.RUNTIME)
|
||||||
|
annotation class ApplicationScope
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object CoroutineScopeModule {
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
@ApplicationScope
|
||||||
|
fun provideApplicationScope(
|
||||||
|
@Dispatcher(LibreChatDispatchers.Default) dispatcher: CoroutineDispatcher,
|
||||||
|
): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
package com.librechat.android.core.common.di
|
||||||
|
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import javax.inject.Qualifier
|
||||||
|
|
||||||
|
@Qualifier
|
||||||
|
@Retention(AnnotationRetention.RUNTIME)
|
||||||
|
annotation class Dispatcher(val dispatcher: LibreChatDispatchers)
|
||||||
|
|
||||||
|
enum class LibreChatDispatchers {
|
||||||
|
IO,
|
||||||
|
Default,
|
||||||
|
Main,
|
||||||
|
}
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object DispatcherModule {
|
||||||
|
@Provides
|
||||||
|
@Dispatcher(LibreChatDispatchers.IO)
|
||||||
|
fun provideIODispatcher(): CoroutineDispatcher = Dispatchers.IO
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Dispatcher(LibreChatDispatchers.Default)
|
||||||
|
fun provideDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Dispatcher(LibreChatDispatchers.Main)
|
||||||
|
fun provideMainDispatcher(): CoroutineDispatcher = Dispatchers.Main
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
package com.librechat.android.core.common.extensions
|
||||||
|
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.temporal.ChronoUnit
|
||||||
|
|
||||||
|
fun String.toInstantOrNull(): Instant? =
|
||||||
|
try { Instant.parse(this) } catch (_: Exception) { null }
|
||||||
|
|
||||||
|
fun Instant.toRelativeDateGroup(): String {
|
||||||
|
val today = LocalDate.now()
|
||||||
|
val date = atZone(ZoneId.systemDefault()).toLocalDate()
|
||||||
|
val daysBetween = ChronoUnit.DAYS.between(date, today)
|
||||||
|
return when {
|
||||||
|
daysBetween == 0L -> "Today"
|
||||||
|
daysBetween == 1L -> "Yesterday"
|
||||||
|
daysBetween <= 7L -> "Previous 7 Days"
|
||||||
|
daysBetween <= 30L -> "Previous 30 Days"
|
||||||
|
else -> date.format(DateTimeFormatter.ofPattern("MMMM yyyy"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Long.toRelativeDateGroup(): String =
|
||||||
|
Instant.ofEpochMilli(this).toRelativeDateGroup()
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
package com.librechat.android.core.common.extensions
|
||||||
|
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.conflate
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.flow.retryWhen
|
||||||
|
import kotlin.math.pow
|
||||||
|
|
||||||
|
fun <T> Flow<T>.retryWithBackoff(
|
||||||
|
maxRetries: Int = 3,
|
||||||
|
initialDelay: Long = 1000L,
|
||||||
|
maxDelay: Long = 30000L,
|
||||||
|
factor: Double = 2.0,
|
||||||
|
): Flow<T> = retryWhen { cause, attempt ->
|
||||||
|
if (attempt >= maxRetries) return@retryWhen false
|
||||||
|
val delayMs = (initialDelay * factor.pow(attempt.toDouble())).toLong().coerceAtMost(maxDelay)
|
||||||
|
delay(delayMs)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T> Flow<T>.throttleFirst(periodMillis: Long): Flow<T> = flow {
|
||||||
|
var lastTime = 0L
|
||||||
|
collect { value ->
|
||||||
|
val currentTime = System.currentTimeMillis()
|
||||||
|
if (currentTime - lastTime >= periodMillis) {
|
||||||
|
lastTime = currentTime
|
||||||
|
emit(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
package com.librechat.android.core.common.extensions
|
||||||
|
|
||||||
|
fun String.trimTrailingSlash(): String = trimEnd('/')
|
||||||
|
|
||||||
|
fun String.isValidUrl(): Boolean =
|
||||||
|
startsWith("http://") || startsWith("https://")
|
||||||
|
|
||||||
|
fun String.ensureHttps(): String = when {
|
||||||
|
startsWith("https://") -> this
|
||||||
|
startsWith("http://") -> replaceFirst("http://", "https://")
|
||||||
|
else -> "https://$this"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun String.truncate(maxLength: Int, ellipsis: String = "..."): String =
|
||||||
|
if (length <= maxLength) this
|
||||||
|
else take(maxLength - ellipsis.length) + ellipsis
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
package com.librechat.android.core.common.network
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.Network
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
|
import android.net.NetworkRequest
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.callbackFlow
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class ConnectivityObserver @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) {
|
||||||
|
// Permission is declared in app module's AndroidManifest.xml
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
val isConnected: Flow<Boolean> = callbackFlow {
|
||||||
|
val connectivityManager = context.getSystemService(ConnectivityManager::class.java)
|
||||||
|
|
||||||
|
// Emit current state
|
||||||
|
val currentNetwork = connectivityManager.activeNetwork
|
||||||
|
val currentCapabilities = connectivityManager.getNetworkCapabilities(currentNetwork)
|
||||||
|
trySend(currentCapabilities.hasInternet())
|
||||||
|
|
||||||
|
val callback = object : ConnectivityManager.NetworkCallback() {
|
||||||
|
override fun onAvailable(network: Network) {
|
||||||
|
trySend(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLost(network: Network) {
|
||||||
|
trySend(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCapabilitiesChanged(
|
||||||
|
network: Network,
|
||||||
|
networkCapabilities: NetworkCapabilities,
|
||||||
|
) {
|
||||||
|
trySend(networkCapabilities.hasInternet())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val request = NetworkRequest.Builder()
|
||||||
|
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
connectivityManager.registerNetworkCallback(request, callback)
|
||||||
|
|
||||||
|
awaitClose {
|
||||||
|
connectivityManager.unregisterNetworkCallback(callback)
|
||||||
|
}
|
||||||
|
}.distinctUntilChanged()
|
||||||
|
|
||||||
|
private fun NetworkCapabilities?.hasInternet(): Boolean {
|
||||||
|
return this?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
package com.librechat.android.core.common.result
|
||||||
|
|
||||||
|
sealed interface Result<out T> {
|
||||||
|
data class Success<T>(val data: T) : Result<T>
|
||||||
|
data class Error(val exception: Throwable? = null, val message: String? = null) : Result<Nothing>
|
||||||
|
data object Loading : Result<Nothing>
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T> Result<T>.isSuccess(): Boolean = this is Result.Success
|
||||||
|
fun <T> Result<T>.isError(): Boolean = this is Result.Error
|
||||||
|
fun <T> Result<T>.isLoading(): Boolean = this is Result.Loading
|
||||||
|
|
||||||
|
fun <T> Result<T>.getOrNull(): T? = (this as? Result.Success)?.data
|
||||||
|
fun <T> Result<T>.getOrThrow(): T = when (this) {
|
||||||
|
is Result.Success -> data
|
||||||
|
is Result.Error -> throw exception ?: IllegalStateException(message ?: "Unknown error")
|
||||||
|
is Result.Loading -> throw IllegalStateException("Result is still loading")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun <T> safeApiCall(block: suspend () -> T): Result<T> =
|
||||||
|
try {
|
||||||
|
Result.Success(block())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.Error(e, e.message ?: "An unexpected error occurred")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
package com.librechat.android.core.common.result
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class SafeApiCallTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `safeApiCall returns Success on successful block`() = runTest {
|
||||||
|
val result = safeApiCall { "hello" }
|
||||||
|
assertThat(result).isInstanceOf(Result.Success::class.java)
|
||||||
|
assertThat((result as Result.Success).data).isEqualTo("hello")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `safeApiCall returns Error on exception`() = runTest {
|
||||||
|
val result = safeApiCall<String> { throw RuntimeException("boom") }
|
||||||
|
assertThat(result).isInstanceOf(Result.Error::class.java)
|
||||||
|
val error = result as Result.Error
|
||||||
|
assertThat(error.message).isEqualTo("boom")
|
||||||
|
assertThat(error.exception).isInstanceOf(RuntimeException::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `isSuccess returns true for Success`() {
|
||||||
|
val result: Result<String> = Result.Success("data")
|
||||||
|
assertThat(result.isSuccess()).isTrue()
|
||||||
|
assertThat(result.isError()).isFalse()
|
||||||
|
assertThat(result.isLoading()).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `isError returns true for Error`() {
|
||||||
|
val result: Result<String> = Result.Error(message = "fail")
|
||||||
|
assertThat(result.isError()).isTrue()
|
||||||
|
assertThat(result.isSuccess()).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `isLoading returns true for Loading`() {
|
||||||
|
val result: Result<String> = Result.Loading
|
||||||
|
assertThat(result.isLoading()).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getOrNull returns data for Success`() {
|
||||||
|
val result: Result<String> = Result.Success("data")
|
||||||
|
assertThat(result.getOrNull()).isEqualTo("data")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getOrNull returns null for Error`() {
|
||||||
|
val result: Result<String> = Result.Error(message = "fail")
|
||||||
|
assertThat(result.getOrNull()).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getOrThrow returns data for Success`() {
|
||||||
|
val result: Result<String> = Result.Success("data")
|
||||||
|
assertThat(result.getOrThrow()).isEqualTo("data")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getOrThrow throws for Error with exception`() {
|
||||||
|
val original = IllegalArgumentException("bad arg")
|
||||||
|
val result: Result<String> = Result.Error(original, "bad arg")
|
||||||
|
try {
|
||||||
|
result.getOrThrow()
|
||||||
|
assertThat(false).isTrue() // Should not reach here
|
||||||
|
} catch (e: Exception) {
|
||||||
|
assertThat(e).isSameInstanceAs(original)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getOrThrow throws for Loading`() {
|
||||||
|
val result: Result<String> = Result.Loading
|
||||||
|
try {
|
||||||
|
result.getOrThrow()
|
||||||
|
assertThat(false).isTrue() // Should not reach here
|
||||||
|
} catch (e: IllegalStateException) {
|
||||||
|
assertThat(e.message).isEqualTo("Result is still loading")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
package com.librechat.android.core.common.test
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.TestDispatcher
|
||||||
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import org.junit.rules.TestWatcher
|
||||||
|
import org.junit.runner.Description
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class TestDispatcherRule(
|
||||||
|
val testDispatcher: TestDispatcher = StandardTestDispatcher(),
|
||||||
|
) : TestWatcher() {
|
||||||
|
override fun starting(description: Description) {
|
||||||
|
Dispatchers.setMain(testDispatcher)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun finished(description: Description) {
|
||||||
|
Dispatchers.resetMain()
|
||||||
|
}
|
||||||
|
}
|
||||||
74
core/data/CLAUDE.md
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
# core:data
|
||||||
|
|
||||||
|
Room database, DataStore preferences, EncryptedSharedPreferences for tokens, and all repository implementations. This is the coordination layer between network and local storage.
|
||||||
|
|
||||||
|
## What This Module Provides
|
||||||
|
|
||||||
|
- **Room database** (`db/LibreChatDatabase.kt`): 6 entities, 6 DAOs, version 1, `exportSchema = true`.
|
||||||
|
- **Entities**: `ConversationEntity`, `MessageEntity`, `FileEntity`, `AgentEntity`, `PresetEntity`, `ConversationTagEntity`. Complex fields (lists, nested objects) stored as JSON strings via `Converters`.
|
||||||
|
- **DAOs**: `ConversationDao`, `MessageDao`, `FileDao`, `AgentDao`, `PresetDao`, `ConversationTagDao`. Read methods return `Flow<T>` for reactive observation.
|
||||||
|
- **DataStore**: `ServerDataStore` (server URL prefs), `SettingsDataStore` (user preferences).
|
||||||
|
- **Token storage** (`datastore/TokenDataStore.kt`): Implements `TokenManager` from `:core:network` using `EncryptedSharedPreferences` with AES-256. Uses `Mutex` to ensure only one refresh runs at a time when multiple 401s arrive concurrently.
|
||||||
|
- **Repositories** (`repository/`): Interface + Impl for each domain: `AuthRepository`, `ConversationRepository`, `MessageRepository`, `ChatRepository`, `FileRepository`, `AgentRepository`, `PresetRepository`, `PromptRepository`, `TagRepository`, `ShareRepository`, `ConfigRepository`, `UserRepository`, `SettingsRepository`.
|
||||||
|
- **Entity mappers** (`db/mapper/EntityMapper.kt`): Convert between Room entities and domain models.
|
||||||
|
- **Sync manager** (`sync/ConversationSyncManager.kt`): Orchestrates cache invalidation.
|
||||||
|
|
||||||
|
## Key Patterns
|
||||||
|
|
||||||
|
### Repository: Interface + Impl
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
interface ConversationRepository {
|
||||||
|
fun observeConversations(...): Flow<Result<List<Conversation>>>
|
||||||
|
suspend fun updateTitle(id: String, title: String): Result<Conversation>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
All impls are `@Inject constructor(api, dao, mapper, @Dispatcher(IO) dispatcher)`.
|
||||||
|
|
||||||
|
### Read-Through Cache
|
||||||
|
|
||||||
|
1. Emit cached data from Room immediately (first page).
|
||||||
|
2. Fetch fresh data from network via API service.
|
||||||
|
3. Upsert into Room. The Room `Flow` auto-emits the updated list.
|
||||||
|
4. On network error, the cached data remains visible; error is surfaced separately.
|
||||||
|
|
||||||
|
### Token Refresh with Mutex
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
private val refreshMutex = Mutex()
|
||||||
|
override suspend fun refreshAccessToken(): Boolean = refreshMutex.withLock {
|
||||||
|
// Single refresh attempt, all concurrent callers wait on this lock
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Wrap `EncryptedSharedPreferences` in try/catch -- some OEM devices have broken Keystore implementations. On `KeyStoreException`, clear tokens and force re-login.
|
||||||
|
|
||||||
|
### DataModule Bindings
|
||||||
|
|
||||||
|
`DataModule` is a Hilt `@Module` that `@Binds` repository interfaces to their implementations and `@Provides` the Room database, DAOs, and DataStore instances.
|
||||||
|
|
||||||
|
## Room TypeConverters
|
||||||
|
|
||||||
|
`Converters.kt` handles JSON serialization for complex Room fields: `List<String>`, `MessageContentPart` lists, `Feedback`, `FileReference` lists, etc. Uses the same `Json` instance from the Hilt graph.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Dependencies: `:core:network`, `:core:model`, `:core:common`, Room, DataStore, security-crypto, Ktor, kotlinx-serialization, Timber, Hilt.
|
||||||
|
- Convention plugins: `librechat.android.library` + `librechat.android.hilt` + `librechat.android.room` + `librechat.kotlin.serialization`.
|
||||||
|
- Repositories must use `safeApiCall` from `:core:common` for all network calls.
|
||||||
|
- Entities are internal to this module -- feature modules work with domain models from `:core:model`.
|
||||||
|
- All DAO read methods that the UI observes should return `Flow`, not suspend functions.
|
||||||
|
|
||||||
|
### New Repositories (Round 2)
|
||||||
|
- `MemoryRepository` / `MemoryRepositoryImpl` — wraps `MemoriesApi` for memory CRUD + preferences
|
||||||
|
- `McpRepository` / `McpRepositoryImpl` — wraps `McpApi` for server CRUD, tools, connection status, reinitialize
|
||||||
|
- Both bound in `DataModule.kt` via `@Binds`
|
||||||
|
- Both use `safeApiCall` — no local caching (server is sole source of truth for memories and MCP)
|
||||||
|
- **Gotcha**: Memory delete/update use `key` as identifier (not a separate ID field)
|
||||||
|
- **Gotcha**: MCP server operations use `serverName` as identifier
|
||||||
|
|
||||||
|
### New Repositories (Round 3)
|
||||||
|
- `BannerRepository` / `BannerRepositoryImpl` — wraps `BannerApi` for fetching server banners
|
||||||
|
- `AgentRepository.getAgentsPaginated()` — server-side paginated agent fetch, maps response to `PaginatedAgents` domain model
|
||||||
|
- Both bound in `DataModule.kt` via `@Binds`, both use `safeApiCall`, no local caching
|
||||||
22
core/data/build.gradle.kts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
plugins {
|
||||||
|
id("librechat.android.library")
|
||||||
|
id("librechat.android.hilt")
|
||||||
|
id("librechat.android.room")
|
||||||
|
id("librechat.kotlin.serialization")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.librechat.android.core.data"
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":core:network"))
|
||||||
|
implementation(project(":core:model"))
|
||||||
|
implementation(project(":core:common"))
|
||||||
|
implementation(libs.bundles.room)
|
||||||
|
implementation(libs.datastore.preferences)
|
||||||
|
implementation(libs.security.crypto)
|
||||||
|
implementation(libs.bundles.ktor)
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
implementation(libs.timber)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,643 @@
|
||||||
|
{
|
||||||
|
"formatVersion": 1,
|
||||||
|
"database": {
|
||||||
|
"version": 1,
|
||||||
|
"identityHash": "8b1491672b4ea5147356685f2beb6908",
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"tableName": "conversations",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`conversationId` TEXT NOT NULL, `title` TEXT NOT NULL, `user` TEXT NOT NULL, `endpoint` TEXT, `endpointType` TEXT, `model` TEXT, `agentId` TEXT, `isArchived` INTEGER NOT NULL, `tags` TEXT NOT NULL, `iconURL` TEXT, `greeting` TEXT, `modelParams` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `lastSyncedAt` INTEGER NOT NULL, PRIMARY KEY(`conversationId`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "conversationId",
|
||||||
|
"columnName": "conversationId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "title",
|
||||||
|
"columnName": "title",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "user",
|
||||||
|
"columnName": "user",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "endpoint",
|
||||||
|
"columnName": "endpoint",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "endpointType",
|
||||||
|
"columnName": "endpointType",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "model",
|
||||||
|
"columnName": "model",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "agentId",
|
||||||
|
"columnName": "agentId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isArchived",
|
||||||
|
"columnName": "isArchived",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "tags",
|
||||||
|
"columnName": "tags",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "iconURL",
|
||||||
|
"columnName": "iconURL",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "greeting",
|
||||||
|
"columnName": "greeting",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "modelParams",
|
||||||
|
"columnName": "modelParams",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"columnName": "createdAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "updatedAt",
|
||||||
|
"columnName": "updatedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "lastSyncedAt",
|
||||||
|
"columnName": "lastSyncedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"conversationId"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_conversations_updatedAt",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"updatedAt"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_conversations_updatedAt` ON `${TABLE_NAME}` (`updatedAt`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_conversations_isArchived",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"isArchived"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_conversations_isArchived` ON `${TABLE_NAME}` (`isArchived`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "messages",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `parentMessageId` TEXT, `sender` TEXT, `text` TEXT, `content` TEXT, `isCreatedByUser` INTEGER NOT NULL, `model` TEXT, `endpoint` TEXT, `iconURL` TEXT, `unfinished` INTEGER NOT NULL, `error` INTEGER NOT NULL, `finishReason` TEXT, `tokenCount` INTEGER, `feedback` TEXT, `files` TEXT, `attachments` TEXT, `metadata` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`messageId`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "messageId",
|
||||||
|
"columnName": "messageId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "conversationId",
|
||||||
|
"columnName": "conversationId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "parentMessageId",
|
||||||
|
"columnName": "parentMessageId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "sender",
|
||||||
|
"columnName": "sender",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "text",
|
||||||
|
"columnName": "text",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "content",
|
||||||
|
"columnName": "content",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isCreatedByUser",
|
||||||
|
"columnName": "isCreatedByUser",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "model",
|
||||||
|
"columnName": "model",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "endpoint",
|
||||||
|
"columnName": "endpoint",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "iconURL",
|
||||||
|
"columnName": "iconURL",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "unfinished",
|
||||||
|
"columnName": "unfinished",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "error",
|
||||||
|
"columnName": "error",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "finishReason",
|
||||||
|
"columnName": "finishReason",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "tokenCount",
|
||||||
|
"columnName": "tokenCount",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "feedback",
|
||||||
|
"columnName": "feedback",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "files",
|
||||||
|
"columnName": "files",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "attachments",
|
||||||
|
"columnName": "attachments",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "metadata",
|
||||||
|
"columnName": "metadata",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"columnName": "createdAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "updatedAt",
|
||||||
|
"columnName": "updatedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"messageId"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_messages_conversationId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"conversationId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_conversationId` ON `${TABLE_NAME}` (`conversationId`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_messages_parentMessageId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"parentMessageId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_parentMessageId` ON `${TABLE_NAME}` (`parentMessageId`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_messages_createdAt",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"createdAt"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_createdAt` ON `${TABLE_NAME}` (`createdAt`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "files",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`fileId` TEXT NOT NULL, `user` TEXT NOT NULL, `conversationId` TEXT, `messageId` TEXT, `filename` TEXT NOT NULL, `filepath` TEXT NOT NULL, `type` TEXT NOT NULL, `bytes` INTEGER NOT NULL, `source` TEXT NOT NULL, `width` INTEGER, `height` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `localPath` TEXT, PRIMARY KEY(`fileId`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "fileId",
|
||||||
|
"columnName": "fileId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "user",
|
||||||
|
"columnName": "user",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "conversationId",
|
||||||
|
"columnName": "conversationId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "messageId",
|
||||||
|
"columnName": "messageId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "filename",
|
||||||
|
"columnName": "filename",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "filepath",
|
||||||
|
"columnName": "filepath",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "type",
|
||||||
|
"columnName": "type",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "bytes",
|
||||||
|
"columnName": "bytes",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "source",
|
||||||
|
"columnName": "source",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "width",
|
||||||
|
"columnName": "width",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "height",
|
||||||
|
"columnName": "height",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"columnName": "createdAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "updatedAt",
|
||||||
|
"columnName": "updatedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "localPath",
|
||||||
|
"columnName": "localPath",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"fileId"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_files_conversationId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"conversationId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_files_conversationId` ON `${TABLE_NAME}` (`conversationId`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_files_messageId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"messageId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_files_messageId` ON `${TABLE_NAME}` (`messageId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "agents",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `description` TEXT, `avatar` TEXT, `provider` TEXT NOT NULL, `model` TEXT NOT NULL, `category` TEXT, `authorName` TEXT, `isPromoted` INTEGER NOT NULL, `conversationStarters` TEXT, `tools` TEXT, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "description",
|
||||||
|
"columnName": "description",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "avatar",
|
||||||
|
"columnName": "avatar",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "provider",
|
||||||
|
"columnName": "provider",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "model",
|
||||||
|
"columnName": "model",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "category",
|
||||||
|
"columnName": "category",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "authorName",
|
||||||
|
"columnName": "authorName",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isPromoted",
|
||||||
|
"columnName": "isPromoted",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "conversationStarters",
|
||||||
|
"columnName": "conversationStarters",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "tools",
|
||||||
|
"columnName": "tools",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "updatedAt",
|
||||||
|
"columnName": "updatedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [],
|
||||||
|
"foreignKeys": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "presets",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`presetId` TEXT NOT NULL, `title` TEXT NOT NULL, `endpoint` TEXT, `model` TEXT, `isDefault` INTEGER NOT NULL, `order` INTEGER, `params` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`presetId`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "presetId",
|
||||||
|
"columnName": "presetId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "title",
|
||||||
|
"columnName": "title",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "endpoint",
|
||||||
|
"columnName": "endpoint",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "model",
|
||||||
|
"columnName": "model",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isDefault",
|
||||||
|
"columnName": "isDefault",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "order",
|
||||||
|
"columnName": "order",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "params",
|
||||||
|
"columnName": "params",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"columnName": "createdAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "updatedAt",
|
||||||
|
"columnName": "updatedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"presetId"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [],
|
||||||
|
"foreignKeys": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "conversation_tags",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `tag` TEXT NOT NULL, `user` TEXT NOT NULL, `description` TEXT, `count` INTEGER NOT NULL, `position` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL)",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "tag",
|
||||||
|
"columnName": "tag",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "user",
|
||||||
|
"columnName": "user",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "description",
|
||||||
|
"columnName": "description",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "count",
|
||||||
|
"columnName": "count",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "position",
|
||||||
|
"columnName": "position",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"columnName": "createdAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "updatedAt",
|
||||||
|
"columnName": "updatedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_conversation_tags_tag_user",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"tag",
|
||||||
|
"user"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_conversation_tags_tag_user` ON `${TABLE_NAME}` (`tag`, `user`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"views": [],
|
||||||
|
"setupQueries": [
|
||||||
|
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||||
|
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8b1491672b4ea5147356685f2beb6908')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,675 @@
|
||||||
|
{
|
||||||
|
"formatVersion": 1,
|
||||||
|
"database": {
|
||||||
|
"version": 2,
|
||||||
|
"identityHash": "8da545f3037e030769e5a13adc88efaf",
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"tableName": "conversations",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`conversationId` TEXT NOT NULL, `title` TEXT NOT NULL, `user` TEXT NOT NULL, `endpoint` TEXT, `endpointType` TEXT, `model` TEXT, `agentId` TEXT, `isArchived` INTEGER NOT NULL, `tags` TEXT NOT NULL, `iconURL` TEXT, `greeting` TEXT, `modelParams` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `lastSyncedAt` INTEGER NOT NULL, PRIMARY KEY(`conversationId`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "conversationId",
|
||||||
|
"columnName": "conversationId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "title",
|
||||||
|
"columnName": "title",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "user",
|
||||||
|
"columnName": "user",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "endpoint",
|
||||||
|
"columnName": "endpoint",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "endpointType",
|
||||||
|
"columnName": "endpointType",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "model",
|
||||||
|
"columnName": "model",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "agentId",
|
||||||
|
"columnName": "agentId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isArchived",
|
||||||
|
"columnName": "isArchived",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "tags",
|
||||||
|
"columnName": "tags",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "iconURL",
|
||||||
|
"columnName": "iconURL",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "greeting",
|
||||||
|
"columnName": "greeting",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "modelParams",
|
||||||
|
"columnName": "modelParams",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"columnName": "createdAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "updatedAt",
|
||||||
|
"columnName": "updatedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "lastSyncedAt",
|
||||||
|
"columnName": "lastSyncedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"conversationId"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_conversations_updatedAt",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"updatedAt"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_conversations_updatedAt` ON `${TABLE_NAME}` (`updatedAt`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_conversations_isArchived",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"isArchived"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_conversations_isArchived` ON `${TABLE_NAME}` (`isArchived`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "messages",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `parentMessageId` TEXT, `sender` TEXT, `text` TEXT, `content` TEXT, `isCreatedByUser` INTEGER NOT NULL, `model` TEXT, `endpoint` TEXT, `iconURL` TEXT, `unfinished` INTEGER NOT NULL, `error` INTEGER NOT NULL, `finishReason` TEXT, `tokenCount` INTEGER, `feedback` TEXT, `files` TEXT, `attachments` TEXT, `metadata` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`messageId`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "messageId",
|
||||||
|
"columnName": "messageId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "conversationId",
|
||||||
|
"columnName": "conversationId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "parentMessageId",
|
||||||
|
"columnName": "parentMessageId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "sender",
|
||||||
|
"columnName": "sender",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "text",
|
||||||
|
"columnName": "text",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "content",
|
||||||
|
"columnName": "content",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isCreatedByUser",
|
||||||
|
"columnName": "isCreatedByUser",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "model",
|
||||||
|
"columnName": "model",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "endpoint",
|
||||||
|
"columnName": "endpoint",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "iconURL",
|
||||||
|
"columnName": "iconURL",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "unfinished",
|
||||||
|
"columnName": "unfinished",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "error",
|
||||||
|
"columnName": "error",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "finishReason",
|
||||||
|
"columnName": "finishReason",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "tokenCount",
|
||||||
|
"columnName": "tokenCount",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "feedback",
|
||||||
|
"columnName": "feedback",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "files",
|
||||||
|
"columnName": "files",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "attachments",
|
||||||
|
"columnName": "attachments",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "metadata",
|
||||||
|
"columnName": "metadata",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"columnName": "createdAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "updatedAt",
|
||||||
|
"columnName": "updatedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"messageId"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_messages_conversationId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"conversationId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_conversationId` ON `${TABLE_NAME}` (`conversationId`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_messages_parentMessageId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"parentMessageId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_parentMessageId` ON `${TABLE_NAME}` (`parentMessageId`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_messages_createdAt",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"createdAt"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_createdAt` ON `${TABLE_NAME}` (`createdAt`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "files",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`fileId` TEXT NOT NULL, `user` TEXT NOT NULL, `conversationId` TEXT, `messageId` TEXT, `filename` TEXT NOT NULL, `filepath` TEXT NOT NULL, `type` TEXT NOT NULL, `bytes` INTEGER NOT NULL, `source` TEXT NOT NULL, `width` INTEGER, `height` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `localPath` TEXT, PRIMARY KEY(`fileId`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "fileId",
|
||||||
|
"columnName": "fileId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "user",
|
||||||
|
"columnName": "user",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "conversationId",
|
||||||
|
"columnName": "conversationId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "messageId",
|
||||||
|
"columnName": "messageId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "filename",
|
||||||
|
"columnName": "filename",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "filepath",
|
||||||
|
"columnName": "filepath",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "type",
|
||||||
|
"columnName": "type",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "bytes",
|
||||||
|
"columnName": "bytes",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "source",
|
||||||
|
"columnName": "source",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "width",
|
||||||
|
"columnName": "width",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "height",
|
||||||
|
"columnName": "height",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"columnName": "createdAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "updatedAt",
|
||||||
|
"columnName": "updatedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "localPath",
|
||||||
|
"columnName": "localPath",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"fileId"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_files_conversationId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"conversationId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_files_conversationId` ON `${TABLE_NAME}` (`conversationId`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_files_messageId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"messageId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_files_messageId` ON `${TABLE_NAME}` (`messageId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "agents",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `description` TEXT, `avatar` TEXT, `provider` TEXT NOT NULL, `model` TEXT NOT NULL, `category` TEXT, `authorName` TEXT, `isPromoted` INTEGER NOT NULL, `conversationStarters` TEXT, `tools` TEXT, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "description",
|
||||||
|
"columnName": "description",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "avatar",
|
||||||
|
"columnName": "avatar",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "provider",
|
||||||
|
"columnName": "provider",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "model",
|
||||||
|
"columnName": "model",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "category",
|
||||||
|
"columnName": "category",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "authorName",
|
||||||
|
"columnName": "authorName",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isPromoted",
|
||||||
|
"columnName": "isPromoted",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "conversationStarters",
|
||||||
|
"columnName": "conversationStarters",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "tools",
|
||||||
|
"columnName": "tools",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "updatedAt",
|
||||||
|
"columnName": "updatedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [],
|
||||||
|
"foreignKeys": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "presets",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`presetId` TEXT NOT NULL, `title` TEXT NOT NULL, `endpoint` TEXT, `model` TEXT, `isDefault` INTEGER NOT NULL, `order` INTEGER, `params` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`presetId`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "presetId",
|
||||||
|
"columnName": "presetId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "title",
|
||||||
|
"columnName": "title",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "endpoint",
|
||||||
|
"columnName": "endpoint",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "model",
|
||||||
|
"columnName": "model",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isDefault",
|
||||||
|
"columnName": "isDefault",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "order",
|
||||||
|
"columnName": "order",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "params",
|
||||||
|
"columnName": "params",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"columnName": "createdAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "updatedAt",
|
||||||
|
"columnName": "updatedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"presetId"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [],
|
||||||
|
"foreignKeys": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "conversation_tags",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `tag` TEXT NOT NULL, `user` TEXT NOT NULL, `description` TEXT, `count` INTEGER NOT NULL, `position` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL)",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "tag",
|
||||||
|
"columnName": "tag",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "user",
|
||||||
|
"columnName": "user",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "description",
|
||||||
|
"columnName": "description",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "count",
|
||||||
|
"columnName": "count",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "position",
|
||||||
|
"columnName": "position",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"columnName": "createdAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "updatedAt",
|
||||||
|
"columnName": "updatedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_conversation_tags_tag_user",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"tag",
|
||||||
|
"user"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_conversation_tags_tag_user` ON `${TABLE_NAME}` (`tag`, `user`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "drafts",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`conversation_id` TEXT NOT NULL, `text` TEXT NOT NULL, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`conversation_id`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "conversationId",
|
||||||
|
"columnName": "conversation_id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "text",
|
||||||
|
"columnName": "text",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "updatedAt",
|
||||||
|
"columnName": "updated_at",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"conversation_id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [],
|
||||||
|
"foreignKeys": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"views": [],
|
||||||
|
"setupQueries": [
|
||||||
|
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||||
|
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8da545f3037e030769e5a13adc88efaf')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,668 @@
|
||||||
|
{
|
||||||
|
"formatVersion": 1,
|
||||||
|
"database": {
|
||||||
|
"version": 3,
|
||||||
|
"identityHash": "b4117797915eafc13b5bd956677f83bf",
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"tableName": "conversations",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`conversationId` TEXT NOT NULL, `title` TEXT NOT NULL, `user` TEXT NOT NULL, `endpoint` TEXT, `endpointType` TEXT, `model` TEXT, `agentId` TEXT, `isArchived` INTEGER NOT NULL, `tags` TEXT NOT NULL, `iconURL` TEXT, `greeting` TEXT, `modelParams` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `lastSyncedAt` INTEGER NOT NULL, PRIMARY KEY(`conversationId`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "conversationId",
|
||||||
|
"columnName": "conversationId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "title",
|
||||||
|
"columnName": "title",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "user",
|
||||||
|
"columnName": "user",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "endpoint",
|
||||||
|
"columnName": "endpoint",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "endpointType",
|
||||||
|
"columnName": "endpointType",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "model",
|
||||||
|
"columnName": "model",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "agentId",
|
||||||
|
"columnName": "agentId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isArchived",
|
||||||
|
"columnName": "isArchived",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "tags",
|
||||||
|
"columnName": "tags",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "iconURL",
|
||||||
|
"columnName": "iconURL",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "greeting",
|
||||||
|
"columnName": "greeting",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "modelParams",
|
||||||
|
"columnName": "modelParams",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"columnName": "createdAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "updatedAt",
|
||||||
|
"columnName": "updatedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "lastSyncedAt",
|
||||||
|
"columnName": "lastSyncedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"conversationId"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_conversations_isArchived_updatedAt",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"isArchived",
|
||||||
|
"updatedAt"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_conversations_isArchived_updatedAt` ON `${TABLE_NAME}` (`isArchived`, `updatedAt`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "messages",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `parentMessageId` TEXT, `sender` TEXT, `text` TEXT, `content` TEXT, `isCreatedByUser` INTEGER NOT NULL, `model` TEXT, `endpoint` TEXT, `iconURL` TEXT, `unfinished` INTEGER NOT NULL, `error` INTEGER NOT NULL, `finishReason` TEXT, `tokenCount` INTEGER, `feedback` TEXT, `files` TEXT, `attachments` TEXT, `metadata` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`messageId`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "messageId",
|
||||||
|
"columnName": "messageId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "conversationId",
|
||||||
|
"columnName": "conversationId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "parentMessageId",
|
||||||
|
"columnName": "parentMessageId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "sender",
|
||||||
|
"columnName": "sender",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "text",
|
||||||
|
"columnName": "text",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "content",
|
||||||
|
"columnName": "content",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isCreatedByUser",
|
||||||
|
"columnName": "isCreatedByUser",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "model",
|
||||||
|
"columnName": "model",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "endpoint",
|
||||||
|
"columnName": "endpoint",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "iconURL",
|
||||||
|
"columnName": "iconURL",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "unfinished",
|
||||||
|
"columnName": "unfinished",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "error",
|
||||||
|
"columnName": "error",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "finishReason",
|
||||||
|
"columnName": "finishReason",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "tokenCount",
|
||||||
|
"columnName": "tokenCount",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "feedback",
|
||||||
|
"columnName": "feedback",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "files",
|
||||||
|
"columnName": "files",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "attachments",
|
||||||
|
"columnName": "attachments",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "metadata",
|
||||||
|
"columnName": "metadata",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"columnName": "createdAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "updatedAt",
|
||||||
|
"columnName": "updatedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"messageId"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_messages_conversationId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"conversationId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_conversationId` ON `${TABLE_NAME}` (`conversationId`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_messages_parentMessageId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"parentMessageId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_parentMessageId` ON `${TABLE_NAME}` (`parentMessageId`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_messages_conversationId_createdAt",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"conversationId",
|
||||||
|
"createdAt"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_conversationId_createdAt` ON `${TABLE_NAME}` (`conversationId`, `createdAt`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "files",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`fileId` TEXT NOT NULL, `user` TEXT NOT NULL, `conversationId` TEXT, `messageId` TEXT, `filename` TEXT NOT NULL, `filepath` TEXT NOT NULL, `type` TEXT NOT NULL, `bytes` INTEGER NOT NULL, `source` TEXT NOT NULL, `width` INTEGER, `height` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `localPath` TEXT, PRIMARY KEY(`fileId`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "fileId",
|
||||||
|
"columnName": "fileId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "user",
|
||||||
|
"columnName": "user",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "conversationId",
|
||||||
|
"columnName": "conversationId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "messageId",
|
||||||
|
"columnName": "messageId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "filename",
|
||||||
|
"columnName": "filename",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "filepath",
|
||||||
|
"columnName": "filepath",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "type",
|
||||||
|
"columnName": "type",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "bytes",
|
||||||
|
"columnName": "bytes",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "source",
|
||||||
|
"columnName": "source",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "width",
|
||||||
|
"columnName": "width",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "height",
|
||||||
|
"columnName": "height",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"columnName": "createdAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "updatedAt",
|
||||||
|
"columnName": "updatedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "localPath",
|
||||||
|
"columnName": "localPath",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"fileId"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_files_conversationId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"conversationId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_files_conversationId` ON `${TABLE_NAME}` (`conversationId`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_files_messageId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"messageId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_files_messageId` ON `${TABLE_NAME}` (`messageId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "agents",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `description` TEXT, `avatar` TEXT, `provider` TEXT NOT NULL, `model` TEXT NOT NULL, `category` TEXT, `authorName` TEXT, `isPromoted` INTEGER NOT NULL, `conversationStarters` TEXT, `tools` TEXT, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "description",
|
||||||
|
"columnName": "description",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "avatar",
|
||||||
|
"columnName": "avatar",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "provider",
|
||||||
|
"columnName": "provider",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "model",
|
||||||
|
"columnName": "model",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "category",
|
||||||
|
"columnName": "category",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "authorName",
|
||||||
|
"columnName": "authorName",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isPromoted",
|
||||||
|
"columnName": "isPromoted",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "conversationStarters",
|
||||||
|
"columnName": "conversationStarters",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "tools",
|
||||||
|
"columnName": "tools",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "updatedAt",
|
||||||
|
"columnName": "updatedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [],
|
||||||
|
"foreignKeys": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "presets",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`presetId` TEXT NOT NULL, `title` TEXT NOT NULL, `endpoint` TEXT, `model` TEXT, `isDefault` INTEGER NOT NULL, `order` INTEGER, `params` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`presetId`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "presetId",
|
||||||
|
"columnName": "presetId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "title",
|
||||||
|
"columnName": "title",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "endpoint",
|
||||||
|
"columnName": "endpoint",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "model",
|
||||||
|
"columnName": "model",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isDefault",
|
||||||
|
"columnName": "isDefault",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "order",
|
||||||
|
"columnName": "order",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "params",
|
||||||
|
"columnName": "params",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"columnName": "createdAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "updatedAt",
|
||||||
|
"columnName": "updatedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"presetId"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [],
|
||||||
|
"foreignKeys": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "conversation_tags",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `tag` TEXT NOT NULL, `user` TEXT NOT NULL, `description` TEXT, `count` INTEGER NOT NULL, `position` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL)",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "tag",
|
||||||
|
"columnName": "tag",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "user",
|
||||||
|
"columnName": "user",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "description",
|
||||||
|
"columnName": "description",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "count",
|
||||||
|
"columnName": "count",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "position",
|
||||||
|
"columnName": "position",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"columnName": "createdAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "updatedAt",
|
||||||
|
"columnName": "updatedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_conversation_tags_tag_user",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"tag",
|
||||||
|
"user"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_conversation_tags_tag_user` ON `${TABLE_NAME}` (`tag`, `user`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "drafts",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`conversation_id` TEXT NOT NULL, `text` TEXT NOT NULL, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`conversation_id`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "conversationId",
|
||||||
|
"columnName": "conversation_id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "text",
|
||||||
|
"columnName": "text",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "updatedAt",
|
||||||
|
"columnName": "updated_at",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"conversation_id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [],
|
||||||
|
"foreignKeys": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"views": [],
|
||||||
|
"setupQueries": [
|
||||||
|
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||||
|
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b4117797915eafc13b5bd956677f83bf')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
package com.librechat.android.core.data.datastore
|
||||||
|
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import com.librechat.android.core.model.EndpointConfig
|
||||||
|
import com.librechat.android.core.model.StartupConfig
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.serialization.builtins.ListSerializer
|
||||||
|
import kotlinx.serialization.builtins.MapSerializer
|
||||||
|
import kotlinx.serialization.builtins.serializer
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class ConfigCacheDataStore @Inject constructor(
|
||||||
|
private val dataStore: DataStore<Preferences>,
|
||||||
|
private val json: Json,
|
||||||
|
) {
|
||||||
|
|
||||||
|
suspend fun saveStartupConfig(config: StartupConfig) {
|
||||||
|
try {
|
||||||
|
val serialized = json.encodeToString(StartupConfig.serializer(), config)
|
||||||
|
dataStore.edit { prefs -> prefs[KEY_STARTUP_CONFIG] = serialized }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.w(e, "Failed to cache startup config")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun loadStartupConfig(): StartupConfig? {
|
||||||
|
return try {
|
||||||
|
val prefs = dataStore.data.first()
|
||||||
|
val serialized = prefs[KEY_STARTUP_CONFIG] ?: return null
|
||||||
|
json.decodeFromString(StartupConfig.serializer(), serialized)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.w(e, "Failed to load cached startup config")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveEndpointConfigs(endpoints: Map<String, EndpointConfig>) {
|
||||||
|
try {
|
||||||
|
val serializer = MapSerializer(String.serializer(), EndpointConfig.serializer())
|
||||||
|
val serialized = json.encodeToString(serializer, endpoints)
|
||||||
|
dataStore.edit { prefs -> prefs[KEY_ENDPOINT_CONFIGS] = serialized }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.w(e, "Failed to cache endpoint configs")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun loadEndpointConfigs(): Map<String, EndpointConfig>? {
|
||||||
|
return try {
|
||||||
|
val prefs = dataStore.data.first()
|
||||||
|
val serialized = prefs[KEY_ENDPOINT_CONFIGS] ?: return null
|
||||||
|
val serializer = MapSerializer(String.serializer(), EndpointConfig.serializer())
|
||||||
|
json.decodeFromString(serializer, serialized)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.w(e, "Failed to load cached endpoint configs")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveAvailableModels(models: Map<String, List<String>>) {
|
||||||
|
try {
|
||||||
|
val serializer = MapSerializer(
|
||||||
|
String.serializer(),
|
||||||
|
ListSerializer(String.serializer()),
|
||||||
|
)
|
||||||
|
val serialized = json.encodeToString(serializer, models)
|
||||||
|
dataStore.edit { prefs -> prefs[KEY_AVAILABLE_MODELS] = serialized }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.w(e, "Failed to cache available models")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun loadAvailableModels(): Map<String, List<String>>? {
|
||||||
|
return try {
|
||||||
|
val prefs = dataStore.data.first()
|
||||||
|
val serialized = prefs[KEY_AVAILABLE_MODELS] ?: return null
|
||||||
|
val serializer = MapSerializer(
|
||||||
|
String.serializer(),
|
||||||
|
ListSerializer(String.serializer()),
|
||||||
|
)
|
||||||
|
json.decodeFromString(serializer, serialized)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.w(e, "Failed to load cached available models")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val KEY_STARTUP_CONFIG = stringPreferencesKey("cached_startup_config")
|
||||||
|
private val KEY_ENDPOINT_CONFIGS = stringPreferencesKey("cached_endpoint_configs")
|
||||||
|
private val KEY_AVAILABLE_MODELS = stringPreferencesKey("cached_available_models")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
package com.librechat.android.core.data.datastore
|
||||||
|
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import com.librechat.android.core.network.client.ServerUrlProvider
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class ServerDataStore @Inject constructor(
|
||||||
|
private val dataStore: DataStore<Preferences>,
|
||||||
|
) : ServerUrlProvider {
|
||||||
|
|
||||||
|
private val _currentUrl = MutableStateFlow("")
|
||||||
|
val currentUrlFlow: Flow<String> = _currentUrl
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Load the persisted URL synchronously so getBaseUrl() is ready
|
||||||
|
// immediately after construction. This is a singleton created once
|
||||||
|
// at app startup, so a brief block on Dispatchers.IO is acceptable.
|
||||||
|
_currentUrl.value = runBlocking(Dispatchers.IO) {
|
||||||
|
dataStore.data
|
||||||
|
.map { prefs -> prefs[KEY_SERVER_URL].orEmpty() }
|
||||||
|
.first()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getBaseUrl(): String = _currentUrl.value
|
||||||
|
|
||||||
|
fun hasServerUrl(): Flow<Boolean> =
|
||||||
|
dataStore.data.map { prefs ->
|
||||||
|
!prefs[KEY_SERVER_URL].isNullOrBlank()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setServerUrl(url: String) {
|
||||||
|
val trimmed = url.trimEnd('/')
|
||||||
|
_currentUrl.value = trimmed
|
||||||
|
dataStore.edit { prefs ->
|
||||||
|
prefs[KEY_SERVER_URL] = trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val KEY_SERVER_URL = stringPreferencesKey("server_url")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,390 @@
|
||||||
|
package com.librechat.android.core.data.datastore
|
||||||
|
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.floatPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
enum class LatexRenderer {
|
||||||
|
NATIVE, KATEX;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromString(value: String?): LatexRenderer = when (value) {
|
||||||
|
"native" -> NATIVE
|
||||||
|
else -> KATEX
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toStorageString(): String = when (this) {
|
||||||
|
NATIVE -> "native"
|
||||||
|
KATEX -> "katex"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class ChatFontSize {
|
||||||
|
SMALL, MEDIUM, LARGE;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromString(value: String?): ChatFontSize = when (value) {
|
||||||
|
"small" -> SMALL
|
||||||
|
"large" -> LARGE
|
||||||
|
else -> MEDIUM
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toStorageString(): String = when (this) {
|
||||||
|
SMALL -> "small"
|
||||||
|
MEDIUM -> "medium"
|
||||||
|
LARGE -> "large"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class SettingsDataStore @Inject constructor(
|
||||||
|
private val dataStore: DataStore<Preferences>,
|
||||||
|
) {
|
||||||
|
val latexRenderer: Flow<LatexRenderer> = dataStore.data.map { prefs ->
|
||||||
|
LatexRenderer.fromString(prefs[KEY_LATEX_RENDERER])
|
||||||
|
}
|
||||||
|
|
||||||
|
val chatFontSize: Flow<ChatFontSize> = dataStore.data.map { prefs ->
|
||||||
|
ChatFontSize.fromString(prefs[KEY_CHAT_FONT_SIZE])
|
||||||
|
}
|
||||||
|
|
||||||
|
val autoScrollEnabled: Flow<Boolean> = dataStore.data.map { prefs ->
|
||||||
|
prefs[KEY_AUTO_SCROLL_ENABLED] ?: true
|
||||||
|
}
|
||||||
|
|
||||||
|
val showThinkingBlocks: Flow<Boolean> = dataStore.data.map { prefs ->
|
||||||
|
prefs[KEY_SHOW_THINKING_BLOCKS] ?: true
|
||||||
|
}
|
||||||
|
|
||||||
|
val autoReadEnabled: Flow<Boolean> = dataStore.data.map { prefs ->
|
||||||
|
prefs[KEY_AUTO_READ_ENABLED] ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
val showImageDescriptions: Flow<Boolean> = dataStore.data.map { prefs ->
|
||||||
|
prefs[KEY_SHOW_IMAGE_DESCRIPTIONS] ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
val selectedVoiceId: Flow<String?> = dataStore.data.map { prefs ->
|
||||||
|
prefs[KEY_SELECTED_VOICE_ID]
|
||||||
|
}
|
||||||
|
|
||||||
|
val lastUsedEndpoint: Flow<String?> = dataStore.data.map { prefs ->
|
||||||
|
prefs[KEY_LAST_USED_ENDPOINT]
|
||||||
|
}
|
||||||
|
|
||||||
|
val lastUsedModel: Flow<String?> = dataStore.data.map { prefs ->
|
||||||
|
prefs[KEY_LAST_USED_MODEL]
|
||||||
|
}
|
||||||
|
|
||||||
|
val dismissKeyboardOnSend: Flow<Boolean> = dataStore.data.map { prefs ->
|
||||||
|
prefs[KEY_DISMISS_KEYBOARD_ON_SEND] ?: true
|
||||||
|
}
|
||||||
|
|
||||||
|
val ttsSource: Flow<String> = dataStore.data.map { prefs ->
|
||||||
|
prefs[KEY_TTS_SOURCE] ?: "device"
|
||||||
|
}
|
||||||
|
|
||||||
|
val ttsSpeechRate: Flow<Float> = dataStore.data.map { prefs ->
|
||||||
|
prefs[KEY_TTS_SPEECH_RATE] ?: 1.0f
|
||||||
|
}
|
||||||
|
|
||||||
|
val ttsPitch: Flow<Float> = dataStore.data.map { prefs ->
|
||||||
|
prefs[KEY_TTS_PITCH] ?: 1.0f
|
||||||
|
}
|
||||||
|
|
||||||
|
val ttsVoiceName: Flow<String> = dataStore.data.map { prefs ->
|
||||||
|
prefs[KEY_TTS_VOICE_NAME] ?: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
val bookmarkedConversationIds: Flow<Set<String>> = dataStore.data.map { prefs ->
|
||||||
|
prefs[KEY_BOOKMARKED_CONVERSATIONS] ?: emptySet()
|
||||||
|
}
|
||||||
|
|
||||||
|
val tabletSidebarOpen: Flow<Boolean> = dataStore.data.map { prefs ->
|
||||||
|
prefs[KEY_TABLET_SIDEBAR_OPEN] ?: true
|
||||||
|
}
|
||||||
|
|
||||||
|
val tabletSidebarGestureEnabled: Flow<Boolean> = dataStore.data.map { prefs ->
|
||||||
|
prefs[KEY_TABLET_SIDEBAR_GESTURE_ENABLED] ?: true
|
||||||
|
}
|
||||||
|
|
||||||
|
val autoSendAfterStt: Flow<Boolean> = dataStore.data.map { prefs ->
|
||||||
|
prefs[KEY_AUTO_SEND_AFTER_STT] ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
val sttEngine: Flow<String> = dataStore.data.map { prefs ->
|
||||||
|
prefs[KEY_STT_ENGINE] ?: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
val sttLanguage: Flow<String> = dataStore.data.map { prefs ->
|
||||||
|
prefs[KEY_STT_LANGUAGE] ?: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
val ttsEngine: Flow<String> = dataStore.data.map { prefs ->
|
||||||
|
prefs[KEY_TTS_ENGINE] ?: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
val ttsVoice: Flow<String> = dataStore.data.map { prefs ->
|
||||||
|
prefs[KEY_TTS_VOICE] ?: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
val ttsCaching: Flow<Boolean> = dataStore.data.map { prefs ->
|
||||||
|
prefs[KEY_TTS_CACHING] ?: true
|
||||||
|
}
|
||||||
|
|
||||||
|
val chatLayoutStyle: Flow<String> = dataStore.data.map { prefs ->
|
||||||
|
prefs[KEY_CHAT_LAYOUT_STYLE] ?: "thread"
|
||||||
|
}
|
||||||
|
|
||||||
|
val showAvatars: Flow<Boolean> = dataStore.data.map { prefs ->
|
||||||
|
prefs[KEY_SHOW_AVATARS] ?: true
|
||||||
|
}
|
||||||
|
|
||||||
|
val showBubbles: Flow<Boolean> = dataStore.data.map { prefs ->
|
||||||
|
prefs[KEY_SHOW_BUBBLES] ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
val selectedMcpServers: Flow<Set<String>> = dataStore.data.map { prefs ->
|
||||||
|
prefs[KEY_SELECTED_MCP_SERVERS]?.split(",")?.filter { it.isNotBlank() }?.toSet() ?: emptySet()
|
||||||
|
}
|
||||||
|
|
||||||
|
val enabledTools: Flow<Set<String>> = dataStore.data.map { prefs ->
|
||||||
|
prefs[KEY_ENABLED_TOOLS]?.split(",")?.filter { it.isNotBlank() }?.toSet() ?: emptySet()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The backend version for which the user dismissed the version mismatch warning.
|
||||||
|
* When the backend updates to a new version, the dialog will appear again.
|
||||||
|
*/
|
||||||
|
val dismissedVersionWarning: Flow<String?> = dataStore.data.map { prefs ->
|
||||||
|
prefs[KEY_DISMISSED_VERSION_WARNING]
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setLatexRenderer(renderer: LatexRenderer) {
|
||||||
|
dataStore.edit { prefs ->
|
||||||
|
prefs[KEY_LATEX_RENDERER] = renderer.toStorageString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setChatFontSize(size: ChatFontSize) {
|
||||||
|
dataStore.edit { prefs ->
|
||||||
|
prefs[KEY_CHAT_FONT_SIZE] = size.toStorageString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setAutoScrollEnabled(enabled: Boolean) {
|
||||||
|
dataStore.edit { prefs ->
|
||||||
|
prefs[KEY_AUTO_SCROLL_ENABLED] = enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setShowThinkingBlocks(show: Boolean) {
|
||||||
|
dataStore.edit { prefs ->
|
||||||
|
prefs[KEY_SHOW_THINKING_BLOCKS] = show
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setAutoReadEnabled(enabled: Boolean) {
|
||||||
|
dataStore.edit { prefs ->
|
||||||
|
prefs[KEY_AUTO_READ_ENABLED] = enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setShowImageDescriptions(show: Boolean) {
|
||||||
|
dataStore.edit { prefs ->
|
||||||
|
prefs[KEY_SHOW_IMAGE_DESCRIPTIONS] = show
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setDismissKeyboardOnSend(enabled: Boolean) {
|
||||||
|
dataStore.edit { prefs ->
|
||||||
|
prefs[KEY_DISMISS_KEYBOARD_ON_SEND] = enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setSelectedVoiceId(voiceId: String) {
|
||||||
|
dataStore.edit { prefs ->
|
||||||
|
prefs[KEY_SELECTED_VOICE_ID] = voiceId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setLastUsedModel(endpoint: String, model: String) {
|
||||||
|
dataStore.edit { prefs ->
|
||||||
|
prefs[KEY_LAST_USED_ENDPOINT] = endpoint
|
||||||
|
prefs[KEY_LAST_USED_MODEL] = model
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setTtsSource(source: String) {
|
||||||
|
dataStore.edit { prefs ->
|
||||||
|
prefs[KEY_TTS_SOURCE] = source
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setTtsSpeechRate(rate: Float) {
|
||||||
|
dataStore.edit { prefs ->
|
||||||
|
prefs[KEY_TTS_SPEECH_RATE] = rate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setTtsPitch(pitch: Float) {
|
||||||
|
dataStore.edit { prefs ->
|
||||||
|
prefs[KEY_TTS_PITCH] = pitch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setTtsVoiceName(voiceName: String) {
|
||||||
|
dataStore.edit { prefs ->
|
||||||
|
prefs[KEY_TTS_VOICE_NAME] = voiceName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setTabletSidebarOpen(open: Boolean) {
|
||||||
|
dataStore.edit { prefs ->
|
||||||
|
prefs[KEY_TABLET_SIDEBAR_OPEN] = open
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setTabletSidebarGestureEnabled(enabled: Boolean) {
|
||||||
|
dataStore.edit { prefs ->
|
||||||
|
prefs[KEY_TABLET_SIDEBAR_GESTURE_ENABLED] = enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setAutoSendAfterStt(enabled: Boolean) {
|
||||||
|
dataStore.edit { prefs ->
|
||||||
|
prefs[KEY_AUTO_SEND_AFTER_STT] = enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setSttEngine(engine: String) {
|
||||||
|
dataStore.edit { prefs ->
|
||||||
|
prefs[KEY_STT_ENGINE] = engine
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setSttLanguage(language: String) {
|
||||||
|
dataStore.edit { prefs ->
|
||||||
|
prefs[KEY_STT_LANGUAGE] = language
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setTtsEngine(engine: String) {
|
||||||
|
dataStore.edit { prefs ->
|
||||||
|
prefs[KEY_TTS_ENGINE] = engine
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setTtsVoice(voice: String) {
|
||||||
|
dataStore.edit { prefs ->
|
||||||
|
prefs[KEY_TTS_VOICE] = voice
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setTtsCaching(enabled: Boolean) {
|
||||||
|
dataStore.edit { prefs ->
|
||||||
|
prefs[KEY_TTS_CACHING] = enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setChatLayoutStyle(style: String) {
|
||||||
|
dataStore.edit { prefs ->
|
||||||
|
prefs[KEY_CHAT_LAYOUT_STYLE] = style
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setShowAvatars(show: Boolean) {
|
||||||
|
dataStore.edit { prefs ->
|
||||||
|
prefs[KEY_SHOW_AVATARS] = show
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setShowBubbles(show: Boolean) {
|
||||||
|
dataStore.edit { prefs ->
|
||||||
|
prefs[KEY_SHOW_BUBBLES] = show
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves the backend version for which the user chose "Don't warn again".
|
||||||
|
* If the backend later updates to a different version, the warning will reappear.
|
||||||
|
*/
|
||||||
|
suspend fun setDismissedVersionWarning(version: String) {
|
||||||
|
dataStore.edit { prefs ->
|
||||||
|
prefs[KEY_DISMISSED_VERSION_WARNING] = version
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setSelectedMcpServers(servers: Set<String>) {
|
||||||
|
dataStore.edit { prefs ->
|
||||||
|
if (servers.isEmpty()) {
|
||||||
|
prefs.remove(KEY_SELECTED_MCP_SERVERS)
|
||||||
|
} else {
|
||||||
|
prefs[KEY_SELECTED_MCP_SERVERS] = servers.joinToString(",")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setEnabledTools(tools: Set<String>) {
|
||||||
|
dataStore.edit { prefs ->
|
||||||
|
if (tools.isEmpty()) {
|
||||||
|
prefs.remove(KEY_ENABLED_TOOLS)
|
||||||
|
} else {
|
||||||
|
prefs[KEY_ENABLED_TOOLS] = tools.joinToString(",")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun toggleBookmark(conversationId: String) {
|
||||||
|
dataStore.edit { prefs ->
|
||||||
|
val current = prefs[KEY_BOOKMARKED_CONVERSATIONS] ?: emptySet()
|
||||||
|
prefs[KEY_BOOKMARKED_CONVERSATIONS] = if (conversationId in current) {
|
||||||
|
current - conversationId
|
||||||
|
} else {
|
||||||
|
current + conversationId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val KEY_LATEX_RENDERER = stringPreferencesKey("latex_renderer")
|
||||||
|
private val KEY_CHAT_FONT_SIZE = stringPreferencesKey("chat_font_size")
|
||||||
|
private val KEY_AUTO_SCROLL_ENABLED = booleanPreferencesKey("auto_scroll_enabled")
|
||||||
|
private val KEY_SHOW_THINKING_BLOCKS = booleanPreferencesKey("show_thinking_blocks")
|
||||||
|
private val KEY_AUTO_READ_ENABLED = booleanPreferencesKey("auto_read_enabled")
|
||||||
|
private val KEY_SHOW_IMAGE_DESCRIPTIONS = booleanPreferencesKey("show_image_descriptions")
|
||||||
|
private val KEY_SELECTED_VOICE_ID = stringPreferencesKey("selected_voice_id")
|
||||||
|
private val KEY_LAST_USED_ENDPOINT = stringPreferencesKey("last_used_endpoint")
|
||||||
|
private val KEY_LAST_USED_MODEL = stringPreferencesKey("last_used_model")
|
||||||
|
private val KEY_DISMISS_KEYBOARD_ON_SEND = booleanPreferencesKey("dismiss_keyboard_on_send")
|
||||||
|
private val KEY_TTS_SOURCE = stringPreferencesKey("tts_source")
|
||||||
|
private val KEY_TTS_SPEECH_RATE = floatPreferencesKey("tts_speech_rate")
|
||||||
|
private val KEY_TTS_PITCH = floatPreferencesKey("tts_pitch")
|
||||||
|
private val KEY_TTS_VOICE_NAME = stringPreferencesKey("tts_voice_name")
|
||||||
|
private val KEY_BOOKMARKED_CONVERSATIONS = stringSetPreferencesKey("bookmarked_conversations")
|
||||||
|
private val KEY_TABLET_SIDEBAR_OPEN = booleanPreferencesKey("tablet_sidebar_open")
|
||||||
|
private val KEY_TABLET_SIDEBAR_GESTURE_ENABLED = booleanPreferencesKey("tablet_sidebar_gesture_enabled")
|
||||||
|
private val KEY_AUTO_SEND_AFTER_STT = booleanPreferencesKey("auto_send_after_stt")
|
||||||
|
private val KEY_STT_ENGINE = stringPreferencesKey("stt_engine")
|
||||||
|
private val KEY_STT_LANGUAGE = stringPreferencesKey("stt_language")
|
||||||
|
private val KEY_TTS_ENGINE = stringPreferencesKey("tts_engine")
|
||||||
|
private val KEY_TTS_VOICE = stringPreferencesKey("tts_voice")
|
||||||
|
private val KEY_TTS_CACHING = booleanPreferencesKey("tts_caching")
|
||||||
|
private val KEY_CHAT_LAYOUT_STYLE = stringPreferencesKey("chat_layout_style")
|
||||||
|
private val KEY_SHOW_AVATARS = booleanPreferencesKey("show_avatars")
|
||||||
|
private val KEY_SHOW_BUBBLES = booleanPreferencesKey("show_bubbles")
|
||||||
|
private val KEY_DISMISSED_VERSION_WARNING = stringPreferencesKey("dismissed_version_warning")
|
||||||
|
private val KEY_SELECTED_MCP_SERVERS = stringPreferencesKey("selected_mcp_servers")
|
||||||
|
private val KEY_ENABLED_TOOLS = stringPreferencesKey("enabled_tools")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
package com.librechat.android.core.data.datastore
|
||||||
|
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
enum class ThemeMode {
|
||||||
|
SYSTEM, LIGHT, DARK
|
||||||
|
}
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class ThemeDataStore @Inject constructor(
|
||||||
|
private val dataStore: DataStore<Preferences>,
|
||||||
|
) {
|
||||||
|
|
||||||
|
val themeMode: Flow<ThemeMode> = dataStore.data.map { prefs ->
|
||||||
|
when (prefs[KEY_THEME]) {
|
||||||
|
"light" -> ThemeMode.LIGHT
|
||||||
|
"dark" -> ThemeMode.DARK
|
||||||
|
else -> ThemeMode.SYSTEM
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setThemeMode(mode: ThemeMode) {
|
||||||
|
dataStore.edit { prefs ->
|
||||||
|
prefs[KEY_THEME] = when (mode) {
|
||||||
|
ThemeMode.SYSTEM -> "system"
|
||||||
|
ThemeMode.LIGHT -> "light"
|
||||||
|
ThemeMode.DARK -> "dark"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val KEY_THEME = stringPreferencesKey("theme_mode")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,148 @@
|
||||||
|
package com.librechat.android.core.data.datastore
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
|
import androidx.security.crypto.MasterKey
|
||||||
|
import com.librechat.android.core.model.response.RefreshResponse
|
||||||
|
import com.librechat.android.core.network.client.CookieHelper
|
||||||
|
import com.librechat.android.core.network.client.SecureTokenStorage
|
||||||
|
import com.librechat.android.core.network.client.TokenManager
|
||||||
|
import com.librechat.android.core.network.di.RefreshClient
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.call.body
|
||||||
|
import io.ktor.client.plugins.ClientRequestException
|
||||||
|
import io.ktor.client.request.header
|
||||||
|
import io.ktor.client.request.post
|
||||||
|
import io.ktor.client.request.setBody
|
||||||
|
import io.ktor.client.statement.HttpResponse
|
||||||
|
import java.io.IOException
|
||||||
|
import java.security.KeyStoreException
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class TokenDataStore @Inject constructor(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
@RefreshClient private val refreshClient: dagger.Lazy<HttpClient>,
|
||||||
|
) : TokenManager, SecureTokenStorage {
|
||||||
|
|
||||||
|
private val masterKey: MasterKey = MasterKey.Builder(context)
|
||||||
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private val prefs: SharedPreferences = EncryptedSharedPreferences.create(
|
||||||
|
context,
|
||||||
|
PREFS_NAME,
|
||||||
|
masterKey,
|
||||||
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Volatile in-memory cache for fast access-token reads. */
|
||||||
|
@Volatile
|
||||||
|
private var cachedAccessToken: String? = prefs.getString(KEY_ACCESS_TOKEN, null)
|
||||||
|
|
||||||
|
private val refreshMutex = Mutex()
|
||||||
|
|
||||||
|
private val _sessionExpired = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||||
|
override val sessionExpiredFlow: SharedFlow<Unit> = _sessionExpired.asSharedFlow()
|
||||||
|
|
||||||
|
// --- TokenManager ---
|
||||||
|
|
||||||
|
override suspend fun getAccessToken(): String? = cachedAccessToken
|
||||||
|
|
||||||
|
override suspend fun setTokens(accessToken: String, refreshToken: String) {
|
||||||
|
cachedAccessToken = accessToken
|
||||||
|
prefs.edit()
|
||||||
|
.putString(KEY_ACCESS_TOKEN, accessToken)
|
||||||
|
.putString(KEY_REFRESH_TOKEN, refreshToken)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun refreshAccessToken(): Boolean = refreshMutex.withLock {
|
||||||
|
// Re-check cached token: another coroutine may have already refreshed while we waited
|
||||||
|
// on the mutex. If the cached token differs from what triggered the 401, skip refresh.
|
||||||
|
val storedRefreshToken = prefs.getString(KEY_REFRESH_TOKEN, null)
|
||||||
|
if (storedRefreshToken.isNullOrBlank()) {
|
||||||
|
Timber.w("No refresh token available")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return try {
|
||||||
|
// Send refresh token via both Cookie header and request body.
|
||||||
|
// The LibreChat backend reads from req.cookies.refreshToken first,
|
||||||
|
// falling back to req.body.refreshToken. Sending both ensures
|
||||||
|
// compatibility across backend versions.
|
||||||
|
val httpResponse: HttpResponse = refreshClient.get()
|
||||||
|
.post("/api/auth/refresh") {
|
||||||
|
header("Cookie", "refreshToken=$storedRefreshToken")
|
||||||
|
setBody(mapOf("refreshToken" to storedRefreshToken))
|
||||||
|
}
|
||||||
|
|
||||||
|
val body: RefreshResponse = httpResponse.body()
|
||||||
|
|
||||||
|
// The backend may rotate the refresh token via Set-Cookie.
|
||||||
|
// If a new refresh token is present, persist it; otherwise keep the current one.
|
||||||
|
val newRefreshToken = CookieHelper.extractRefreshToken(httpResponse.headers)
|
||||||
|
?: storedRefreshToken
|
||||||
|
|
||||||
|
setTokens(body.token, newRefreshToken)
|
||||||
|
Timber.d("Token refreshed successfully")
|
||||||
|
true
|
||||||
|
} catch (e: KeyStoreException) {
|
||||||
|
Timber.e(e, "Keystore corruption—clearing tokens")
|
||||||
|
clearTokens()
|
||||||
|
false
|
||||||
|
} catch (e: IOException) {
|
||||||
|
Timber.w(e, "Network error during token refresh")
|
||||||
|
false
|
||||||
|
} catch (e: ClientRequestException) {
|
||||||
|
// 401/403 means session is truly expired
|
||||||
|
Timber.w(e, "Auth error during token refresh (status=${e.response.status})")
|
||||||
|
clearTokens()
|
||||||
|
false
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.e(e, "Unexpected error during token refresh")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun clearTokens() {
|
||||||
|
cachedAccessToken = null
|
||||||
|
prefs.edit()
|
||||||
|
.remove(KEY_ACCESS_TOKEN)
|
||||||
|
.remove(KEY_REFRESH_TOKEN)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun emitSessionExpired() {
|
||||||
|
_sessionExpired.tryEmit(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- SecureTokenStorage ---
|
||||||
|
|
||||||
|
override suspend fun getRefreshToken(): String? =
|
||||||
|
prefs.getString(KEY_REFRESH_TOKEN, null)
|
||||||
|
|
||||||
|
override suspend fun storeTokens(accessToken: String, refreshToken: String) {
|
||||||
|
setTokens(accessToken, refreshToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun clearAll() {
|
||||||
|
clearTokens()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val PREFS_NAME = "librechat_tokens"
|
||||||
|
private const val KEY_ACCESS_TOKEN = "access_token"
|
||||||
|
private const val KEY_REFRESH_TOKEN = "refresh_token"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
package com.librechat.android.core.data.db
|
||||||
|
|
||||||
|
import androidx.room.AutoMigration
|
||||||
|
import androidx.room.Database
|
||||||
|
import androidx.room.RoomDatabase
|
||||||
|
import androidx.room.TypeConverters
|
||||||
|
import com.librechat.android.core.data.db.converter.Converters
|
||||||
|
import com.librechat.android.core.data.db.dao.AgentDao
|
||||||
|
import com.librechat.android.core.data.db.dao.ConversationDao
|
||||||
|
import com.librechat.android.core.data.db.dao.ConversationTagDao
|
||||||
|
import com.librechat.android.core.data.db.dao.DraftDao
|
||||||
|
import com.librechat.android.core.data.db.dao.FileDao
|
||||||
|
import com.librechat.android.core.data.db.dao.MessageDao
|
||||||
|
import com.librechat.android.core.data.db.dao.PresetDao
|
||||||
|
import com.librechat.android.core.data.db.entity.AgentEntity
|
||||||
|
import com.librechat.android.core.data.db.entity.ConversationEntity
|
||||||
|
import com.librechat.android.core.data.db.entity.ConversationTagEntity
|
||||||
|
import com.librechat.android.core.data.db.entity.DraftEntity
|
||||||
|
import com.librechat.android.core.data.db.entity.FileEntity
|
||||||
|
import com.librechat.android.core.data.db.entity.MessageEntity
|
||||||
|
import com.librechat.android.core.data.db.entity.PresetEntity
|
||||||
|
|
||||||
|
@Database(
|
||||||
|
entities = [
|
||||||
|
ConversationEntity::class,
|
||||||
|
MessageEntity::class,
|
||||||
|
FileEntity::class,
|
||||||
|
AgentEntity::class,
|
||||||
|
PresetEntity::class,
|
||||||
|
ConversationTagEntity::class,
|
||||||
|
DraftEntity::class,
|
||||||
|
],
|
||||||
|
version = 3,
|
||||||
|
exportSchema = true,
|
||||||
|
autoMigrations = [
|
||||||
|
AutoMigration(from = 1, to = 2),
|
||||||
|
AutoMigration(from = 2, to = 3),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@TypeConverters(Converters::class)
|
||||||
|
abstract class LibreChatDatabase : RoomDatabase() {
|
||||||
|
abstract fun conversationDao(): ConversationDao
|
||||||
|
abstract fun messageDao(): MessageDao
|
||||||
|
abstract fun fileDao(): FileDao
|
||||||
|
abstract fun agentDao(): AgentDao
|
||||||
|
abstract fun presetDao(): PresetDao
|
||||||
|
abstract fun conversationTagDao(): ConversationTagDao
|
||||||
|
abstract fun draftDao(): DraftDao
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
package com.librechat.android.core.data.db.converter
|
||||||
|
|
||||||
|
import androidx.room.TypeConverter
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
class Converters {
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun fromStringList(value: List<String>): String = json.encodeToString(value)
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun toStringList(value: String): List<String> =
|
||||||
|
try { json.decodeFromString(value) } catch (_: Exception) { emptyList() }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
package com.librechat.android.core.data.db.dao
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Query
|
||||||
|
import androidx.room.Upsert
|
||||||
|
import com.librechat.android.core.data.db.entity.AgentEntity
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface AgentDao {
|
||||||
|
@Query("SELECT * FROM agents ORDER BY isPromoted DESC, name ASC")
|
||||||
|
fun getAll(): Flow<List<AgentEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM agents WHERE id = :id")
|
||||||
|
suspend fun getById(id: String): AgentEntity?
|
||||||
|
|
||||||
|
@Upsert
|
||||||
|
suspend fun upsert(agent: AgentEntity)
|
||||||
|
|
||||||
|
@Upsert
|
||||||
|
suspend fun upsertAll(agents: List<AgentEntity>)
|
||||||
|
|
||||||
|
@Query("DELETE FROM agents")
|
||||||
|
suspend fun deleteAll()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
package com.librechat.android.core.data.db.dao
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Query
|
||||||
|
import androidx.room.Upsert
|
||||||
|
import com.librechat.android.core.data.db.entity.ConversationEntity
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface ConversationDao {
|
||||||
|
@Query("SELECT * FROM conversations WHERE user = :userId AND isArchived = :isArchived ORDER BY updatedAt DESC LIMIT :limit")
|
||||||
|
fun getConversations(userId: String, isArchived: Boolean = false, limit: Int = 25): Flow<List<ConversationEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM conversations WHERE isArchived = :isArchived ORDER BY updatedAt DESC")
|
||||||
|
fun getAllConversations(isArchived: Boolean = false): Flow<List<ConversationEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM conversations WHERE conversationId = :id")
|
||||||
|
suspend fun getById(id: String): ConversationEntity?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM conversations WHERE conversationId = :id")
|
||||||
|
fun observeById(id: String): Flow<ConversationEntity?>
|
||||||
|
|
||||||
|
@Upsert
|
||||||
|
suspend fun upsert(conversation: ConversationEntity)
|
||||||
|
|
||||||
|
@Upsert
|
||||||
|
suspend fun upsertAll(conversations: List<ConversationEntity>)
|
||||||
|
|
||||||
|
@Query("DELETE FROM conversations WHERE conversationId = :id")
|
||||||
|
suspend fun deleteById(id: String)
|
||||||
|
|
||||||
|
@Query("DELETE FROM conversations WHERE user = :userId")
|
||||||
|
suspend fun deleteAllForUser(userId: String)
|
||||||
|
|
||||||
|
@Query("UPDATE conversations SET title = :title, updatedAt = :updatedAt WHERE conversationId = :id")
|
||||||
|
suspend fun updateTitle(id: String, title: String, updatedAt: Long)
|
||||||
|
|
||||||
|
@Query("UPDATE conversations SET isArchived = :isArchived, updatedAt = :updatedAt WHERE conversationId = :id")
|
||||||
|
suspend fun updateArchived(id: String, isArchived: Boolean, updatedAt: Long)
|
||||||
|
|
||||||
|
@Query("DELETE FROM conversations")
|
||||||
|
suspend fun deleteAll()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
package com.librechat.android.core.data.db.dao
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Query
|
||||||
|
import androidx.room.Upsert
|
||||||
|
import com.librechat.android.core.data.db.entity.ConversationTagEntity
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface ConversationTagDao {
|
||||||
|
@Query("SELECT * FROM conversation_tags WHERE user = :userId ORDER BY position ASC")
|
||||||
|
fun getTagsForUser(userId: String): Flow<List<ConversationTagEntity>>
|
||||||
|
|
||||||
|
@Upsert
|
||||||
|
suspend fun upsert(tag: ConversationTagEntity)
|
||||||
|
|
||||||
|
@Upsert
|
||||||
|
suspend fun upsertAll(tags: List<ConversationTagEntity>)
|
||||||
|
|
||||||
|
@Query("DELETE FROM conversation_tags WHERE id = :id")
|
||||||
|
suspend fun deleteById(id: Long)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
package com.librechat.android.core.data.db.dao
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Query
|
||||||
|
import androidx.room.Upsert
|
||||||
|
import com.librechat.android.core.data.db.entity.DraftEntity
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface DraftDao {
|
||||||
|
@Query("SELECT * FROM drafts WHERE conversation_id = :conversationId")
|
||||||
|
suspend fun getDraft(conversationId: String): DraftEntity?
|
||||||
|
|
||||||
|
@Upsert
|
||||||
|
suspend fun upsertDraft(draft: DraftEntity)
|
||||||
|
|
||||||
|
@Query("DELETE FROM drafts WHERE conversation_id = :conversationId")
|
||||||
|
suspend fun deleteDraft(conversationId: String)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM drafts ORDER BY updated_at DESC")
|
||||||
|
fun observeAllDrafts(): Flow<List<DraftEntity>>
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
package com.librechat.android.core.data.db.dao
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Query
|
||||||
|
import androidx.room.Upsert
|
||||||
|
import com.librechat.android.core.data.db.entity.FileEntity
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface FileDao {
|
||||||
|
@Query("SELECT * FROM files WHERE user = :userId ORDER BY createdAt DESC")
|
||||||
|
fun getFilesForUser(userId: String): Flow<List<FileEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM files WHERE fileId = :fileId")
|
||||||
|
suspend fun getById(fileId: String): FileEntity?
|
||||||
|
|
||||||
|
@Upsert
|
||||||
|
suspend fun upsert(file: FileEntity)
|
||||||
|
|
||||||
|
@Upsert
|
||||||
|
suspend fun upsertAll(files: List<FileEntity>)
|
||||||
|
|
||||||
|
@Query("DELETE FROM files WHERE fileId = :fileId")
|
||||||
|
suspend fun deleteById(fileId: String)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
package com.librechat.android.core.data.db.dao
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Query
|
||||||
|
import androidx.room.Upsert
|
||||||
|
import com.librechat.android.core.data.db.entity.MessageEntity
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface MessageDao {
|
||||||
|
@Query("SELECT * FROM messages WHERE conversationId = :conversationId ORDER BY createdAt ASC")
|
||||||
|
fun getMessagesForConversation(conversationId: String): Flow<List<MessageEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM messages WHERE messageId = :messageId")
|
||||||
|
suspend fun getById(messageId: String): MessageEntity?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM messages WHERE conversationId = :conversationId AND parentMessageId = :parentMessageId ORDER BY createdAt ASC")
|
||||||
|
suspend fun getSiblings(conversationId: String, parentMessageId: String): List<MessageEntity>
|
||||||
|
|
||||||
|
@Upsert
|
||||||
|
suspend fun upsert(message: MessageEntity)
|
||||||
|
|
||||||
|
@Upsert
|
||||||
|
suspend fun upsertAll(messages: List<MessageEntity>)
|
||||||
|
|
||||||
|
@Query("DELETE FROM messages WHERE messageId = :messageId")
|
||||||
|
suspend fun deleteById(messageId: String)
|
||||||
|
|
||||||
|
@Query("DELETE FROM messages WHERE conversationId = :conversationId")
|
||||||
|
suspend fun deleteAllForConversation(conversationId: String)
|
||||||
|
|
||||||
|
@androidx.room.Transaction
|
||||||
|
suspend fun replaceAllForConversation(conversationId: String, messages: List<MessageEntity>) {
|
||||||
|
deleteAllForConversation(conversationId)
|
||||||
|
upsertAll(messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Query("UPDATE messages SET feedback = :feedback WHERE messageId = :messageId")
|
||||||
|
suspend fun updateFeedback(messageId: String, feedback: String?)
|
||||||
|
|
||||||
|
@Query("UPDATE messages SET text = :text, content = NULL WHERE messageId = :messageId")
|
||||||
|
suspend fun updateText(messageId: String, text: String)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
package com.librechat.android.core.data.db.dao
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Query
|
||||||
|
import androidx.room.Upsert
|
||||||
|
import com.librechat.android.core.data.db.entity.PresetEntity
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface PresetDao {
|
||||||
|
@Query("SELECT * FROM presets ORDER BY `order` ASC, title ASC")
|
||||||
|
fun getAll(): Flow<List<PresetEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM presets WHERE presetId = :presetId")
|
||||||
|
suspend fun getById(presetId: String): PresetEntity?
|
||||||
|
|
||||||
|
@Upsert
|
||||||
|
suspend fun upsert(preset: PresetEntity)
|
||||||
|
|
||||||
|
@Query("DELETE FROM presets WHERE presetId = :presetId")
|
||||||
|
suspend fun deleteById(presetId: String)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
package com.librechat.android.core.data.db.entity
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
@Entity(tableName = "agents")
|
||||||
|
data class AgentEntity(
|
||||||
|
@PrimaryKey
|
||||||
|
val id: String,
|
||||||
|
val name: String?,
|
||||||
|
val description: String?,
|
||||||
|
val avatar: String?,
|
||||||
|
val provider: String,
|
||||||
|
val model: String,
|
||||||
|
val category: String?,
|
||||||
|
val authorName: String?,
|
||||||
|
val isPromoted: Boolean = false,
|
||||||
|
val conversationStarters: String?,
|
||||||
|
val tools: String?,
|
||||||
|
val updatedAt: Long,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
package com.librechat.android.core.data.db.entity
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.Index
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "conversations",
|
||||||
|
indices = [
|
||||||
|
Index(value = ["isArchived", "updatedAt"]),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
data class ConversationEntity(
|
||||||
|
@PrimaryKey
|
||||||
|
val conversationId: String,
|
||||||
|
val title: String,
|
||||||
|
val user: String,
|
||||||
|
val endpoint: String?,
|
||||||
|
val endpointType: String?,
|
||||||
|
val model: String?,
|
||||||
|
val agentId: String?,
|
||||||
|
val isArchived: Boolean = false,
|
||||||
|
val tags: String,
|
||||||
|
val iconURL: String?,
|
||||||
|
val greeting: String?,
|
||||||
|
val modelParams: String?,
|
||||||
|
val createdAt: Long,
|
||||||
|
val updatedAt: Long,
|
||||||
|
val lastSyncedAt: Long = 0,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
package com.librechat.android.core.data.db.entity
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.Index
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "conversation_tags",
|
||||||
|
indices = [Index(value = ["tag", "user"], unique = true)],
|
||||||
|
)
|
||||||
|
data class ConversationTagEntity(
|
||||||
|
@PrimaryKey(autoGenerate = true)
|
||||||
|
val id: Long = 0,
|
||||||
|
val tag: String,
|
||||||
|
val user: String,
|
||||||
|
val description: String?,
|
||||||
|
val count: Int = 0,
|
||||||
|
val position: Int = 0,
|
||||||
|
val createdAt: Long,
|
||||||
|
val updatedAt: Long,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
package com.librechat.android.core.data.db.entity
|
||||||
|
|
||||||
|
import androidx.room.ColumnInfo
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
@Entity(tableName = "drafts")
|
||||||
|
data class DraftEntity(
|
||||||
|
@PrimaryKey
|
||||||
|
@ColumnInfo(name = "conversation_id")
|
||||||
|
val conversationId: String,
|
||||||
|
@ColumnInfo(name = "text")
|
||||||
|
val text: String,
|
||||||
|
@ColumnInfo(name = "updated_at")
|
||||||
|
val updatedAt: Long = System.currentTimeMillis(),
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
package com.librechat.android.core.data.db.entity
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.Index
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "files",
|
||||||
|
indices = [
|
||||||
|
Index("conversationId"),
|
||||||
|
Index("messageId"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
data class FileEntity(
|
||||||
|
@PrimaryKey
|
||||||
|
val fileId: String,
|
||||||
|
val user: String,
|
||||||
|
val conversationId: String?,
|
||||||
|
val messageId: String?,
|
||||||
|
val filename: String,
|
||||||
|
val filepath: String,
|
||||||
|
val type: String,
|
||||||
|
val bytes: Long,
|
||||||
|
val source: String,
|
||||||
|
val width: Int?,
|
||||||
|
val height: Int?,
|
||||||
|
val createdAt: Long,
|
||||||
|
val updatedAt: Long,
|
||||||
|
val localPath: String? = null,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
package com.librechat.android.core.data.db.entity
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.Index
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "messages",
|
||||||
|
indices = [
|
||||||
|
Index("conversationId"),
|
||||||
|
Index("parentMessageId"),
|
||||||
|
Index(value = ["conversationId", "createdAt"]),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
data class MessageEntity(
|
||||||
|
@PrimaryKey
|
||||||
|
val messageId: String,
|
||||||
|
val conversationId: String,
|
||||||
|
val parentMessageId: String?,
|
||||||
|
val sender: String?,
|
||||||
|
val text: String?,
|
||||||
|
val content: String?,
|
||||||
|
val isCreatedByUser: Boolean,
|
||||||
|
val model: String?,
|
||||||
|
val endpoint: String?,
|
||||||
|
val iconURL: String?,
|
||||||
|
val unfinished: Boolean = false,
|
||||||
|
val error: Boolean = false,
|
||||||
|
val finishReason: String?,
|
||||||
|
val tokenCount: Int?,
|
||||||
|
val feedback: String?,
|
||||||
|
val files: String?,
|
||||||
|
val attachments: String?,
|
||||||
|
val metadata: String?,
|
||||||
|
val createdAt: Long,
|
||||||
|
val updatedAt: Long,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
package com.librechat.android.core.data.db.entity
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
@Entity(tableName = "presets")
|
||||||
|
data class PresetEntity(
|
||||||
|
@PrimaryKey
|
||||||
|
val presetId: String,
|
||||||
|
val title: String,
|
||||||
|
val endpoint: String?,
|
||||||
|
val model: String?,
|
||||||
|
val isDefault: Boolean = false,
|
||||||
|
val order: Int?,
|
||||||
|
val params: String?,
|
||||||
|
val createdAt: Long,
|
||||||
|
val updatedAt: Long,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,173 @@
|
||||||
|
package com.librechat.android.core.data.di
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import com.librechat.android.core.data.datastore.ServerDataStore
|
||||||
|
import com.librechat.android.core.data.datastore.TokenDataStore
|
||||||
|
import com.librechat.android.core.data.repository.AgentRepository
|
||||||
|
import com.librechat.android.core.data.repository.AgentRepositoryImpl
|
||||||
|
import com.librechat.android.core.data.repository.ApiKeyRepository
|
||||||
|
import com.librechat.android.core.data.repository.ApiKeyRepositoryImpl
|
||||||
|
import com.librechat.android.core.data.repository.AuthRepository
|
||||||
|
import com.librechat.android.core.data.repository.BalanceRepository
|
||||||
|
import com.librechat.android.core.data.repository.BalanceRepositoryImpl
|
||||||
|
import com.librechat.android.core.data.repository.BannerRepository
|
||||||
|
import com.librechat.android.core.data.repository.BannerRepositoryImpl
|
||||||
|
import com.librechat.android.core.data.repository.SearchRepository
|
||||||
|
import com.librechat.android.core.data.repository.SearchRepositoryImpl
|
||||||
|
import com.librechat.android.core.data.repository.TagRepository
|
||||||
|
import com.librechat.android.core.data.repository.TagRepositoryImpl
|
||||||
|
import com.librechat.android.core.data.repository.AuthRepositoryImpl
|
||||||
|
import com.librechat.android.core.data.repository.ChatRepository
|
||||||
|
import com.librechat.android.core.data.repository.ChatRepositoryImpl
|
||||||
|
import com.librechat.android.core.data.repository.ConfigRepository
|
||||||
|
import com.librechat.android.core.data.repository.ConfigRepositoryImpl
|
||||||
|
import com.librechat.android.core.data.repository.ConversationRepository
|
||||||
|
import com.librechat.android.core.data.repository.ConversationRepositoryImpl
|
||||||
|
import com.librechat.android.core.data.repository.DraftRepository
|
||||||
|
import com.librechat.android.core.data.repository.DraftRepositoryImpl
|
||||||
|
import com.librechat.android.core.data.repository.FileRepository
|
||||||
|
import com.librechat.android.core.data.repository.FileRepositoryImpl
|
||||||
|
import com.librechat.android.core.data.repository.KeyRepository
|
||||||
|
import com.librechat.android.core.data.repository.KeyRepositoryImpl
|
||||||
|
import com.librechat.android.core.data.repository.McpRepository
|
||||||
|
import com.librechat.android.core.data.repository.McpRepositoryImpl
|
||||||
|
import com.librechat.android.core.data.repository.MemoryRepository
|
||||||
|
import com.librechat.android.core.data.repository.MemoryRepositoryImpl
|
||||||
|
import com.librechat.android.core.data.repository.MessageRepository
|
||||||
|
import com.librechat.android.core.data.repository.MessageRepositoryImpl
|
||||||
|
import com.librechat.android.core.data.repository.PresetRepository
|
||||||
|
import com.librechat.android.core.data.repository.PresetRepositoryImpl
|
||||||
|
import com.librechat.android.core.data.repository.PromptRepository
|
||||||
|
import com.librechat.android.core.data.repository.PromptRepositoryImpl
|
||||||
|
import com.librechat.android.core.data.repository.ShareRepository
|
||||||
|
import com.librechat.android.core.data.repository.ShareRepositoryImpl
|
||||||
|
import com.librechat.android.core.data.repository.SpeechRepository
|
||||||
|
import com.librechat.android.core.data.repository.SpeechRepositoryImpl
|
||||||
|
import com.librechat.android.core.data.repository.UserRepository
|
||||||
|
import com.librechat.android.core.data.repository.UserRepositoryImpl
|
||||||
|
import com.librechat.android.core.network.client.SecureTokenStorage
|
||||||
|
import com.librechat.android.core.network.client.ServerUrlProvider
|
||||||
|
import com.librechat.android.core.network.client.TokenManager
|
||||||
|
import dagger.Binds
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
private val Context.settingsDataStore: DataStore<Preferences> by preferencesDataStore(
|
||||||
|
name = "librechat_settings",
|
||||||
|
)
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
abstract class DataModule {
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindTokenManager(impl: TokenDataStore): TokenManager
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindSecureTokenStorage(impl: TokenDataStore): SecureTokenStorage
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindServerUrlProvider(impl: ServerDataStore): ServerUrlProvider
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindAuthRepository(impl: AuthRepositoryImpl): AuthRepository
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindBalanceRepository(impl: BalanceRepositoryImpl): BalanceRepository
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindConfigRepository(impl: ConfigRepositoryImpl): ConfigRepository
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindConversationRepository(impl: ConversationRepositoryImpl): ConversationRepository
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindDraftRepository(impl: DraftRepositoryImpl): DraftRepository
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindMessageRepository(impl: MessageRepositoryImpl): MessageRepository
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindChatRepository(impl: ChatRepositoryImpl): ChatRepository
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindAgentRepository(impl: AgentRepositoryImpl): AgentRepository
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindTagRepository(impl: TagRepositoryImpl): TagRepository
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindSearchRepository(impl: SearchRepositoryImpl): SearchRepository
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindFileRepository(impl: FileRepositoryImpl): FileRepository
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindKeyRepository(impl: KeyRepositoryImpl): KeyRepository
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindApiKeyRepository(impl: ApiKeyRepositoryImpl): ApiKeyRepository
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindMcpRepository(impl: McpRepositoryImpl): McpRepository
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindMemoryRepository(impl: MemoryRepositoryImpl): MemoryRepository
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindPresetRepository(impl: PresetRepositoryImpl): PresetRepository
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindPromptRepository(impl: PromptRepositoryImpl): PromptRepository
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindShareRepository(impl: ShareRepositoryImpl): ShareRepository
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindSpeechRepository(impl: SpeechRepositoryImpl): SpeechRepository
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindUserRepository(impl: UserRepositoryImpl): UserRepository
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindBannerRepository(impl: BannerRepositoryImpl): BannerRepository
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideDataStore(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
): DataStore<Preferences> = context.settingsDataStore
|
||||||
|
}
|
||||||
|
}
|
||||||