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
|
||||
}
|
||||
}
|
||||