Compare commits

..

132 Commits

Author SHA1 Message Date
Timothy Jaeryang Baek e40766430a fix: windows python path 2025-07-29 12:08:09 +04:00
Timothy Jaeryang Baek be9d866c7c chore: build 2025-07-29 12:00:15 +04:00
Timothy Jaeryang Baek 7b2b816816 refac 2025-07-29 11:36:08 +04:00
Timothy Jaeryang Baek 67f3ecbc53 refac 2025-07-29 11:25:43 +04:00
Tim Jaeryang Baek b14b1dedeb Merge pull request #34 from tjbck/main
chore: build
2025-07-29 11:24:57 +04:00
Timothy Jaeryang Baek 33e0d55c07 Update build.yaml 2025-07-29 11:23:47 +04:00
Timothy Jaeryang Baek c7e53c12b5 Update build.yaml 2025-07-29 10:55:02 +04:00
Timothy Jaeryang Baek 5c326e335a Update build.yaml 2025-07-29 02:10:00 +04:00
Timothy Jaeryang Baek da51625f90 refac 2025-07-29 01:59:49 +04:00
Timothy Jaeryang Baek 3d4108b349 refac 2025-07-29 01:54:16 +04:00
Timothy Jaeryang Baek bfe7aa3915 chore 2025-07-29 01:50:04 +04:00
Timothy Jaeryang Baek 1db95d07a1 chore 2025-07-29 01:36:24 +04:00
Timothy Jaeryang Baek 96dd41fbc5 refac 2025-07-29 01:35:04 +04:00
Timothy Jaeryang Baek 4ce92b6571 refac 2025-07-29 00:42:21 +04:00
Timothy Jaeryang Baek 7509bc3f27 refac: rm uv 2025-07-29 00:26:23 +04:00
Timothy Jaeryang Baek 191d844aaf refac 2025-07-28 23:47:21 +04:00
Timothy Jaeryang Baek fea2bf4c29 refac 2025-07-28 23:29:16 +04:00
Timothy Jaeryang Baek 5247a22c74 refac 2025-07-28 23:24:41 +04:00
Timothy Jaeryang Baek 641acc3bb7 refac 2025-07-28 22:36:30 +04:00
Timothy Jaeryang Baek 02a4093f2c refac 2025-07-28 22:33:50 +04:00
Timothy Jaeryang Baek cd03451baa feat: persistent config 2025-07-28 22:11:09 +04:00
Timothy Jaeryang Baek 6bab179828 refac 2025-07-28 20:07:29 +04:00
Timothy Jaeryang Baek db3c27a006 refac 2025-07-28 19:02:22 +04:00
Timothy Jaeryang Baek 3e71eb9367 feat: auto-update 2025-07-28 19:01:02 +04:00
Timothy Jaeryang Baek ffa1d20f19 refac 2025-07-28 18:46:27 +04:00
Timothy Jaeryang Baek d2b384e649 refac 2025-07-28 18:44:07 +04:00
Timothy Jaeryang Baek 84a5648988 refac 2025-07-28 18:35:12 +04:00
Timothy Jaeryang Baek 1f9011de16 refac 2025-07-28 18:30:10 +04:00
Timothy Jaeryang Baek bb3799c30b refac 2025-07-28 18:29:18 +04:00
Timothy Jaeryang Baek c8e6c275f5 refac 2025-07-28 17:46:03 +04:00
Timothy Jaeryang Baek 1fdd427853 refac 2025-07-28 17:35:14 +04:00
Timothy Jaeryang Baek 97b4db2905 refac 2025-07-28 15:34:34 +04:00
Timothy Jaeryang Baek 8b852618e6 refac 2025-07-28 10:58:11 +04:00
Timothy Jaeryang Baek 6b05d11c63 refac 2025-07-27 23:47:49 +04:00
Timothy Jaeryang Baek d91d43ae2d refac 2025-07-27 23:37:37 +04:00
Timothy Jaeryang Baek 4717fa6a11 refac 2025-07-27 23:30:13 +04:00
Timothy Jaeryang Baek 9365fc1a14 refac 2025-07-27 16:16:17 +04:00
Timothy Jaeryang Baek c456ee6d38 build dir 2025-07-27 16:02:05 +04:00
Timothy Jaeryang Baek bfddadb887 chore 2025-07-27 16:01:58 +04:00
Timothy Jaeryang Baek 9a449b9cda reboot 2025-07-27 14:41:30 +04:00
Timothy Jaeryang Baek cdee70b863 reset 2025-07-26 17:49:05 +04:00
Timothy Jaeryang Baek df1a4b6f75 doc: readme 2025-05-26 20:10:28 +04:00
Timothy Jaeryang Baek d48ed8abeb reboot 2025-05-26 20:00:49 +04:00
Timothy Jaeryang Baek c36ab799ef enh: toast 2025-01-17 22:34:54 -08:00
Timothy Jaeryang Baek ab0736728a enh: utils 2025-01-17 22:34:39 -08:00
Timothy Jaeryang Baek 9c96f896d0 refac: show installation progress 2025-01-17 22:21:10 -08:00
Timothy Jaeryang Baek 9c20ed3de9 refac 2025-01-17 17:06:11 -08:00
Timothy Jaeryang Baek 7300ef8a4b Merge pull request #20 from tjbck/main
enh: macos
2025-01-16 19:29:19 -08:00
Timothy Jaeryang Baek e85142da05 enh: macos dmg installer 2025-01-16 19:28:31 -08:00
Timothy Jaeryang Baek 19d7cc76b1 enh: macos code sign 2025-01-16 17:41:16 -08:00
Timothy Jaeryang Baek 144e34e0aa refac: styling 2025-01-15 21:28:15 -08:00
Timothy Jaeryang Baek 328e130f50 Merge pull request #19 from tjbck/main
enh: windows code signing
2025-01-15 19:50:58 -08:00
Timothy Jaeryang Baek a4307e9fa7 infra: disable workflow 2025-01-15 19:50:10 -08:00
Timothy Jaeryang Baek 20ffd8b7cf refac 2025-01-15 19:13:42 -08:00
Timothy Jaeryang Baek fa260904b8 refac: workflow 2025-01-15 19:01:40 -08:00
Timothy Jaeryang Baek 805b9c3e8d refac 2025-01-15 18:48:51 -08:00
Timothy Jaeryang Baek 9efcf44d09 refac 2025-01-15 17:04:59 -08:00
Timothy Jaeryang Baek 9617675916 refac 2025-01-15 16:56:47 -08:00
Timothy Jaeryang Baek 225e3dd13a Update build.yaml 2025-01-15 16:20:44 -08:00
Timothy Jaeryang Baek 096aa3189a Update build.yaml 2025-01-15 16:14:09 -08:00
Timothy Jaeryang Baek 8bf4582489 refac 2025-01-15 16:04:01 -08:00
Timothy Jaeryang Baek 1d5cba474e Update build.yaml 2025-01-15 15:44:25 -08:00
Timothy Jaeryang Baek 4ad9f5fbe6 enh: windows code-signing 2025-01-15 15:32:26 -08:00
Timothy Jaeryang Baek 14f3cf8dac infra: enable workflow 2025-01-14 17:39:17 -08:00
Timothy Jaeryang Baek 65f7e970ec refac: comments 2025-01-14 17:38:53 -08:00
Timothy Jaeryang Baek a1a642429c fix 2025-01-14 17:34:09 -08:00
Timothy Jaeryang Baek a0dcc3a927 fix: windows 2025-01-14 17:28:52 -08:00
Timothy Jaeryang Baek fd3ef1b0bd refac 2025-01-14 17:12:01 -08:00
Timothy Jaeryang Baek bc495914b9 fix 2025-01-14 16:53:09 -08:00
Timothy Jaeryang Baek fde14e001e Merge pull request #13 from tjbck/main
refac
2025-01-14 16:49:43 -08:00
Timothy Jaeryang Baek 58968c9df0 refac: disable build 2025-01-14 16:49:00 -08:00
Timothy Jaeryang Baek 97bab72da8 refac 2025-01-14 16:48:44 -08:00
Timothy Jaeryang Baek bd753e8651 refac 2025-01-14 13:56:04 -08:00
Timothy Jaeryang Baek ecbaf0b9b3 Update build.yaml 2025-01-14 02:09:33 -08:00
Timothy Jaeryang Baek b60723db4c Update build.yaml 2025-01-14 02:06:45 -08:00
Timothy Jaeryang Baek 3e2305d0f5 Update build.yaml 2025-01-14 02:04:07 -08:00
Timothy Jaeryang Baek 9586543c15 Update build.yaml 2025-01-14 02:00:03 -08:00
Timothy Jaeryang Baek 98b5df6185 Update build.yaml 2025-01-14 01:59:01 -08:00
Timothy Jaeryang Baek 3a57140cd1 Update build.yaml 2025-01-14 01:53:09 -08:00
Timothy Jaeryang Baek 2803d0fdb0 enh: arm workflow 2025-01-14 01:46:21 -08:00
Timothy Jaeryang Baek 7048bb0a46 Update build.yaml 2025-01-14 01:44:58 -08:00
Timothy Jaeryang Baek 19ea3087d7 Update forge.config.ts 2025-01-14 01:36:40 -08:00
Timothy Jaeryang Baek b0fe43818a Create build.yaml 2025-01-14 01:34:21 -08:00
Timothy Jaeryang Baek fa610e6327 refac 2025-01-14 01:12:50 -08:00
Timothy Jaeryang Baek 29b75fd9b0 refac: styling 2025-01-14 01:00:57 -08:00
Timothy Jaeryang Baek 77b6706add refac 2025-01-14 00:50:00 -08:00
Timothy Jaeryang Baek 347ff2aa18 refac 2025-01-14 00:42:05 -08:00
Timothy Jaeryang Baek 1a9c2ac192 refac 2025-01-14 00:00:44 -08:00
Timothy Jaeryang Baek 30edf4d3c8 refac 2025-01-13 23:55:36 -08:00
Timothy Jaeryang Baek 825516be11 refac 2025-01-13 22:59:13 -08:00
Timothy Jaeryang Baek c19a17a820 refac: path 2025-01-13 22:41:55 -08:00
Timothy Jaeryang Baek 25b98ea31b refac 2025-01-13 19:52:51 -08:00
Timothy Jaeryang Baek f5c69a0d72 enh: tray menu 2025-01-13 16:05:16 -08:00
Timothy Jaeryang Baek 25f2671d10 feat: connect to remote server button 2025-01-13 15:22:42 -08:00
Timothy Jaeryang Baek d1c9195bc9 enh: show logs button 2025-01-12 23:41:02 -08:00
Timothy Jaeryang Baek 646270d4b1 refac: styling 2025-01-12 23:25:00 -08:00
Timothy Jaeryang Baek 7b69e0a6bb Update dev.md 2025-01-12 19:18:52 -08:00
Timothy Jaeryang Baek ff8fa70dc4 doc: readme 2025-01-12 13:53:22 -08:00
Timothy Jaeryang Baek 3b82d15f0c Update README.md 2025-01-12 13:33:16 -08:00
Timothy Jaeryang Baek 3e7573b2fa Update README.md 2025-01-12 13:32:34 -08:00
Timothy Jaeryang Baek a54d9701d5 refac: styling 2025-01-12 13:11:09 -08:00
Timothy Jaeryang Baek 8f499475fd refac 2025-01-12 12:53:55 -08:00
Timothy Jaeryang Baek 58edd82696 enh: install help message 2025-01-12 12:24:12 -08:00
Timothy Jaeryang Baek 4c267250a9 refac: remove uv
clang is required
2025-01-12 12:15:32 -08:00
Timothy Jaeryang Baek b20ac2d4a6 fix: app path 2025-01-12 11:57:35 -08:00
Timothy Jaeryang Baek 1783fa59e3 refac 2025-01-12 11:57:18 -08:00
Timothy Jaeryang Baek 81716fcc00 enh: main 2025-01-12 11:44:58 -08:00
Timothy Jaeryang Baek 6d6b84d43c refac: styling 2025-01-12 11:44:43 -08:00
Timothy Jaeryang Baek 5b1eb61ae2 enh: minify image 2025-01-12 11:44:34 -08:00
Timothy Jaeryang Baek 8180cb5bb7 revert: git 2025-01-12 00:07:49 -08:00
Timothy Jaeryang Baek fb46954a2b enh: add git 2025-01-11 23:09:52 -08:00
Timothy Jaeryang Baek ea7ac10364 refac 2025-01-11 22:58:50 -08:00
Timothy Jaeryang Baek 73740d7d73 refac 2025-01-11 22:55:04 -08:00
Timothy Jaeryang Baek 8d97c049e6 refac 2025-01-11 22:01:14 -08:00
Timothy Jaeryang Baek 37e1b6bd1d refac: styling 2025-01-11 21:55:31 -08:00
Timothy Jaeryang Baek 37cdf72959 refac 2025-01-11 19:43:10 -08:00
Timothy Jaeryang Baek c5f5681f94 refac 2025-01-11 19:36:23 -08:00
Timothy Jaeryang Baek e91a8ab4a7 chore: format 2025-01-11 18:13:43 -08:00
Timothy Jaeryang Baek 1825957bd3 Create dev.md 2025-01-11 16:59:58 -08:00
Timothy Jaeryang Baek 82c68c4bb0 refac 2025-01-11 16:42:27 -08:00
Timothy Jaeryang Baek f33baf219f refac: styling 2025-01-11 14:46:56 -08:00
Timothy Jaeryang Baek 052a981077 refac 2025-01-11 14:26:14 -08:00
Timothy Jaeryang Baek cd1e0904cc Update package-lock.json 2025-01-11 13:19:02 -08:00
Timothy Jaeryang Baek 9f09e26247 refac 2025-01-11 13:18:46 -08:00
Timothy Jaeryang Baek 4e99cf651e refac: python tar 2025-01-11 12:11:47 -08:00
Timothy Jaeryang Baek be467b390c refac 2025-01-11 10:05:19 -08:00
Timothy Jaeryang Baek 7dc108f830 wip 2025-01-08 23:40:07 -08:00
Timothy Jaeryang Baek ad85a212f9 feat: assets 2025-01-08 22:53:37 -08:00
Timothy Jaeryang Baek 5dbfd7087b refac 2025-01-08 21:09:41 -08:00
Timothy Jaeryang Baek c57cea5932 feat: vite+svelte+tailwind setup 2025-01-08 19:15:46 -08:00
Timothy Jaeryang Baek 1d0bd81560 doc: readme 2025-01-08 18:37:11 -08:00
Timothy Jaeryang Baek 1b885cab0e init 2025-01-08 18:26:12 -08:00
118 changed files with 14503 additions and 22773 deletions
-9
View File
@@ -1,9 +0,0 @@
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
+64 -14
View File
@@ -1,24 +1,74 @@
## Description
# Pull Request Checklist
<!-- Describe your changes in detail. What problem does this solve? -->
**Before submitting, make sure you've checked the following:**
## Related Issues
- [ ] **Target branch:** Please verify that the pull request targets the `dev` branch.
- [ ] **Description:** Provide a concise description of the changes made in this pull request.
- [ ] **Changelog:** Ensure a changelog entry following the format of [Keep a Changelog](https://keepachangelog.com/) is added at the bottom of the PR description.
- [ ] **Documentation:** Have you updated relevant documentation [Open WebUI Docs](https://github.com/open-webui/docs), or other documentation sources?
- [ ] **Dependencies:** Are there any new dependencies? Have you updated the dependency versions in the documentation?
- [ ] **Testing:** Have you written and run sufficient tests to validate the changes?
- [ ] **Code review:** Have you performed a self-review of your code, addressing any coding standard issues and ensuring adherence to the project's coding standards?
- [ ] **Prefix:** To clearly categorize this pull request, prefix the pull request title using one of the following:
- **BREAKING CHANGE**: Significant changes that may affect compatibility
- **build**: Changes that affect the build system or external dependencies
- **ci**: Changes to our continuous integration processes or workflows
- **chore**: Refactor, cleanup, or other non-functional code changes
- **docs**: Documentation update or addition
- **feat**: Introduces a new feature or enhancement to the codebase
- **fix**: Bug fix or error correction
- **i18n**: Internationalization or localization changes
- **perf**: Performance improvement
- **refactor**: Code restructuring for better maintainability, readability, or scalability
- **style**: Changes that do not affect the meaning of the code (white space, formatting, missing semi-colons, etc.)
- **test**: Adding missing tests or correcting existing tests
- **WIP**: Work in progress, a temporary label for incomplete or ongoing work
<!-- Link any related issues: Fixes #123, Closes #456 -->
# Changelog Entry
### Description
- [Concisely describe the changes made in this pull request, including any relevant motivation and impact (e.g., fixing a bug, adding a feature, or improving performance)]
### Added
- [List any new features, functionalities, or additions]
### Changed
- [List any changes, updates, refactorings, or optimizations]
### Deprecated
- [List any deprecated functionality or features that have been removed]
### Removed
- [List any removed features, files, or functionalities]
### Fixed
- [List any fixes, corrections, or bug fixes]
### Security
- [List any new or updated security-related changes, including vulnerability fixes]
### Breaking Changes
- **BREAKING CHANGE**: [List any breaking changes affecting compatibility or functionality]
---
## Contributor License Agreement
### Additional Information
<!--
🚨 DO NOT DELETE THE TEXT BELOW 🚨
Keep the "Contributor License Agreement" confirmation text intact.
Deleting it will trigger the CLA-Bot to INVALIDATE your PR.
- [Insert any additional context, notes, or explanations for the changes]
- [Reference any related issues, commits, or other relevant information]
Your PR will NOT be reviewed or merged until you check the box below confirming that you have read and agree to the terms of the CLA.
-->
### Screenshots or Videos
- [ ] By submitting this pull request, I confirm that I have read and fully agree to the [Contributor License Agreement (CLA)](https://github.com/open-webui/desktop/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms.
- [Attach any relevant screenshots or videos demonstrating the changes]
> [!NOTE]
> Deleting the CLA section will lead to immediate closure of your PR and it will not be merged in.
### Contributor License Agreement
By submitting this pull request, I confirm that I have read and fully agree to the [Contributor License Agreement (CLA)](/CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms.
+189
View File
@@ -0,0 +1,189 @@
name: Build and Release Electron App (electron-builder)
on:
push:
branches:
- main
pull_request:
jobs:
build:
name: Build and Package
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
# - os: ubuntu-latest
# arch: x64
# - os: ubuntu-latest
# arch: arm64
- os: windows-latest
arch: x64
- os: macos-latest
arch: x64
- os: macos-latest
arch: arm64
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: "npm"
- name: Install Dependencies
run: npm ci
- name: Install Apple codesigning certificate
if: ${{ matrix.os == 'macos-latest' }}
env:
BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }}
P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode > $CERTIFICATE_PATH
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security list-keychain -d user -s $KEYCHAIN_PATH
# Build commands
- name: Create Windows Builds
if: ${{ matrix.os == 'windows-latest' }}
run: npm run build:win
- name: Create macOS Builds
if: ${{ matrix.os == 'macos-latest' }}
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: npm run build:mac
- name: Create Linux Builds
if: ${{ matrix.os == 'ubuntu-latest' }}
run: npm run build:linux
- name: Find and Rename Windows Executable
if: ${{ matrix.os == 'windows-latest' }}
shell: pwsh
run: |
$exePath = Get-ChildItem -Path dist -Recurse -Filter "*.exe" | Select-Object -First 1
if (-not $exePath) { throw "Error: No .exe file was found in dist."; }
Write-Host "The found executable is: $($exePath.FullName)"
$destinationPath = "${{ matrix.os }}-${{ matrix.arch }}.exe"
Copy-Item -Path $exePath.FullName -Destination $destinationPath
Write-Host "Copied executable to: $destinationPath"
- name: Find and Rename macOS Package
if: ${{ matrix.os == 'macos-latest' }}
run: |
if [ -d "dist" ]; then
package_file=$(find dist -maxdepth 1 -name "*.dmg" -o -name "*.zip" -o -name "*.pkg" | head -1)
if [ -n "$package_file" ]; then
extension="${package_file##*.}"
cp "$package_file" "${{ matrix.os }}-${{ matrix.arch }}.$extension"
echo "Copied package to: ${{ matrix.os }}-${{ matrix.arch }}.$extension"
else
echo "No macOS package found in dist"
ls -la dist/ || echo "dist directory not found or empty"
fi
else
echo "dist directory not found"
fi
- name: Find and Rename Linux Package
if: ${{ matrix.os == 'ubuntu-latest' }}
run: |
if [ -d "dist" ]; then
package_file=$(find dist -maxdepth 1 -name "*.deb" -o -name "*.rpm" -o -name "*.AppImage" -o -name "*.tar.gz" | head -1)
if [ -n "$package_file" ]; then
extension="${package_file##*.}"
if [[ "$package_file" == *.tar.gz ]]; then
extension="tar.gz"
fi
cp "$package_file" "${{ matrix.os }}-${{ matrix.arch }}.$extension"
echo "Copied package to: ${{ matrix.os }}-${{ matrix.arch }}.$extension"
else
echo "No Linux package found in dist"
ls -la dist/ || echo "dist directory not found or empty"
fi
else
echo "dist directory not found"
fi
# (Optional Windows Signing step remains)
- name: Azure Trusted Signing (Windows Only)
if: ${{ matrix.os == 'windows-latest' }}
uses: azure/trusted-signing-action@v0.5.1
with:
azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
azure-client-id: ${{ secrets.AZURE_CLIENT_ID }}
azure-client-secret: ${{ secrets.AZURE_CLIENT_SECRET }}
endpoint: https://eus.codesigning.azure.net/
trusted-signing-account-name: open-webui
certificate-profile-name: open-webui
files-folder: .
files-folder-filter: exe
- name: List files for debugging
shell: bash
run: |
echo "Files in current directory:"
ls -la
echo "Files in dist directory (if exists):"
ls -la dist/ || echo "dist directory not found"
- name: Upload Artifacts
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.os }}-${{ matrix.arch }}
path: |
${{ matrix.os }}-${{ matrix.arch }}.*
if-no-files-found: warn
release:
needs: build
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Get Short SHA
id: slug
run: echo "sha8=$(echo ${GITHUB_SHA} | cut -c1-8)" >> $GITHUB_OUTPUT
- name: Download Artifacts
uses: actions/download-artifact@v4
- name: List downloaded artifacts
run: |
echo "Downloaded artifacts:"
find . -type f -name "*" | grep -E "\.(exe|zip|dmg|pkg|deb|rpm|AppImage|tar\.gz)$" || echo "No package files found"
ls -la
- name: Create Release
id: create_release
uses: softprops/action-gh-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: build-${{ steps.slug.outputs.sha8 }}
name: Build ${{ steps.slug.outputs.sha8 }}
draft: false
prerelease: false
files: |
**/*.zip
**/*.exe
**/*.dmg
**/*.pkg
**/*.deb
**/*.rpm
**/*.AppImage
**/*.tar.gz
-261
View File
@@ -1,261 +0,0 @@
name: Build and Release Electron App (electron-builder)
on:
push:
branches:
- release
jobs:
compile:
name: Compile (Typecheck + Vite Build)
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
- name: Install Dependencies
run: npm ci
- name: Typecheck and Build
run: npm run build
- name: Upload Compiled Output
uses: actions/upload-artifact@v4
with:
name: compiled-output
path: out/
retention-days: 1
package:
name: Package (${{ matrix.os }}-${{ matrix.arch }})
needs: compile
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
arch: x64
- os: windows-latest
arch: x64
- os: macos-latest
arch: x64
- os: macos-latest
arch: arm64
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
- name: Install Dependencies
run: npm ci
- name: Download Compiled Output
uses: actions/download-artifact@v4
with:
name: compiled-output
path: out/
# ── Flatpak setup (Linux only) ──
- name: Cache Flatpak SDKs
if: matrix.os == 'ubuntu-latest'
uses: actions/cache@v4
with:
path: ~/.local/share/flatpak
key: flatpak-sdk-23.08-electron2-${{ runner.os }}
- name: Install Flatpak build tools
id: flatpak
if: matrix.os == 'ubuntu-latest'
continue-on-error: true
run: |
sudo apt-get update
sudo apt-get install -y flatpak flatpak-builder
flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
flatpak install --user -y flathub org.freedesktop.Platform//23.08 org.freedesktop.Sdk//23.08
flatpak install --user -y flathub org.electronjs.Electron2.BaseApp//23.08
# ── Apple codesigning (macOS only) ──
- name: Install Apple codesigning certificate
id: apple_cert
if: matrix.os == 'macos-latest'
continue-on-error: true
env:
BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }}
P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
if [ -z "$BUILD_CERTIFICATE_BASE64" ]; then
echo "No certificate provided, will build unsigned"
exit 1
fi
CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode > $CERTIFICATE_PATH
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security list-keychain -d user -s $KEYCHAIN_PATH
# ── Platform packaging ──
- name: Package for Windows
id: win_build
if: matrix.os == 'windows-latest'
continue-on-error: true
run: npx electron-builder --win --${{ matrix.arch }} --publish never
- name: Package for Windows (unsigned fallback)
if: matrix.os == 'windows-latest' && steps.win_build.outcome == 'failure'
env:
WIN_CSC_LINK: ''
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
run: |
rm -rf dist/
npx electron-builder --win --${{ matrix.arch }} --publish never
- name: Package for macOS (signed + notarized)
id: mac_build_signed
if: matrix.os == 'macos-latest' && steps.apple_cert.outcome == 'success'
continue-on-error: true
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/
run: npx electron-builder --mac --${{ matrix.arch }} -c.mac.identity=auto -c.mac.notarize=true --publish never
- name: Package for macOS (unsigned fallback)
if: matrix.os == 'macos-latest' && (steps.apple_cert.outcome != 'success' || steps.mac_build_signed.outcome == 'failure')
env:
ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/
run: |
rm -rf dist/
npx electron-builder --mac --${{ matrix.arch }} -c.mac.notarize=false --publish never
- name: Package for Linux
if: matrix.os == 'ubuntu-latest'
run: |
if [ "${{ steps.flatpak.outcome }}" == "success" ]; then
npx electron-builder --linux --${{ matrix.arch }} --publish never
else
echo "Flatpak not available, building without flatpak"
npx electron-builder --linux AppImage deb snap --${{ matrix.arch }} --publish never
fi
# ── Windows code signing ──
- name: Azure Trusted Signing (Windows Only)
if: matrix.os == 'windows-latest'
continue-on-error: true
uses: azure/trusted-signing-action@v0.5.1
with:
azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
azure-client-id: ${{ secrets.AZURE_CLIENT_ID }}
azure-client-secret: ${{ secrets.AZURE_CLIENT_SECRET }}
endpoint: https://eus.codesigning.azure.net/
trusted-signing-account-name: open-webui
certificate-profile-name: open-webui
files-folder: dist
files-folder-filter: exe
# ── Upload release artifacts ──
- name: Upload Artifacts
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.os }}-${{ matrix.arch }}
path: |
dist/*.exe
dist/*.dmg
dist/*.zip
dist/*.pkg
dist/*.deb
dist/*.rpm
dist/*.AppImage
dist/*.snap
dist/*.flatpak
dist/*.tar.gz
dist/*.blockmap
dist/latest*.yml
if-no-files-found: warn
release:
name: Create GitHub Release
needs: package
if: >-
github.event_name == 'push' &&
github.ref == 'refs/heads/release' &&
!cancelled() &&
needs.package.result != 'failure'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout Repository
uses: actions/checkout@v4
with:
sparse-checkout: |
package.json
CHANGELOG.md
sparse-checkout-cone-mode: false
- name: Get version from package.json
id: version
run: |
VERSION=$(node -p "require('./package.json').version")
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Release version: $VERSION"
- name: Extract changelog for this version
id: changelog
run: |
# Extract the section for the current version from CHANGELOG.md
VERSION="${{ steps.version.outputs.version }}"
# Get content between ## [VERSION] and the next ## [ heading
NOTES=$(awk "/^## \[$VERSION\]/{found=1; next} /^## \[/{if(found) exit} found{print}" CHANGELOG.md)
if [ -z "$NOTES" ]; then
NOTES="Release v$VERSION"
fi
# Write to file to preserve multiline content
echo "$NOTES" > /tmp/release_notes.md
echo "Changelog notes:"
cat /tmp/release_notes.md
- name: Download Artifacts
uses: actions/download-artifact@v4
with:
pattern: '*-*'
merge-multiple: false
- name: Create Release
uses: softprops/action-gh-release@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: v${{ steps.version.outputs.version }}
name: v${{ steps.version.outputs.version }}
body_path: /tmp/release_notes.md
draft: false
prerelease: false
files: |
**/*.zip
**/*.exe
**/*.dmg
**/*.pkg
**/*.deb
**/*.rpm
**/*.AppImage
**/*.flatpak
**/*.tar.gz
**/*.blockmap
**/latest*.yml
+100 -6
View File
@@ -1,8 +1,102 @@
_old
.vscode
node_modules
dist
out
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
.DS_Store
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
*.log*
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
# parcel-bundler cache (https://parceljs.org/)
.cache
# next.js build output
.next
# nuxt.js build output
.nuxt
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# Webpack
.webpack/
# Vite
.vite/
# Electron-Forge
out/
resources/python
resources/python.tar.gz
.webui_secret_key
_old
dist
-1
View File
@@ -1 +0,0 @@
-6
View File
@@ -1,6 +0,0 @@
out
dist
pnpm-lock.yaml
LICENSE.md
tsconfig.json
tsconfig.*.json
-10
View File
@@ -1,10 +0,0 @@
singleQuote: true
semi: false
printWidth: 100
trailingComma: none
plugins:
- prettier-plugin-svelte
overrides:
- files: '*.svelte'
options:
parser: svelte
-51
View File
@@ -1,51 +0,0 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.0.3] - 2026-04-06
### Fixed
- **Spotlight Focus** — Spotlight now reliably appears after interacting with the main window or webview (fixed blur-during-show race condition on macOS)
- **Spotlight Query Passthrough** — The `?q=` search parameter from spotlight now correctly navigates already-open webviews instead of being silently ignored
## [0.0.2] - 2026-04-06
### Added
- **Spotlight Input Bar** — Lightweight quick-chat bar (⇧⌘I) for submitting queries without opening the full app
- **Spotlight Shortcut** — Dedicated configurable shortcut for the spotlight, independent from the global app shortcut
- **Draggable Spotlight** — Spotlight bar can be dragged to any position on screen
- **Persistent Spotlight Position** — Spotlight position is saved to config and restored across app restarts
- **Spotlight Settings** — Shortcut recorder in Settings → General for the spotlight shortcut
### Fixed
- **System Theme Sync** — App now listens for OS dark/light mode changes in real-time when set to "Auto" (previously only checked once at startup)
## [0.0.1] - 2026-03-20
### Added
- **Local Server Management** — Install, start, stop, and restart Open WebUI directly from the desktop app
- **Connection Manager** — Connect to multiple Open WebUI servers with sidebar quick-switch
- **Status Bar** — Real-time status indicators for Open WebUI, Open Terminal, and llama.cpp services
- **Log Panel** — Live PTY-backed terminal log viewer for all services with copy, refresh, and resize
- **Open Terminal Integration** — Built-in terminal server for AI-powered shell access
- **llama.cpp Integration** — Local inference runtime with model management and Hugging Face downloads
- **Settings** — General, Open WebUI, Terminal, Inference, Models, Connections, and About panels
- **Global Shortcut** — Configurable system-wide hotkey to bring the app to the foreground
- **Auto-Update** — Built-in software update checker with download and install
- **Tray Support** — System tray icon with quick actions and optional background execution
- **Factory Reset** — One-click removal of all installed components, data, and connections
- **Disk Space Check** — Pre-install validation requiring at least 5 GB of free storage
- **Internationalization** — i18n support with English, Japanese, Chinese (Simplified & Traditional) translations
- **Changelog Viewer** — In-app changelog accessible from the About settings page
- **Singleton Enforcement** — Prevents multiple instances of managed services from running simultaneously
- **Cross-Platform** — macOS, Windows, and Linux support
+4 -4
View File
@@ -1,7 +1,7 @@
# Contributor License Agreement
# Open WebUI Contributor License Agreement
By submitting my contributions to this repository in any form, I grant Open WebUI Inc. a perpetual, worldwide, irrevocable, royalty-free license, under copyright and patent, to use, modify, distribute, sublicense, and commercialize my work under any terms they choose, both now and in the future.
By submitting my contributions ("Contribution") to this project, I grant the project maintainers a perpetual, irrevocable, worldwide, royalty-free, transferable, non-exclusive license to use, reproduce, modify, prepare derivative works of, publicly display, publicly perform, sublicense, and distribute my Contribution, in whole or in part, under any terms and for any purpose, including commercial purposes, both now and in the future. To the fullest extent permitted by law, I waive, or agree not to assert, any moral rights I may have in my Contribution, so that the project maintainers may freely exercise their rights in the Contribution.
I represent that my contributions are my original work (or that I have sufficient rights to grant this license) and that I have the authority to enter into this agreement.
Taking part in this process means my work can be seamlessly integrated and combined with others, ensuring longevity and adaptability for everyone who benefits from the project. This collaborative approach strengthens the projects future and helps guarantee that improvements can always be shared and distributed in the most effective way possible.
**_To the fullest extent permitted by law, my contributions are provided on an “as is” basis, with no warranties or guarantees of any kind, and I disclaim any liability for any issues or damages arising from their use or incorporation into the project, regardless of the type of legal claim._**
My Contribution is provided "as is," without warranties or guarantees of any kind, and I disclaim any liability for any issues or damages arising from its use or incorporation into the project, regardless of the type of legal claim.
+26 -637
View File
@@ -1,661 +1,50 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
# Open WebUI Sustainable Use License 1.0
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Copyright (c) 2025 Timothy Jaeryang Baek (Open WebUI)
All rights reserved.
Preamble
1. Definitions
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
For purposes of this License, "Software" refers to all code, documentation, interfaces, assets, and materials provided in this repository, in whole or in part, whether in source code or object code form, and any modified versions or derivative works thereof. “Licensor” means the entity that provides this License for the Software, as identified in the copyright notice, documentation, or otherwise designated in connection with the Software. "You" refers to any individual or entity exercising rights under this License.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
2. Acceptance
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
By using, copying, modifying, or distributing the Software, you agree to all terms of this license.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
3. Grant of Rights
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
Subject to the terms and conditions set forth herein, you are hereby granted a non-exclusive, non-transferable, worldwide, royalty-free right to use and modify the Software exclusively for your own personal, non-commercial use, or for activities undertaken solely for your own internal business operations. You may prepare derivative works based upon the Software, provided your use remains strictly within the intended bounds of internal, non-commercial, or personal application.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
Any distribution of the Software or any part thereof, including derivative works, is permitted only if made accessible to others without charge and solely for non-commercial purposes. If you distribute the Software or any derivative works, you must do so only under the terms of this License. You are not permitted to sublicense or relicense the Software or any derivative work under any other license or terms, whether open source or otherwise, except with the prior written authorization of the Licensor.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
Any use, reproduction, modification, distribution, or making available of the Software or derivative works for commercial advantage or monetary compensation, including but not limited to business-to-business sales, resale, sublicensing, or inclusion in paid services, requires the prior written authorization of the Licensor. If you are uncertain whether your use qualifies as non-commercial or internal business use, you must contact the Licensor.
The precise terms and conditions for copying, distribution and
modification follow.
4. Branding and Attribution
TERMS AND CONDITIONS
You shall not alter, remove, obscure, replace, or otherwise modify any branding or attribution identifying "Open WebUI," including but not limited to names, logos, graphical marks, or textual identifiers, present in the Software or its interfaces, whether in original or modified form. All such branding and attribution must be retained and presented exactly as included in the Software and in every instance and manner originally provided by the Licensor.
0. Definitions.
Any copy, deployment, distribution, derivative work, or integration of the Software must preserve all original "Open WebUI" branding and attribution, unless prior written permission has been obtained from the Licensor, or where an express waiver or enterprise agreement has been executed by both parties.
"This License" refers to version 3 of the GNU Affero General Public License.
5. Contributions
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
If you submit or propose any contributions (including code, documentation, or other materials) for inclusion in the Software, you hereby agree to and are bound by the Contributor License Agreement as presented in this repositorys documentation at the time of your contribution.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
6. Termination
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
Any violation of the terms and conditions of this License, including but not limited to unauthorized commercial use or modification of branding or attribution, results in the automatic termination of all rights granted to you under this License, effective immediately upon such violation.
A "covered work" means either the unmodified Program or a work based
on the Program.
However, if you cure the violation within thirty (30) days of becoming aware of such violation, or within thirty (30) days of receiving written notice from the Licensor describing the breach (whichever is earlier), your license rights under this License will be automatically reinstated as of the cure date. If you fail to cure the violation within that period, your license remains terminated, and you must immediately cease all use, reproduction, modification, and distribution of the Software and any derivative works.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
For the avoidance of doubt, no rights of use, distribution, or modification exist during any period of breach until cured in accordance with this section.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
7. No Warranty
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, WHETHER EXPRESS, IMPLIED, STATUTORY, OR OTHERWISE, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. IN NO EVENT SHALL THE Licensor(S), AUTHOR(S), OR CONTRIBUTOR(S) 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, DISTRIBUTION, OR OTHER DEALINGS IN THE SOFTWARE.
1. Source Code.
8. Patent Grant
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
The Licensor grants you a license, under any patent claims they can license or become able to license, to make, use, modify, and distribute the Software as permitted under this License. This grant is strictly subject to the limitations and conditions stated herein, and does not extend to any patent claims infringed by your modifications or additions to the Software. If you or your organization make any written claim that the Software infringes or contributes to the infringement of any patent, your rights under this patent license terminate immediately. If your organization makes such a claim, your patent license terminates immediately for all use or work on its behalf.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
9. Miscellaneous
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
This License constitutes the entire agreement between you and the Licensor concerning the Software. Any waiver or amendment of any term or condition of this License shall be effective only if made in writing and signed by the Licensor. If any provision of this License is held to be invalid or unenforceable, the remaining provisions shall remain in full force and effect.
+18 -19
View File
@@ -2,53 +2,52 @@
![App Demo](./demo.png)
**Open WebUI Desktop** is a cross-platform desktop application for [Open WebUI](https://github.com/open-webui/open-webui). It brings the _full-featured Open WebUI experience_ directly to your device, effectively transforming it into a powerful server—without the complexities of manual setup.
**Open WebUI App** is the upcoming cross-platform desktop application for [Open WebUI](https://github.com/open-webui/open-webui). It brings the *full-featured Open WebUI experience* directly to your device, effectively transforming it into a powerful server—without the complexities of manual setup.
> [!WARNING]
> This project is currently in **alpha** and under active development. 🛠️ Expect frequent updates and potential changes as we refine the application.
This project is still in an **experimental phase** and under active development. 🛠️ Expect frequent updates and potential changes as we refine the application.
## Download 📥
> [!NOTE]
> An internet connection is required for initial setup, but afterwards the application can be used completely offline.
Get the latest alpha release from our [releases page](https://github.com/open-webui/desktop/releases).
---
## Features
- **One-Click Installation**: Quickly and effortlessly install and set up Open WebUI with all its dependencies. This feature is fully functional and ready to make your setup a breeze.
- **Cross-Platform Support**: Compatible with Windows, macOS, and Linux to ensure broad accessibility.
- **Offline Capability**: After initial setup, use the application completely offline for enhanced privacy and reliability.
---
## Project Setup
### Install
```bash
npm install
$ npm install
```
### Development
```bash
npm run dev
$ npm run dev
```
### Build
```bash
# For windows
npm run build:win
$ npm run build:win
# For macOS
npm run build:mac
$ npm run build:mac
# For Linux
npm run build:linux
$ npm run build:linux
```
## License 📜
---
This project is licensed under the **AGPL-3.0**. For details, see [LICENSE](LICENSE).
## License 📜
This project is licensed under the **Open WebUI Sustainable Use License**. For details, see [LICENSE](LICENSE).
---
## Stay Tuned! 🌟
We're actively developing Open WebUI. Follow [Open WebUI](https://github.com/open-webui/open-webui) for updates, and join the [community on Discord](https://discord.gg/5rJgQTnV4s) to stay involved.
We're actively developing Open WebUI App. Follow [Open WebUI](https://github.com/open-webui/open-webui) for updates, and join the [community on Discord](https://discord.gg/5rJgQTnV4s) to stay involved.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 708 KiB

After

Width:  |  Height:  |  Size: 946 KiB

+2 -3
View File
@@ -1,4 +1,3 @@
provider: github
owner: open-webui
repo: desktop
provider: generic
url: https://example.com/auto-updates
updaterCacheDirName: desktop-updater
+41 -65
View File
@@ -1,78 +1,54 @@
appId: com.openwebui.desktop
productName: Open WebUI
directories:
buildResources: build
buildResources: build
files:
- '!**/.vscode/*'
- '!src/*'
- '!electron.vite.config.{js,ts,mjs,cjs}'
- '!svelte.config.mjs'
- '!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,README.md}'
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
extraResources:
- from: CHANGELOG.md
to: CHANGELOG.md
- "!**/.vscode/*"
- "!src/*"
- "!electron.vite.config.{js,ts,mjs,cjs}"
- "!svelte.config.mjs"
- "!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}"
- "!{.env,.env.*,.npmrc,pnpm-lock.yaml}"
- "!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}"
asarUnpack:
- resources/**
- resources/**
win:
executableName: open-webui
executableName: open-webui
nsis:
artifactName: ${name}-${version}-setup.${ext}
shortcutName: ${productName}
uninstallDisplayName: ${productName}
createDesktopShortcut: always
artifactName: ${name}-${version}-setup.${ext}
shortcutName: ${productName}
uninstallDisplayName: ${productName}
createDesktopShortcut: always
mac:
target:
- dmg
- zip
identity: null
entitlementsInherit: build/entitlements.mac.plist
extendInfo:
- NSCameraUsageDescription: Application requests access to the device's camera.
- NSMicrophoneUsageDescription: Application requests access to the device's microphone.
- NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
- NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
entitlementsInherit: build/entitlements.mac.plist
extendInfo:
- NSCameraUsageDescription: Application requests access to the device's camera.
- NSMicrophoneUsageDescription: Application requests access to the device's microphone.
- NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
- NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
notarize: true
dmg:
background: build/dmg-background.png
artifactName: ${name}-${version}.${ext}
title: ${productName}
contents:
- x: 225
y: 250
type: file
- x: 400
y: 240
type: link
path: /Applications
background: build/dmg-background.png
artifactName: ${name}-${version}.${ext}
title: ${productName}
contents:
- x: 225
y: 250
type: file
- x: 400
y: 240
type: link
path: /Applications
linux:
target:
- AppImage
- snap
- deb
- flatpak
maintainer: openwebui.com
category: Utility
target:
- AppImage
- snap
- deb
maintainer: openwebui.com
category: Utility
appImage:
artifactName: ${name}-${version}.${ext}
flatpak:
base: org.electronjs.Electron2.BaseApp
baseVersion: '23.08'
runtime: org.freedesktop.Platform
runtimeVersion: '23.08'
sdk: org.freedesktop.Sdk
artifactName: ${name}-${version}.flatpak
finishArgs:
- --share=ipc
- --socket=x11
- --socket=wayland
- --socket=pulseaudio
- --share=network
- --device=dri
- --filesystem=home
- --talk-name=org.freedesktop.Notifications
artifactName: ${name}-${version}.${ext}
npmRebuild: false
publish:
provider: github
owner: open-webui
repo: desktop
provider: generic
url: https://example.com/auto-updates
+12 -31
View File
@@ -1,34 +1,15 @@
import { resolve } from 'path'
import { defineConfig } from 'electron-vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
import tailwindcss from '@tailwindcss/vite'
if (process.platform === 'linux') {
process.env.ELECTRON_DISABLE_SANDBOX = '1'
}
import { defineConfig, externalizeDepsPlugin } from "electron-vite";
import { svelte } from "@sveltejs/vite-plugin-svelte";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
main: {},
preload: {
build: {
rollupOptions: {
input: {
index: resolve(__dirname, 'src/preload/index.ts'),
'content-preload': resolve(__dirname, 'src/preload/content-preload.ts'),
'spotlight-preload': resolve(__dirname, 'src/preload/spotlight-preload.ts')
}
}
}
},
renderer: {
build: {
rollupOptions: {
input: {
index: resolve(__dirname, 'src/renderer/index.html'),
spotlight: resolve(__dirname, 'src/renderer/spotlight.html')
}
}
main: {
plugins: [externalizeDepsPlugin()],
},
plugins: [tailwindcss(), svelte()]
}
})
preload: {
plugins: [externalizeDepsPlugin()],
},
renderer: {
plugins: [svelte(), tailwindcss()],
},
});
+2 -2
View File
@@ -1,11 +1,11 @@
import { defineConfig } from 'eslint/config'
import tseslint from '@electron-toolkit/eslint-config-ts'
import eslintConfigPrettier from '@electron-toolkit/eslint-config-prettier'
import eslintPluginSvelte from 'eslint-plugin-svelte'
export default defineConfig(
export default tseslint.config(
{ ignores: ['**/node_modules', '**/dist', '**/out'] },
tseslint.configs.recommended,
indent: "off",
eslintPluginSvelte.configs['flat/recommended'],
{
files: ['**/*.svelte'],
+9669 -9420
View File
File diff suppressed because it is too large Load Diff
+55 -57
View File
@@ -1,59 +1,57 @@
{
"name": "open-webui",
"version": "0.0.3",
"license": "AGPL-3.0",
"description": "Open WebUI Desktop",
"main": "./out/main/index.js",
"author": "Open WebUI Inc. (Timothy Jaeryang Baek)",
"homepage": "https://openwebui.com",
"scripts": {
"format": "prettier --plugin prettier-plugin-svelte --write .",
"lint": "eslint --cache .",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"svelte-check": "svelte-check --tsconfig ./tsconfig.json",
"typecheck": "npm run typecheck:node && npm run svelte-check",
"start": "electron-vite preview",
"dev": "electron-vite dev",
"build": "npm run typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps",
"build:unpack": "npm run build && electron-builder --dir",
"build:win": "npm run build && electron-builder --win",
"build:mac": "npm run build && electron-builder --mac",
"build:linux": "npm run build && electron-builder --linux"
},
"dependencies": {
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"@tailwindcss/vite": "^4.2.1",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"electron-log": "^5.4.3",
"electron-updater": "^6.3.9",
"i18next": "^25.8.18",
"i18next-browser-languagedetector": "^8.2.1",
"i18next-resources-to-backend": "^1.2.1",
"node-pty": "^1.1.0",
"svelte-sonner": "^1.1.0",
"tailwindcss": "^4.2.1",
"tar": "^7.5.11",
"tippy.js": "^6.3.7"
},
"devDependencies": {
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
"@electron-toolkit/eslint-config-ts": "^3.1.0",
"@electron-toolkit/tsconfig": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^6.2.0",
"@types/node": "^22.19.1",
"electron": "^39.2.6",
"electron-builder": "^26.0.12",
"electron-vite": "^5.0.0",
"eslint": "^9.39.1",
"eslint-plugin-svelte": "^3.13.1",
"prettier": "^3.7.4",
"prettier-plugin-svelte": "^3.4.0",
"svelte": "^5.45.6",
"svelte-check": "^4.3.4",
"typescript": "^5.9.3",
"vite": "^7.2.6"
}
"name": "open-webui-desktop",
"version": "0.0.1",
"description": "Open WebUI Desktop",
"main": "./out/main/index.js",
"author": "Timothy Jaeryang Baek (Open WebUI)",
"homepage": "https://openwebui.com",
"scripts": {
"format": "prettier --plugin prettier-plugin-svelte --write .",
"lint": "eslint --cache .",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"svelte-check": "svelte-check --tsconfig ./tsconfig.json",
"typecheck": "npm run typecheck:node && npm run svelte-check",
"start": "electron-vite preview",
"dev": "electron-vite dev",
"build": "npm run typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps",
"build:unpack": "npm run build && electron-builder --dir",
"build:win": "npm run build && electron-builder --win",
"build:mac": "npm run build && electron-builder --mac",
"build:linux": "npm run build && electron-builder --linux"
},
"dependencies": {
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"@tailwindcss/vite": "^4.1.11",
"bits-ui": "^2.8.13",
"dompurify": "^3.2.6",
"electron-log": "^5.4.1",
"electron-updater": "^6.3.9",
"focus-trap": "^7.6.5",
"marked": "^16.1.1",
"svelte-sonner": "^1.0.5",
"tailwindcss": "^4.1.11",
"tar": "^7.4.3",
"tippy.js": "^6.3.7"
},
"devDependencies": {
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
"@electron-toolkit/eslint-config-ts": "^3.0.0",
"@electron-toolkit/tsconfig": "^1.0.1",
"@electron/notarize": "^2.5.0",
"@sveltejs/vite-plugin-svelte": "^6.1.0",
"@types/node": "^22.16.5",
"electron": "^37.2.3",
"electron-builder": "^25.1.8",
"electron-vite": "^4.0.0",
"eslint": "^9.31.0",
"eslint-plugin-svelte": "^3.11.0",
"prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0",
"svelte": "^5.36.10",
"svelte-check": "^4.3.0",
"typescript": "^5.8.3",
"vite": "^7.0.5"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 782 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 KiB

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
# environment.yml
channels:
- conda-forge
dependencies:
- python=3.11
- pip
platforms:
- linux-64
- linux-aarch64 # aka arm64, use for Docker on Apple Silicon
- osx-64
- osx-arm64 # For Apple Silicon, e.g. M1/M2
- win-64
# TODO: Add win-arm64 when available
+545 -1326
View File
File diff suppressed because it is too large Load Diff
-73
View File
@@ -1,73 +0,0 @@
import { autoUpdater, type UpdateInfo } from 'electron-updater'
import log from 'electron-log'
import { app, BrowserWindow } from 'electron'
let mainWin: BrowserWindow | null = null
const send = (type: string, data?: any): void => {
mainWin?.webContents.send('main:data', { type, data })
}
export function initUpdater(window: BrowserWindow): void {
mainWin = window
autoUpdater.logger = log
autoUpdater.autoDownload = false
autoUpdater.autoInstallOnAppQuit = true
autoUpdater.on('checking-for-update', () => {
send('update:checking')
})
autoUpdater.on('update-available', (info: UpdateInfo) => {
send('update:available', {
version: info.version,
releaseDate: info.releaseDate
})
})
autoUpdater.on('update-not-available', (_info: UpdateInfo) => {
send('update:not-available')
})
autoUpdater.on('download-progress', (progress) => {
send('update:download-progress', {
percent: progress.percent,
bytesPerSecond: progress.bytesPerSecond,
transferred: progress.transferred,
total: progress.total
})
})
autoUpdater.on('update-downloaded', (_info: UpdateInfo) => {
send('update:downloaded')
})
autoUpdater.on('error', (error: Error) => {
send('update:error', { message: error?.message ?? 'Update error' })
})
// Auto-check on launch (silently, only when packaged)
if (app.isPackaged) {
autoUpdater.checkForUpdates().catch((err) => {
log.warn('Auto update check failed:', err)
})
}
}
export async function checkForUpdates(): Promise<void> {
if (!app.isPackaged) {
log.info('Skipping update check — app is not packaged')
send('update:not-available')
return
}
await autoUpdater.checkForUpdates()
}
export async function downloadUpdate(): Promise<void> {
await autoUpdater.downloadUpdate()
}
export function installUpdate(): void {
autoUpdater.quitAndInstall(false, true)
}
-340
View File
@@ -1,340 +0,0 @@
// @ts-nocheck
/**
* Reusable Hugging Face utility module.
* Downloads files from HF repos, manages a local model cache,
* and provides listing/deletion of cached models.
*
* Cache dir: <userData>/models/huggingface/<repo-slug>/<filename>
*/
import * as fs from 'fs'
import * as path from 'path'
import log from 'electron-log'
import { getUserDataPath, downloadFileWithProgress } from './index'
// ─── Types ──────────────────────────────────────────────
export interface HfModel {
repo: string
filename: string
filepath: string
size: number // bytes
downloadedAt: string // ISO date
}
export interface HfDownloadProgress {
percent: number
downloadedBytes: number
totalBytes: number
}
// ─── Paths ──────────────────────────────────────────────
const getHfCacheDir = (): string => {
const dir = path.join(getUserDataPath(), 'models', 'huggingface')
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true })
}
return dir
}
const repoSlug = (repo: string): string => repo.replace(/\//g, '--')
const getManifestPath = (): string => path.join(getHfCacheDir(), 'manifest.json')
// ─── Manifest ───────────────────────────────────────────
const readManifest = (): HfModel[] => {
const p = getManifestPath()
if (!fs.existsSync(p)) return []
try {
return JSON.parse(fs.readFileSync(p, 'utf-8'))
} catch {
return []
}
}
const writeManifest = (models: HfModel[]): void => {
fs.writeFileSync(getManifestPath(), JSON.stringify(models, null, 2))
}
// ─── Public API ─────────────────────────────────────────
let activeDownloadAbort: AbortController | null = null
/**
* Cancel the current download in progress.
*/
export const cancelDownload = (): void => {
if (activeDownloadAbort) {
activeDownloadAbort.abort()
activeDownloadAbort = null
}
}
/**
* List all downloaded models.
*/
export const listModels = (): HfModel[] => {
const manifest = readManifest()
// Filter out entries whose files no longer exist
return manifest.filter((m) => fs.existsSync(m.filepath))
}
/**
* Get the cache directory path (so runtimes can reference it).
*/
export const getModelsDir = (): string => {
const dir = path.join(getUserDataPath(), 'models')
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true })
}
return dir
}
/**
* Download a file from a Hugging Face repository.
*
* @param repo - HF repo, e.g. "ggml-org/gemma-3-1b-it-GGUF"
* @param filename - File to download, e.g. "gemma-3-1b-it-Q4_K_M.gguf"
* @param onProgress - Progress callback
* @param token - Optional HF access token for private repos
* @returns Absolute path to the downloaded file
*/
export const downloadModel = async (
repo: string,
filename: string,
onProgress?: (progress: HfDownloadProgress) => void,
token?: string,
expectedSize?: number
): Promise<string> => {
const slug = repoSlug(repo)
const repoDir = path.join(getHfCacheDir(), slug)
if (!fs.existsSync(repoDir)) {
fs.mkdirSync(repoDir, { recursive: true })
}
const destPath = path.join(repoDir, filename)
// Already downloaded?
if (fs.existsSync(destPath)) {
log.info(`[huggingface] Already cached: ${destPath}`)
return destPath
}
// Build download URL
const downloadUrl = `https://huggingface.co/${repo}/resolve/main/${encodeURIComponent(filename)}`
log.info(`[huggingface] Downloading ${repo}/${filename}`)
log.info(`[huggingface] URL: ${downloadUrl}`)
// Download with progress
const headers: Record<string, string> = {}
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
activeDownloadAbort = new AbortController()
const { signal } = activeDownloadAbort
// Use fetch for streaming download with progress
const response = await fetch(downloadUrl, {
headers,
redirect: 'follow',
signal
})
if (!response.ok) {
throw new Error(
`Failed to download ${repo}/${filename}: ${response.status} ${response.statusText}`
)
}
const contentLength = parseInt(response.headers.get('content-length') ?? '0', 10)
const totalBytes = contentLength || expectedSize || 0
const reader = response.body?.getReader()
if (!reader) {
throw new Error('Response body is not readable')
}
const tmpPath = destPath + '.tmp'
const writeStream = fs.createWriteStream(tmpPath)
let downloadedBytes = 0
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
writeStream.write(Buffer.from(value))
downloadedBytes += value.byteLength
if (onProgress && totalBytes > 0) {
onProgress({
percent: (downloadedBytes / totalBytes) * 100,
downloadedBytes,
totalBytes
})
}
}
} catch (err) {
writeStream.end()
// Clean up partial download
try { fs.unlinkSync(tmpPath) } catch {}
activeDownloadAbort = null
throw err
} finally {
writeStream.end()
await new Promise((resolve) => writeStream.on('finish', resolve))
}
// Rename tmp to final
fs.renameSync(tmpPath, destPath)
activeDownloadAbort = null
// Update manifest
const manifest = readManifest()
const existing = manifest.findIndex((m) => m.repo === repo && m.filename === filename)
const entry: HfModel = {
repo,
filename,
filepath: destPath,
size: fs.statSync(destPath).size,
downloadedAt: new Date().toISOString()
}
if (existing >= 0) {
manifest[existing] = entry
} else {
manifest.push(entry)
}
writeManifest(manifest)
log.info(`[huggingface] Downloaded: ${destPath} (${entry.size} bytes)`)
return destPath
}
/**
* Delete a downloaded model.
*/
export const deleteModel = (repo: string, filename: string): boolean => {
const slug = repoSlug(repo)
const filepath = path.join(getHfCacheDir(), slug, filename)
try {
if (fs.existsSync(filepath)) {
fs.unlinkSync(filepath)
}
} catch (e) {
log.error(`[huggingface] Failed to delete ${filepath}:`, e)
return false
}
// Remove from manifest
const manifest = readManifest()
const updated = manifest.filter((m) => !(m.repo === repo && m.filename === filename))
writeManifest(updated)
// Clean up empty repo dir
const repoDir = path.join(getHfCacheDir(), slug)
try {
const remaining = fs.readdirSync(repoDir)
if (remaining.length === 0) {
fs.rmdirSync(repoDir)
}
} catch {}
log.info(`[huggingface] Deleted: ${repo}/${filename}`)
return true
}
/**
* Get info about a specific model.
*/
export const getModelInfo = (repo: string, filename: string): HfModel | null => {
const manifest = readManifest()
return manifest.find((m) => m.repo === repo && m.filename === filename) ?? null
}
// ─── HF API Integration ────────────────────────────────
export interface HfRepoResult {
id: string // e.g. "ggml-org/gemma-3-1b-it-GGUF"
author: string
modelId: string
downloads: number
likes: number
tags: string[]
lastModified: string
}
export interface HfFileInfo {
filename: string
size: number // bytes
lfs?: { size: number }
}
/**
* Search HF for GGUF model repos.
*/
export const searchModels = async (
query: string,
token?: string
): Promise<HfRepoResult[]> => {
const params = new URLSearchParams({
search: query,
filter: 'gguf',
sort: 'downloads',
direction: '-1',
limit: '20'
})
const headers: Record<string, string> = { Accept: 'application/json' }
if (token) headers['Authorization'] = `Bearer ${token}`
const response = await fetch(`https://huggingface.co/api/models?${params}`, { headers })
if (!response.ok) {
throw new Error(`HF search failed: ${response.status} ${response.statusText}`)
}
const data = await response.json()
return data.map((item: any) => ({
id: item.id ?? item.modelId,
author: item.author ?? item.id?.split('/')[0] ?? '',
modelId: item.modelId ?? item.id,
downloads: item.downloads ?? 0,
likes: item.likes ?? 0,
tags: item.tags ?? [],
lastModified: item.lastModified ?? ''
}))
}
/**
* List GGUF files in a HF repo.
*/
export const getRepoFiles = async (
repo: string,
token?: string
): Promise<HfFileInfo[]> => {
const headers: Record<string, string> = { Accept: 'application/json' }
if (token) headers['Authorization'] = `Bearer ${token}`
const response = await fetch(`https://huggingface.co/api/models/${repo}`, { headers })
if (!response.ok) {
throw new Error(`Failed to fetch repo info: ${response.status} ${response.statusText}`)
}
const data = await response.json()
const siblings = data.siblings ?? []
// Filter to only GGUF files
return siblings
.filter((f: any) => f.rfilename?.endsWith('.gguf'))
.map((f: any) => ({
filename: f.rfilename,
size: f.lfs?.size ?? f.size ?? 0
}))
.sort((a: HfFileInfo, b: HfFileInfo) => a.size - b.size)
}
+726 -856
View File
File diff suppressed because it is too large Load Diff
-522
View File
@@ -1,522 +0,0 @@
// @ts-nocheck
import * as fs from 'fs'
import * as path from 'path'
import { execFileSync } from 'child_process'
import * as tar from 'tar'
import * as pty from 'node-pty'
import log from 'electron-log'
import {
getConfig,
getUserDataPath,
portInUse,
downloadFileWithProgress
} from './index'
import { getModelsDir } from './huggingface'
import { ServiceLock, isProcessAlive } from './service-lock'
// ─── State ──────────────────────────────────────────────
let ptyProcess: pty.IPty | null = null
let pid: number | null = null
let url: string | null = null
let status: string | null = null // null | setting-up | starting | started | stopped | failed
let logBuffer: string[] = []
const lock = new ServiceLock('llamacpp')
let binaryPath: string | null = null
// ─── Public Getters ─────────────────────────────────────
export const getLlamaCppInfo = () => {
// Extract version tag from binaryPath — the tag is the directory name
// directly under the llama.cpp cache dir, e.g. …/llama.cpp/<tag>/bin/llama-server
let version: string | null = null
if (binaryPath) {
const cacheBase = path.join(getUserDataPath(), 'llama.cpp')
const relative = path.relative(cacheBase, binaryPath)
const tag = relative.split(path.sep)[0]
if (tag) version = tag
}
return { url, status, pid, binaryPath, version }
}
export const getLlamaCppPty = (): pty.IPty | null => ptyProcess
export const getLlamaCppLog = (): string[] => logBuffer
// ─── Asset Resolution ───────────────────────────────────
interface ReleaseAsset {
name: string
browser_download_url: string
}
/**
* Detect the best GPU variant for the current platform.
* Returns the variant string (e.g. 'cuda-12.4', 'vulkan', 'rocm', 'cpu').
*/
const detectBestVariant = (): string => {
const platform = process.platform
// macOS: Metal is baked into the macOS binary; no variant choice needed.
if (platform === 'darwin') return 'cpu'
// Check for NVIDIA GPU (CUDA)
try {
execFileSync('nvidia-smi', ['--query-gpu=name', '--format=csv,noheader'], {
timeout: 5000,
stdio: 'pipe'
})
// NVIDIA GPU detected
if (platform === 'win32') return 'cuda-12.4'
// Linux: no CUDA asset currently available, fall through to other checks
} catch {
// nvidia-smi not available or no NVIDIA GPU
}
// Check for Vulkan support
try {
if (platform === 'win32') {
execFileSync('vulkaninfo', ['--summary'], { timeout: 5000, stdio: 'pipe' })
} else {
execFileSync('vulkaninfo', ['--summary'], { timeout: 5000, stdio: 'pipe' })
}
return 'vulkan'
} catch {
// Vulkan not available
}
// Linux: check for ROCm (AMD GPU)
if (platform === 'linux') {
try {
if (fs.existsSync('/opt/rocm') || fs.existsSync('/usr/lib/rocm')) {
return 'rocm'
}
} catch {
// ROCm not available
}
}
return 'cpu'
}
/**
* Resolve the variant — if 'auto' or empty, detect the best one.
*/
const resolveVariant = (variant: string | undefined): string => {
if (!variant || variant === 'auto') {
const detected = detectBestVariant()
log.info(`Auto-detected variant: ${detected}`)
return detected
}
return variant
}
/**
* Determine the correct release asset name for this platform/arch/variant.
*/
const getAssetPattern = (tag: string, variant: string): { pattern: string; isZip: boolean } => {
const platform = process.platform
const arch = process.arch
if (platform === 'darwin') {
const archStr = arch === 'arm64' ? 'arm64' : 'x64'
return { pattern: `llama-${tag}-bin-macos-${archStr}.tar.gz`, isZip: false }
}
if (platform === 'linux') {
const variantMap: Record<string, string> = {
cpu: `llama-${tag}-bin-ubuntu-x64.tar.gz`,
vulkan: `llama-${tag}-bin-ubuntu-vulkan-x64.tar.gz`,
rocm: `llama-${tag}-bin-ubuntu-rocm-7.2-x64.tar.gz`
}
const name = variantMap[variant] ?? variantMap.cpu
return { pattern: name, isZip: false }
}
if (platform === 'win32') {
const archStr = arch === 'arm64' ? 'arm64' : 'x64'
const variantMap: Record<string, string> = {
cpu: `llama-${tag}-bin-win-cpu-${archStr}.zip`,
'cuda-12.4': `llama-${tag}-bin-win-cuda-12.4-x64.zip`,
'cuda-13.1': `llama-${tag}-bin-win-cuda-13.1-x64.zip`,
vulkan: `llama-${tag}-bin-win-vulkan-x64.zip`
}
const name = variantMap[variant] ?? variantMap.cpu
return { pattern: name, isZip: true }
}
return { pattern: `llama-${tag}-bin-ubuntu-x64.tar.gz`, isZip: false }
}
/**
* Find the llama-server binary inside the extracted directory.
*/
const findBinary = (dir: string): string | null => {
const exeName = process.platform === 'win32' ? 'llama-server.exe' : 'llama-server'
const candidates = [
path.join(dir, exeName),
path.join(dir, 'bin', exeName),
path.join(dir, 'build', 'bin', exeName)
]
for (const candidate of candidates) {
if (fs.existsSync(candidate)) return candidate
}
try {
const entries = fs.readdirSync(dir, { withFileTypes: true })
for (const entry of entries) {
if (entry.isDirectory()) {
const nested = path.join(dir, entry.name, exeName)
if (fs.existsSync(nested)) return nested
const nestedBin = path.join(dir, entry.name, 'bin', exeName)
if (fs.existsSync(nestedBin)) return nestedBin
}
}
} catch {}
return null
}
// ─── Setup (Download & Extract) ─────────────────────────
export const setupLlamaCpp = async (
onStatus?: (status: string) => void
): Promise<string> => {
const config = await getConfig()
const llamaConfig = config.llamaCpp ?? {}
const version = llamaConfig.version || 'latest'
const variant = resolveVariant(llamaConfig.variant)
const cacheBase = path.join(getUserDataPath(), 'llama.cpp')
if (!fs.existsSync(cacheBase)) {
fs.mkdirSync(cacheBase, { recursive: true })
}
onStatus?.('Fetching release info…')
const apiUrl =
version === 'latest'
? 'https://api.github.com/repos/ggml-org/llama.cpp/releases/latest'
: `https://api.github.com/repos/ggml-org/llama.cpp/releases/tags/${version}`
let releaseData: any
try {
const response = await fetch(apiUrl, {
headers: { Accept: 'application/vnd.github.v3+json' }
})
if (!response.ok) {
throw new Error(`GitHub API returned ${response.status}: ${response.statusText}`)
}
releaseData = await response.json()
} catch (error) {
throw new Error(`Failed to fetch release info: ${error?.message ?? error}`)
}
const tag = releaseData.tag_name
log.info(`llama.cpp release tag: ${tag}`)
const versionDir = path.join(cacheBase, tag)
if (!fs.existsSync(versionDir)) {
fs.mkdirSync(versionDir, { recursive: true })
}
const existingBinary = findBinary(versionDir)
if (existingBinary) {
log.info(`llama-server binary already exists: ${existingBinary}`)
binaryPath = existingBinary
return existingBinary
}
const { pattern, isZip } = getAssetPattern(tag, variant)
const asset = (releaseData.assets as ReleaseAsset[]).find((a) => a.name === pattern)
if (!asset) {
const available = (releaseData.assets as ReleaseAsset[]).map((a) => a.name).join(', ')
throw new Error(
`No matching asset found for pattern "${pattern}". Available: ${available}`
)
}
log.info(`Downloading asset: ${asset.name}`)
onStatus?.(`Downloading ${asset.name}`)
const downloadPath = path.join(versionDir, asset.name)
if (!fs.existsSync(downloadPath)) {
await downloadFileWithProgress(asset.browser_download_url, downloadPath, (progress) => {
onStatus?.(`Downloading… ${progress.toFixed(0)}%`)
})
}
onStatus?.('Extracting…')
log.info(`Extracting ${downloadPath} to ${versionDir}`)
if (isZip) {
try {
if (process.platform === 'win32') {
execFileSync('powershell', [
'-Command',
`Expand-Archive -Path "${downloadPath}" -DestinationPath "${versionDir}" -Force`
])
} else {
execFileSync('unzip', ['-o', downloadPath, '-d', versionDir])
}
} catch (error) {
throw new Error(`Failed to extract zip: ${error?.message ?? error}`)
}
} else {
await tar.x({ cwd: versionDir, file: downloadPath })
}
try {
fs.unlinkSync(downloadPath)
} catch {}
if (process.platform !== 'win32') {
const binary = findBinary(versionDir)
if (binary) {
try {
fs.chmodSync(binary, 0o755)
} catch {}
}
}
const resultBinary = findBinary(versionDir)
if (!resultBinary) {
throw new Error(`llama-server binary not found after extraction in ${versionDir}`)
}
log.info(`llama-server binary ready: ${resultBinary}`)
binaryPath = resultBinary
onStatus?.('Ready')
return resultBinary
}
export const checkLlamaCppUpdate = async (): Promise<{ currentVersion: string | null; latestVersion: string | null; updateAvailable: boolean }> => {
const currentInfo = getLlamaCppInfo()
try {
const response = await fetch('https://api.github.com/repos/ggml-org/llama.cpp/releases/latest', {
headers: { Accept: 'application/vnd.github.v3+json' },
signal: AbortSignal.timeout(5000)
})
if (!response.ok) {
throw new Error(`GitHub API returned ${response.status}: ${response.statusText}`)
}
const releaseData = await response.json()
const latestVersion = releaseData.tag_name
const currentVersion = currentInfo.version
if (!currentVersion) {
return { currentVersion: null, latestVersion, updateAvailable: true }
}
return {
currentVersion,
latestVersion,
updateAvailable: currentVersion !== latestVersion
}
} catch (error) {
log.error('Failed to check for llama.cpp updates:', error)
return {
currentVersion: currentInfo.version,
latestVersion: null,
updateAvailable: false
}
}
}
export const updateLlamaCpp = async (
onStatus?: (status: string) => void
): Promise<{ url?: string; status?: string; pid?: number; binaryPath?: string; version?: string | null }> => {
// 1. Stop if running
await stopLlamaCpp()
// 2. Clear old cache directory
const currentInfo = getLlamaCppInfo()
if (currentInfo.version) {
const cacheDir = path.join(getUserDataPath(), 'llama.cpp', currentInfo.version)
if (fs.existsSync(cacheDir)) {
onStatus?.('Removing old version…')
try {
fs.rmSync(cacheDir, { recursive: true, force: true })
} catch (err) {
log.error(`Failed to remove old llama.cpp cache at ${cacheDir}:`, err)
}
}
}
// 3. Temporarily enforce 'latest' in config so it fetches the newest
const config = await getConfig()
await setConfig({ llamaCpp: { ...config.llamaCpp, version: 'latest' } }) // Assuming setConfig is available, if not we'll modify it
// 4. Download new release
onStatus?.('Downloading update…')
await setupLlamaCpp(onStatus)
return getLlamaCppInfo()
}
// ─── Lifecycle ──────────────────────────────────────────
export const startLlamaCpp = async (
onStatus?: (status: string) => void
): Promise<{ url: string; pid: number }> => {
if (!lock.acquire()) {
return { url, pid }
}
await stopLlamaCpp()
status = 'setting-up'
onStatus?.('Setting up llama.cpp…')
const binary = await setupLlamaCpp(onStatus)
status = 'starting'
onStatus?.('Starting llama-server…')
const config = await getConfig()
const llamaConfig = config.llamaCpp ?? {}
const host = '127.0.0.1'
let desiredPort = llamaConfig.port || 18881
let availablePort = desiredPort
while (await portInUse(availablePort, host)) {
availablePort++
if (availablePort > desiredPort + 100) {
throw new Error('No available port found for llama-server')
}
}
const extraArgs = llamaConfig.extraArgs ?? []
const modelsDir = getModelsDir()
const commandArgs = ['--host', host, '--port', availablePort.toString(), '--models-dir', modelsDir, ...extraArgs]
log.info('Starting llama-server:', binary, commandArgs.join(' '))
let spawned: pty.IPty
try {
spawned = pty.spawn(binary, commandArgs, {
name: 'xterm-256color',
cols: 200,
rows: 50,
env: {
...process.env,
...(config.envVars ?? {})
}
})
} catch (error) {
status = 'failed'
throw new Error(`Failed to spawn llama-server: ${error?.message ?? error}`)
}
const spawnedPid = spawned.pid
logBuffer = []
ptyProcess = spawned
pid = spawnedPid
spawned.onData((data: string) => {
logBuffer.push(data)
log.info(`[llamacpp:${spawnedPid}] ${data.replace(/[\r\n]+/g, ' ').trim()}`)
})
spawned.onExit(({ exitCode, signal }) => {
log.info(`[llamacpp:${spawnedPid}] Exited code=${exitCode} signal=${signal}`)
const exitMsg = `\r\n[Process exited with code ${exitCode}${signal ? ` signal ${signal}` : ''}]\r\n`
logBuffer.push(exitMsg)
ptyProcess = null
pid = null
url = null
status = 'stopped'
})
const serverUrl = `http://${host}:${availablePort}`
const maxAttempts = 30
let ready = false
for (let i = 0; i < maxAttempts; i++) {
await new Promise((r) => setTimeout(r, 1000))
try {
const resp = await fetch(`${serverUrl}/health`, { signal: AbortSignal.timeout(2000) })
if (resp.ok) {
const body = await resp.json()
if (body.status === 'ok' || body.status === 'no slot available') {
ready = true
break
}
}
} catch {
// Not ready yet
}
}
if (!ready) {
log.warn('llama-server did not report healthy within 30s, continuing anyway')
}
url = serverUrl
status = 'started'
log.info(`llama-server started — PID: ${spawnedPid}, URL: ${serverUrl}`)
return { url: serverUrl, pid: spawnedPid }
}
export const stopLlamaCpp = async (): Promise<void> => {
if (ptyProcess) {
try {
ptyProcess.kill()
} catch (e) {
log.warn('Failed to kill llama-server PTY:', e)
}
await new Promise((r) => setTimeout(r, 2000))
if (pid) {
try {
process.kill(pid, 0)
process.kill(pid, 'SIGKILL')
} catch {
// already dead
}
}
}
ptyProcess = null
pid = null
url = null
status = null
logBuffer = []
lock.release()
}
/**
* Validate whether the tracked llama.cpp process is still alive.
* Used for crash recovery on app startup.
*/
export const validateLlamaCppProcess = (): boolean => {
if (!pid) return false
if (isProcessAlive(pid)) return true
// Stale PID — clean up
pid = null
status = null
lock.release()
return false
}
/**
* Uninstall llama.cpp — stop the server and remove all downloaded binaries.
*/
export const uninstallLlamaCpp = async (): Promise<void> => {
await stopLlamaCpp()
const cacheBase = path.join(getUserDataPath(), 'llama.cpp')
if (fs.existsSync(cacheBase)) {
fs.rmSync(cacheBase, { recursive: true, force: true })
log.info('Removed llama.cpp directory:', cacheBase)
}
binaryPath = null
}
-171
View File
@@ -1,171 +0,0 @@
// @ts-nocheck
import crypto from 'crypto'
import log from 'electron-log'
import * as pty from 'node-pty'
import {
getPythonPath,
getConfig,
installPackage,
isPackageInstalled,
isPythonInstalled,
portInUse
} from './index'
import { ServiceLock, isProcessAlive } from './service-lock'
// ─── State ──────────────────────────────────────────────
let ptyProcess: pty.IPty | null = null
let pid: number | null = null
let url: string | null = null
let apiKey: string | null = null
let status: string | null = null // null | starting | started | stopped | failed
let logBuffer: string[] = []
const lock = new ServiceLock('open-terminal')
// ─── Public API ─────────────────────────────────────────
export const getOpenTerminalInfo = () => ({
url,
apiKey,
status,
pid
})
export const getOpenTerminalPty = (): pty.IPty | null => ptyProcess
export const getOpenTerminalLog = (): string[] => logBuffer
export const startOpenTerminal = async (
port: number | null = null
): Promise<{ url: string; apiKey: string; pid: number }> => {
if (!lock.acquire()) {
return { url, apiKey, pid }
}
await stopOpenTerminal()
if (!isPythonInstalled()) throw new Error('Python is not installed')
if (!isPackageInstalled('open-terminal')) {
log.info('open-terminal not installed, installing...')
const ok = await installPackage('open-terminal')
if (!ok) throw new Error('Failed to install open-terminal')
}
const pythonPath = getPythonPath()
const host = '127.0.0.1'
const config = await getConfig()
const configEnvVars = config.envVars ?? {}
// Auto-generate API key
const generatedKey = crypto.randomBytes(24).toString('base64url')
// Find available port
let desiredPort = port || 39284
let availablePort = desiredPort
while (await portInUse(availablePort, host)) {
availablePort++
if (availablePort > desiredPort + 100) {
throw new Error('No available port found for Open Terminal')
}
}
const cwd = config.openTerminal?.cwd || require('os').homedir()
const commandArgs = [
'-m', 'uv', 'run', 'open-terminal', 'run',
'--host', host,
'--port', availablePort.toString(),
'--api-key', generatedKey,
'--cwd', cwd
]
log.info('Starting Open Terminal...', pythonPath, commandArgs.join(' '))
let spawned: pty.IPty
try {
spawned = pty.spawn(pythonPath, commandArgs, {
name: 'xterm-256color',
cols: 200,
rows: 50,
env: {
...process.env,
...(configEnvVars ?? {}),
PYTHONUNBUFFERED: '1',
...(process.platform === 'win32' ? { PYTHONIOENCODING: 'utf-8' } : {})
}
})
} catch (error) {
throw new Error(
`Failed to spawn Open Terminal: ${error?.message ?? error}`
)
}
const spawnedPid = spawned.pid
logBuffer = []
ptyProcess = spawned
pid = spawnedPid
apiKey = generatedKey
status = 'starting'
spawned.onData((data: string) => {
logBuffer.push(data)
log.info(`[OpenTerminal:${spawnedPid}] ${data.replace(/[\r\n]+/g, ' ').trim()}`)
})
spawned.onExit(({ exitCode, signal }) => {
log.info(`[OpenTerminal:${spawnedPid}] Exited code=${exitCode} signal=${signal}`)
ptyProcess = null
pid = null
url = null
apiKey = null
status = 'stopped'
})
const serverUrl = `http://${host}:${availablePort}`
url = serverUrl
status = 'started'
log.info(`Open Terminal started — PID: ${spawnedPid}, URL: ${serverUrl}`)
return { url: serverUrl, apiKey: generatedKey, pid: spawnedPid }
}
export const stopOpenTerminal = async (): Promise<void> => {
if (ptyProcess) {
try {
ptyProcess.kill()
} catch (e) {
log.warn('Failed to kill Open Terminal PTY:', e)
}
// Give it a moment to exit
await new Promise((r) => setTimeout(r, 1000))
// Force kill if still running
if (pid) {
try {
process.kill(pid, 0) // check alive
process.kill(pid, 'SIGKILL')
} catch {
// already dead
}
}
}
ptyProcess = null
pid = null
url = null
apiKey = null
status = null
logBuffer = []
lock.release()
}
/**
* Validate whether the tracked Open Terminal process is still alive.
*/
export const validateOpenTerminalProcess = (): boolean => {
if (!pid) return false
if (isProcessAlive(pid)) return true
pid = null
status = null
lock.release()
return false
}
-67
View File
@@ -1,67 +0,0 @@
// @ts-nocheck
/**
* ServiceLock — reusable singleton lock for managed child processes.
*
* In Node.js, the synchronous check-and-set before any `await` is atomic
* (event loop guarantees no interleaving). This class makes that pattern
* explicit and self-documenting.
*
* Usage:
* const lock = new ServiceLock('my-service')
* if (!lock.acquire()) return existingResult
* try { ... } catch { lock.release() }
* // release in stop(), not in start()
*/
import log from 'electron-log'
export class ServiceLock {
private locked = false
private name: string
constructor(name: string) {
this.name = name
}
/**
* Try to acquire the lock. Returns false if already locked.
* This is synchronous — no interleaving possible in Node.js event loop.
*/
acquire(): boolean {
if (this.locked) {
log.info(`[${this.name}] Lock held — rejecting duplicate start`)
return false
}
this.locked = true
return true
}
/**
* Release the lock. Called in stop() or on failure.
*/
release(): void {
this.locked = false
}
/**
* Check if the lock is currently held.
*/
isLocked(): boolean {
return this.locked
}
}
/**
* Validate whether a PID is still alive.
* Returns true if the process exists, false if it's gone.
*/
export const isProcessAlive = (pid: number | null): boolean => {
if (!pid) return false
try {
process.kill(pid, 0) // signal 0 = existence check, no actual signal sent
return true
} catch {
return false
}
}
-44
View File
@@ -1,44 +0,0 @@
import { ipcRenderer, contextBridge } from 'electron'
// ─── Desktop ↔ Open WebUI Generic Protocol ──────────────
// This preload is a dumb relay. It passes typed {type, data}
// messages between the embedder (desktop renderer) and the
// Open WebUI page. Business logic lives elsewhere.
// To add new features, just add new event types — this file
// never needs to change.
type EventCallback = (data: any) => void
const eventCallbacks: EventCallback[] = []
// Embedder → Guest (push events from desktop)
ipcRenderer.on('desktop:event', (_event, data) => {
eventCallbacks.forEach((cb) => cb(data))
})
// Expose to the Open WebUI page via contextBridge (secure, unforgeable)
contextBridge.exposeInMainWorld('electronAPI', {
// Push events: desktop → Open WebUI
onEvent: (callback: EventCallback): void => {
eventCallbacks.push(callback)
},
// Request/Response: Open WebUI → desktop
send: (data: any): Promise<any> => {
return new Promise((resolve) => {
const id = Math.random().toString(36).slice(2)
const handler = (_event: any, response: any) => {
if (response?._responseId === id) {
ipcRenderer.removeListener('desktop:response', handler)
resolve(response.data)
}
}
ipcRenderer.on('desktop:response', handler)
ipcRenderer.sendToHost('webview:send', { ...data, _requestId: id })
})
},
// Navigation: Open WebUI → desktop
load: (page: string): void => {
ipcRenderer.sendToHost('webview:load', page)
}
})
+173 -207
View File
@@ -1,229 +1,195 @@
import { ipcRenderer, contextBridge } from 'electron'
import { electronAPI } from '@electron-toolkit/preload'
import { ipcRenderer, contextBridge } from "electron";
import { electronAPI } from "@electron-toolkit/preload";
window.addEventListener('DOMContentLoaded', () => {
ipcRenderer.on('main:data', (_event, data) => {
window.postMessage(
{
...data,
type: `electron:${data.type}`
},
window.location.origin
)
})
})
const isLocalSource = () => {
// Check if the execution environment is local
const origin = window.location.origin;
// ─── PTY MessagePort ────────────────────────────────────
// The MessagePort stays in the preload (cannot cross contextBridge).
// We expose simple functions so the renderer never touches the port.
let activePtyPort: MessagePort | null = null
let ptyOutputCallback: ((data: string) => void) | null = null
// Allow local sources: file protocol, localhost, or 0.0.0.0
return (
origin.startsWith("file://") ||
origin.includes("localhost") ||
origin.includes("127.0.0.1") ||
origin.includes("0.0.0.0")
);
};
ipcRenderer.on('pty:port', (event, _data) => {
const [port] = event.ports
if (!port) return
// Clean up previous port
if (activePtyPort) {
activePtyPort.close()
}
activePtyPort = port
port.onmessage = (ev: MessageEvent) => {
if (ev.data?.type === 'output' && ptyOutputCallback) {
ptyOutputCallback(ev.data.data)
}
}
port.start()
})
// ─── Open Terminal PTY MessagePort ──────────────────────
let activeOtPtyPort: MessagePort | null = null
let otPtyOutputCallback: ((data: string) => void) | null = null
ipcRenderer.on('open-terminal:pty:port', (event, _data) => {
const [port] = event.ports
if (!port) return
if (activeOtPtyPort) {
activeOtPtyPort.close()
}
activeOtPtyPort = port
port.onmessage = (ev: MessageEvent) => {
if (ev.data?.type === 'output' && otPtyOutputCallback) {
otPtyOutputCallback(ev.data.data)
}
}
port.start()
})
// ─── llama.cpp PTY MessagePort ───────────────────────
let activeLsCppPtyPort: MessagePort | null = null
let lsCppPtyOutputCallback: ((data: string) => void) | null = null
ipcRenderer.on('llamacpp:pty:port', (event, _data) => {
const [port] = event.ports
if (!port) return
if (activeLsCppPtyPort) {
activeLsCppPtyPort.close()
}
activeLsCppPtyPort = port
port.onmessage = (ev: MessageEvent) => {
if (ev.data?.type === 'output' && lsCppPtyOutputCallback) {
lsCppPtyOutputCallback(ev.data.data)
}
}
port.start()
})
window.addEventListener("DOMContentLoaded", () => {
// Listen for messages from the main process
ipcRenderer.on("main:data", (event, data) => {
// Forward the message to the renderer using window.postMessage
window.postMessage(
{
...data,
type: `electron:${data.type}`,
},
window.location.origin
);
});
});
// Custom APIs for renderer
const api = {
onData: (callback: (data: any) => void) => {
const handler = (_: any, data: any): void => callback(data)
ipcRenderer.on('main:data', handler)
return () => ipcRenderer.removeListener('main:data', handler)
},
onLog: (callback: (message: string) => void) => {
if (!isLocalSource()) {
throw new Error(
"Access restricted: This operation is only allowed in a local environment."
);
}
// App
getAppInfo: () => ipcRenderer.invoke('app:info'),
getVersion: () => ipcRenderer.invoke('get:version'),
resetApp: () => ipcRenderer.invoke('app:reset'),
getDefaultDataPath: () => ipcRenderer.invoke('app:defaultDataPath'),
getContentPreloadPath: () => ipcRenderer.invoke('app:contentPreloadPath'),
getDiskSpace: () => ipcRenderer.invoke('system:diskSpace'),
getLaunchAtLogin: () => ipcRenderer.invoke('app:launchAtLogin:get'),
setLaunchAtLogin: (enabled: boolean) => ipcRenderer.invoke('app:launchAtLogin:set', enabled),
openInBrowser: (url: string) => ipcRenderer.invoke('open:browser', { url }),
openPath: (folderPath: string) => ipcRenderer.invoke('open:path', folderPath),
notification: (title: string, body: string) =>
ipcRenderer.invoke('notification', { title, body }),
ipcRenderer.on("main:log", (_, message: string) => callback(message));
},
// Config
getConfig: () => ipcRenderer.invoke('get:config'),
setConfig: (config: Record<string, any>) => ipcRenderer.invoke('set:config', config),
send: async ({ type, data }: { type: string; data?: any }) => {
return await ipcRenderer.invoke("renderer:data", { type, data });
},
// Python/uv
installPython: () => ipcRenderer.invoke('install:python'),
getPythonStatus: () => ipcRenderer.invoke('status:python'),
openInBrowser: async (url: string) => {
if (!isLocalSource()) {
throw new Error(
"Access restricted: This operation is only allowed in a local environment."
);
}
// Package
installPackage: () => ipcRenderer.invoke('install:package'),
getPackageStatus: () => ipcRenderer.invoke('status:package'),
await ipcRenderer.invoke("open:browser", { url });
},
// Server
startServer: () => ipcRenderer.invoke('server:start'),
stopServer: () => ipcRenderer.invoke('server:stop'),
restartServer: () => ipcRenderer.invoke('server:restart'),
getServerInfo: () => ipcRenderer.invoke('server:info'),
getServerLogs: () => ipcRenderer.invoke('server:logs'),
clearServerLogs: () => ipcRenderer.invoke('server:logs:clear'),
getVersion: async () => {
if (!isLocalSource()) {
throw new Error(
"Access restricted: This operation is only allowed in a local environment."
);
}
// PTY — MessagePort stays in preload, renderer uses these functions
listPtys: () => ipcRenderer.invoke('pty:list'),
connectPty: (onOutput: (data: string) => void, pid?: number) => {
ptyOutputCallback = onOutput
ipcRenderer.invoke('pty:connect', pid)
},
writePty: (data: string) => {
activePtyPort?.postMessage({ type: 'input', data })
},
resizePty: (cols: number, rows: number) => {
activePtyPort?.postMessage({ type: 'resize', cols, rows })
},
disconnectPty: () => {
ptyOutputCallback = null
if (activePtyPort) {
activePtyPort.close()
activePtyPort = null
}
},
return await ipcRenderer.invoke("get:version");
},
// Open Terminal
startOpenTerminal: () => ipcRenderer.invoke('open-terminal:start'),
stopOpenTerminal: () => ipcRenderer.invoke('open-terminal:stop'),
getOpenTerminalInfo: () => ipcRenderer.invoke('open-terminal:info'),
getOpenTerminalStatus: () => ipcRenderer.invoke('open-terminal:status'),
connectOpenTerminalPty: (onOutput: (data: string) => void) => {
otPtyOutputCallback = onOutput
ipcRenderer.invoke('open-terminal:pty:connect')
},
disconnectOpenTerminalPty: () => {
otPtyOutputCallback = null
if (activeOtPtyPort) {
activeOtPtyPort.close()
activeOtPtyPort = null
}
},
getConfig: async () => {
if (!isLocalSource()) {
throw new Error(
"Access restricted: This operation is only allowed in a local environment."
);
}
// llama.cpp
setupLlamaCpp: () => ipcRenderer.invoke('llamacpp:setup'),
startLlamaCpp: () => ipcRenderer.invoke('llamacpp:start'),
stopLlamaCpp: () => ipcRenderer.invoke('llamacpp:stop'),
getLlamaCppInfo: () => ipcRenderer.invoke('llamacpp:info'),
getLlamaCppLogs: () => ipcRenderer.invoke('llamacpp:logs'),
connectLlamaCppPty: (onOutput: (data: string) => void) => {
lsCppPtyOutputCallback = onOutput
ipcRenderer.invoke('llamacpp:pty:connect')
},
disconnectLlamaCppPty: () => {
lsCppPtyOutputCallback = null
if (activeLsCppPtyPort) {
activeLsCppPtyPort.close()
activeLsCppPtyPort = null
}
},
checkLlamaCppUpdate: () => ipcRenderer.invoke('llamacpp:check-update'),
updateLlamaCpp: () => ipcRenderer.invoke('llamacpp:update'),
uninstallLlamaCpp: () => ipcRenderer.invoke('llamacpp:uninstall'),
return await ipcRenderer.invoke("get:config");
},
// Hugging Face models
listHfModels: () => ipcRenderer.invoke('huggingface:models:list'),
getHfModelsDir: () => ipcRenderer.invoke('huggingface:models:dir'),
downloadHfModel: (repo: string, filename: string, token?: string, expectedSize?: number) =>
ipcRenderer.invoke('huggingface:models:download', repo, filename, token, expectedSize),
deleteHfModel: (repo: string, filename: string) =>
ipcRenderer.invoke('huggingface:models:delete', repo, filename),
cancelHfDownload: () => ipcRenderer.invoke('huggingface:models:cancel'),
searchHfModels: (query: string, token?: string) =>
ipcRenderer.invoke('huggingface:search', query, token),
getHfRepoFiles: (repo: string, token?: string) =>
ipcRenderer.invoke('huggingface:repo:files', repo, token),
setConfig: async (config: Record<string, any>) => {
if (!isLocalSource()) {
throw new Error(
"Access restricted: This operation is only allowed in a local environment."
);
}
// Package
getPackageVersion: (packageName: string) => ipcRenderer.invoke('package:version', packageName),
uninstallPackage: (packageName: string) => ipcRenderer.invoke('package:uninstall', packageName),
return await ipcRenderer.invoke("set:config", config);
},
// Connections
getConnections: () => ipcRenderer.invoke('connections:list'),
addConnection: (connection: any) => ipcRenderer.invoke('connections:add', connection),
removeConnection: (id: string) => ipcRenderer.invoke('connections:remove', id),
updateConnection: (id: string, updates: any) => ipcRenderer.invoke('connections:update', id, updates),
setDefaultConnection: (id: string) => ipcRenderer.invoke('connections:setDefault', id),
connectTo: (id: string) => ipcRenderer.invoke('connections:connect', id),
validateUrl: (url: string) => ipcRenderer.invoke('validate:url', url),
selectFolder: () => ipcRenderer.invoke('dialog:selectFolder'),
installPython: async () => {
if (!isLocalSource()) {
throw new Error(
"Access restricted: This operation is only allowed in a local environment."
);
}
// Updater
checkForUpdates: () => ipcRenderer.invoke('updater:check'),
downloadUpdate: () => ipcRenderer.invoke('updater:download'),
installUpdate: () => ipcRenderer.invoke('updater:install'),
return await ipcRenderer.invoke("install:python");
},
// Changelog
getChangelog: () => ipcRenderer.invoke('app:changelog')
}
installPackage: async () => {
if (!isLocalSource()) {
throw new Error(
"Access restricted: This operation is only allowed in a local environment."
);
}
return await ipcRenderer.invoke("install:package");
},
getPythonStatus: async () => {
return await ipcRenderer.invoke("status:python");
},
getPackageStatus: async () => {
return await ipcRenderer.invoke("status:package");
},
getServerStatus: async () => {
if (!isLocalSource()) {
throw new Error(
"Access restricted: This operation is only allowed in a local environment."
);
}
return await ipcRenderer.invoke("status:server");
},
getServerInfo: async () => {
if (!isLocalSource()) {
throw new Error(
"Access restricted: This operation is only allowed in a local environment."
);
}
return await ipcRenderer.invoke("server:info");
},
startServer: async () => {
if (!isLocalSource()) {
throw new Error(
"Access restricted: This operation is only allowed in a local environment."
);
}
return await ipcRenderer.invoke("server:start");
},
stopServer: async () => {
if (!isLocalSource()) {
throw new Error(
"Access restricted: This operation is only allowed in a local environment."
);
}
return await ipcRenderer.invoke("server:stop");
},
restartServer: async () => {
if (!isLocalSource()) {
throw new Error(
"Access restricted: This operation is only allowed in a local environment."
);
}
return await ipcRenderer.invoke("server:restart");
},
getServerUrl: async () => {
return await ipcRenderer.invoke("server:url");
},
notification: async (title: string, body: string) => {
if (!isLocalSource()) {
throw new Error(
"Access restricted: This operation is only allowed in a local environment."
);
}
return await ipcRenderer.invoke("notification", { title, body });
},
};
// Use `contextBridge` APIs to expose Electron APIs to
// renderer only if context isolation is enabled, otherwise
// just add to the DOM global.
if (process.contextIsolated) {
try {
contextBridge.exposeInMainWorld('electron', electronAPI)
contextBridge.exposeInMainWorld('electronAPI', api)
} catch (error) {
console.error(error)
}
try {
contextBridge.exposeInMainWorld("electron", electronAPI);
contextBridge.exposeInMainWorld("electronAPI", api);
} catch (error) {
console.error(error);
}
} else {
// @ts-ignore
window.electron = electronAPI
// @ts-ignore
window.electronAPI = api
// @ts-ignore (define in dts)
window.electron = electronAPI;
// @ts-ignore (define in dts)
window.electronAPI = api;
}
-21
View File
@@ -1,21 +0,0 @@
import { ipcRenderer, contextBridge } from 'electron'
const api = {
submitQuery: (query: string): void => {
ipcRenderer.invoke('spotlight:submit', query)
},
closeSpotlight: (): void => {
ipcRenderer.invoke('spotlight:close')
}
}
if (process.contextIsolated) {
try {
contextBridge.exposeInMainWorld('spotlightAPI', api)
} catch (error) {
console.error(error)
}
} else {
// @ts-ignore
window.spotlightAPI = api
}
+2 -2
View File
@@ -2,11 +2,11 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Electron</title>
<title>Open WebUI</title>
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; media-src 'self' https://community.s3.openwebui.com; connect-src 'self' https://community.s3.openwebui.com https://fonts.googleapis.com https://fonts.gstatic.com"
content="script-src 'self'; style-src 'self' 'unsafe-inline';"
/>
</head>
-15
View File
@@ -1,15 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Open WebUI Spotlight</title>
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'"
/>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/spotlight-main.ts"></script>
</body>
</html>
+57 -56
View File
@@ -1,69 +1,70 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte'
import { fade } from 'svelte/transition'
import { appInfo, config, connections, serverInfo, appState } from './lib/stores'
import { toast, Toaster } from "svelte-sonner";
import { onMount } from "svelte";
import Main from './lib/components/Main.svelte'
import Controls from "./lib/components/Controls.svelte";
import Installation from "./lib/components/Installation.svelte";
import Loading from "./lib/components/Loading.svelte";
let themeMediaQuery: MediaQueryList
let themeChangeHandler: ((e: MediaQueryListEvent) => void) | null = null
import { info, config } from "./lib/stores";
const applyResolvedTheme = (theme: string) => {
let resolved = theme
if (theme === 'system') {
resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
document.documentElement.classList.remove('light', 'dark')
document.documentElement.classList.add(resolved)
}
let installed = $state(false);
onMount(async () => {
const api = window?.electronAPI
if (!api) return
onMount(async () => {
config.set(await window?.electronAPI?.getConfig());
appInfo.set(await api.getAppInfo())
config.set(await api.getConfig())
connections.set(await api.getConnections())
const pythonStatus = await window?.electronAPI?.getPythonStatus();
if (pythonStatus) {
const packageStatus = await window?.electronAPI?.getPackageStatus();
if (packageStatus) {
installed = true;
} else {
installed = false;
}
}
// Apply saved theme
const savedTheme = (await api.getConfig())?.theme ?? 'system'
applyResolvedTheme(savedTheme)
window.addEventListener("message", async (event) => {
console.log("Received message from main process:", event);
if (event.data?.type === "electron:notification") {
if (event.data?.data?.type) {
toast(event.data.data.message, {
type: event.data?.data?.type,
});
} else {
toast(event.data?.data.message);
}
}
// Listen for OS theme changes so "system" mode reacts in real-time
themeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
themeChangeHandler = () => {
const currentTheme = $config?.theme ?? 'system'
if (currentTheme === 'system') {
applyResolvedTheme('system')
}
}
themeMediaQuery.addEventListener('change', themeChangeHandler)
if (event.data?.type === "electron:reload") {
window.location.reload();
}
api.onData((data: any) => {
if (data.type === 'status:server') {
serverInfo.update((info) => ({ ...info, status: data.data }))
}
if (data.type === 'server:ready') {
serverInfo.update((info) => ({ ...info, reachable: true, url: data.data?.url }))
}
})
if (event.data?.type === "electron:server") {
info.set(await window.electronAPI.getServerInfo());
}
});
// Don't auto-install anything — the user must explicitly choose
// "Get Started" (local install) which handles Python/uv as a prerequisite.
appState.set('ready')
setInterval(async () => {
serverInfo.set(await api.getServerInfo())
}, 3000)
})
onDestroy(() => {
if (themeMediaQuery && themeChangeHandler) {
themeMediaQuery.removeEventListener('change', themeChangeHandler)
}
})
info.set(await window.electronAPI.getServerInfo());
setInterval(async () => {
info.set(await window.electronAPI.getServerInfo());
}, 1000);
});
</script>
<main class="w-full h-full bg-[#f5f5f7] dark:bg-[#0a0a0a]">
<Main />
<main class="w-screen h-screen bg-gray-900">
{#if installed === null}
<Loading />
{:else if installed === false}
<Installation bind:installed />
{:else}
<Controls />
{/if}
</main>
<Toaster
theme={window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"}
richColors
position="top-center"
/>
+44 -55
View File
@@ -1,29 +1,17 @@
@import "tailwindcss";
@variant dark (&:where(.dark, .dark *));
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentColor);
}
}
@layer base {
button {
@apply cursor-pointer;
}
}
@font-face {
font-family: "Archivo";
src: url("./lib/assets/fonts/Archivo-Variable.ttf");
font-display: swap;
}
@font-face {
font-family: "InstrumentSerif";
src: url("./lib/assets/fonts/InstrumentSerif-Regular.ttf");
font-display: swap;
}
.drag-region {
-webkit-app-region: drag;
}
@@ -33,10 +21,35 @@
-webkit-app-region: no-drag;
}
.no-drag {
.no-drag-region {
-webkit-app-region: no-drag;
}
.font-secondary {
font-family: "InstrumentSerif", sans-serif;
}
.font-system {
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
"Helvetica Neue",
Arial,
"Noto Sans",
sans-serif,
"Apple Color Emoji",
"Segoe UI Emoji",
"Segoe UI Symbol",
"Noto Color Emoji";
}
html {
font-family: "Archivo";
}
@theme {
--color-white: #fff;
--color-black: #000;
@@ -54,48 +67,24 @@
--color-gray-950: #0d0d0d;
}
html {
font-family: "Archivo", -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
background: #f5f5f7;
color: #1d1d1f;
font-size: 13px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
user-select: none;
.tippy-box[data-theme~="dark"] {
@apply rounded-lg bg-gray-950 text-xs border border-gray-900 shadow-xl;
}
html.dark {
background: #0a0a0a;
color: #fafafa;
.tippy-box[data-theme~="transparent"] {
@apply bg-transparent p-0 m-0;
}
body {
height: 100%;
width: 100%;
overflow: hidden;
.scrollbar-hidden:active::-webkit-scrollbar-thumb,
.scrollbar-hidden:focus::-webkit-scrollbar-thumb,
.scrollbar-hidden:hover::-webkit-scrollbar-thumb {
visibility: visible;
}
#app {
height: 100vh;
.scrollbar-hidden::-webkit-scrollbar-thumb {
visibility: hidden;
}
::selection {
background: rgba(0, 0, 0, 0.12);
}
.dark ::selection {
background: rgba(255, 255, 255, 0.15);
}
::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.08);
border-radius: 99px;
}
.dark ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.08);
}
::-webkit-scrollbar-track { background: transparent; }
.xterm-screen {
height: 100% !important;
.scrollbar-hidden::-webkit-scrollbar-corner {
display: none;
}
-79
View File
@@ -1,79 +0,0 @@
:root {
--ev-c-white: #ffffff;
--ev-c-white-soft: #f8f8f8;
--ev-c-white-mute: #f2f2f2;
--ev-c-black: #1b1b1f;
--ev-c-black-soft: #222222;
--ev-c-black-mute: #282828;
--ev-c-gray-1: #515c67;
--ev-c-gray-2: #414853;
--ev-c-gray-3: #32363f;
--ev-c-text-1: rgba(255, 255, 245, 0.86);
--ev-c-text-2: rgba(235, 235, 245, 0.6);
--ev-c-text-3: rgba(235, 235, 245, 0.38);
--ev-button-alt-border: transparent;
--ev-button-alt-text: var(--ev-c-text-1);
--ev-button-alt-bg: var(--ev-c-gray-3);
--ev-button-alt-hover-border: transparent;
--ev-button-alt-hover-text: var(--ev-c-text-1);
--ev-button-alt-hover-bg: var(--ev-c-gray-2);
}
:root {
--color-background: var(--ev-c-black);
--color-background-soft: var(--ev-c-black-soft);
--color-background-mute: var(--ev-c-black-mute);
--color-text: var(--ev-c-text-1);
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
font-weight: normal;
}
ul {
list-style: none;
}
body {
min-height: 100vh;
color: var(--color-text);
background: var(--color-background);
line-height: 1.6;
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Light theme overrides */
html.light {
--color-background: #f5f5f7;
--color-background-soft: #e8e8ed;
--color-background-mute: #d1d1d6;
--color-text: #1d1d1f;
--ev-c-text-1: rgba(29, 29, 31, 0.9);
--ev-c-text-2: rgba(29, 29, 31, 0.6);
--ev-c-text-3: rgba(29, 29, 31, 0.4);
color-scheme: light;
}
-10
View File
@@ -1,10 +0,0 @@
<svg viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="64" cy="64" r="64" fill="#2F3242"/>
<ellipse cx="63.9835" cy="23.2036" rx="4.48794" ry="4.495" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
<path d="M51.3954 39.5028C52.3733 39.6812 53.3108 39.033 53.4892 38.055C53.6676 37.0771 53.0194 36.1396 52.0414 35.9612L51.3954 39.5028ZM28.6153 43.5751L30.1748 44.4741L30.1748 44.4741L28.6153 43.5751ZM28.9393 60.9358C29.4332 61.7985 30.5329 62.0976 31.3957 61.6037C32.2585 61.1098 32.5575 60.0101 32.0636 59.1473L28.9393 60.9358ZM37.6935 66.7457C37.025 66.01 35.8866 65.9554 35.1508 66.6239C34.415 67.2924 34.3605 68.4308 35.029 69.1666L37.6935 66.7457ZM53.7489 81.7014L52.8478 83.2597L53.7489 81.7014ZM96.9206 89.515C97.7416 88.9544 97.9526 87.8344 97.3919 87.0135C96.8313 86.1925 95.7113 85.9815 94.8904 86.5422L96.9206 89.515ZM52.0414 35.9612C46.4712 34.9451 41.2848 34.8966 36.9738 35.9376C32.6548 36.9806 29.0841 39.1576 27.0559 42.6762L30.1748 44.4741C31.5693 42.0549 34.1448 40.3243 37.8188 39.4371C41.5009 38.5479 46.1547 38.5468 51.3954 39.5028L52.0414 35.9612ZM27.0559 42.6762C24.043 47.9029 25.2781 54.5399 28.9393 60.9358L32.0636 59.1473C28.6579 53.1977 28.1088 48.0581 30.1748 44.4741L27.0559 42.6762ZM35.029 69.1666C39.6385 74.24 45.7158 79.1355 52.8478 83.2597L54.6499 80.1432C47.8081 76.1868 42.0298 71.5185 37.6935 66.7457L35.029 69.1666ZM52.8478 83.2597C61.344 88.1726 70.0465 91.2445 77.7351 92.3608C85.359 93.4677 92.2744 92.6881 96.9206 89.515L94.8904 86.5422C91.3255 88.9767 85.4902 89.849 78.2524 88.7982C71.0793 87.7567 62.809 84.8612 54.6499 80.1432L52.8478 83.2597ZM105.359 84.9077C105.359 81.4337 102.546 78.6127 99.071 78.6127V82.2127C100.553 82.2127 101.759 83.4166 101.759 84.9077H105.359ZM99.071 78.6127C95.5956 78.6127 92.7831 81.4337 92.7831 84.9077H96.3831C96.3831 83.4166 97.5892 82.2127 99.071 82.2127V78.6127ZM92.7831 84.9077C92.7831 88.3817 95.5956 91.2027 99.071 91.2027V87.6027C97.5892 87.6027 96.3831 86.3988 96.3831 84.9077H92.7831ZM99.071 91.2027C102.546 91.2027 105.359 88.3817 105.359 84.9077H101.759C101.759 86.3988 100.553 87.6027 99.071 87.6027V91.2027Z" fill="#A2ECFB"/>
<path d="M91.4873 65.382C90.8456 66.1412 90.9409 67.2769 91.7002 67.9186C92.4594 68.5603 93.5951 68.465 94.2368 67.7058L91.4873 65.382ZM99.3169 43.6354L97.7574 44.5344L99.3169 43.6354ZM84.507 35.2412C83.513 35.2282 82.6967 36.0236 82.6838 37.0176C82.6708 38.0116 83.4661 38.8279 84.4602 38.8409L84.507 35.2412ZM74.9407 39.8801C75.9127 39.6716 76.5315 38.7145 76.323 37.7425C76.1144 36.7706 75.1573 36.1517 74.1854 36.3603L74.9407 39.8801ZM53.7836 46.3728L54.6847 47.931L53.7836 46.3728ZM25.5491 80.9047C25.6932 81.8883 26.6074 82.5688 27.5911 82.4247C28.5747 82.2806 29.2552 81.3664 29.1111 80.3828L25.5491 80.9047ZM94.2368 67.7058C97.8838 63.3907 100.505 58.927 101.752 54.678C103.001 50.4213 102.9 46.2472 100.876 42.7365L97.7574 44.5344C99.1494 46.9491 99.3603 50.0419 98.2974 53.6644C97.2323 57.2945 94.9184 61.3223 91.4873 65.382L94.2368 67.7058ZM100.876 42.7365C97.9119 37.5938 91.7082 35.335 84.507 35.2412L84.4602 38.8409C91.1328 38.9278 95.7262 41.0106 97.7574 44.5344L100.876 42.7365ZM74.1854 36.3603C67.4362 37.8086 60.0878 40.648 52.8826 44.8146L54.6847 47.931C61.5972 43.9338 68.5948 41.2419 74.9407 39.8801L74.1854 36.3603ZM52.8826 44.8146C44.1366 49.872 36.9669 56.0954 32.1491 62.3927C27.3774 68.63 24.7148 75.2115 25.5491 80.9047L29.1111 80.3828C28.4839 76.1026 30.4747 70.5062 35.0084 64.5802C39.496 58.7143 46.2839 52.7889 54.6847 47.931L52.8826 44.8146Z" fill="#A2ECFB"/>
<path d="M49.0825 87.2295C48.7478 86.2934 47.7176 85.8059 46.7816 86.1406C45.8455 86.4753 45.358 87.5055 45.6927 88.4416L49.0825 87.2295ZM78.5635 96.4256C79.075 95.5732 78.7988 94.4675 77.9464 93.9559C77.0941 93.4443 75.9884 93.7205 75.4768 94.5729L78.5635 96.4256ZM79.5703 85.1795C79.2738 86.1284 79.8027 87.1379 80.7516 87.4344C81.7004 87.7308 82.71 87.2019 83.0064 86.2531L79.5703 85.1795ZM84.3832 64.0673H82.5832H84.3832ZM69.156 22.5301C68.2477 22.1261 67.1838 22.535 66.7799 23.4433C66.3759 24.3517 66.7848 25.4155 67.6931 25.8194L69.156 22.5301ZM45.6927 88.4416C47.5994 93.7741 50.1496 98.2905 53.2032 101.505C56.2623 104.724 59.9279 106.731 63.9835 106.731V103.131C61.1984 103.131 58.4165 101.765 55.8131 99.0249C53.2042 96.279 50.8768 92.2477 49.0825 87.2295L45.6927 88.4416ZM63.9835 106.731C69.8694 106.731 74.8921 102.542 78.5635 96.4256L75.4768 94.5729C72.0781 100.235 68.0122 103.131 63.9835 103.131V106.731ZM83.0064 86.2531C85.0269 79.7864 86.1832 72.1831 86.1832 64.0673H82.5832C82.5832 71.8536 81.4723 79.0919 79.5703 85.1795L83.0064 86.2531ZM86.1832 64.0673C86.1832 54.1144 84.4439 44.922 81.4961 37.6502C78.5748 30.4436 74.3436 24.8371 69.156 22.5301L67.6931 25.8194C71.6364 27.5731 75.3846 32.1564 78.1598 39.0026C80.9086 45.7836 82.5832 54.507 82.5832 64.0673H86.1832Z" fill="#A2ECFB"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M103.559 84.9077C103.559 82.4252 101.55 80.4127 99.071 80.4127C96.5924 80.4127 94.5831 82.4252 94.5831 84.9077C94.5831 87.3902 96.5924 89.4027 99.071 89.4027C101.55 89.4027 103.559 87.3902 103.559 84.9077V84.9077Z" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M28.8143 89.4027C31.2929 89.4027 33.3023 87.3902 33.3023 84.9077C33.3023 82.4252 31.2929 80.4127 28.8143 80.4127C26.3357 80.4127 24.3264 82.4252 24.3264 84.9077C24.3264 87.3902 26.3357 89.4027 28.8143 89.4027V89.4027V89.4027Z" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M64.8501 68.0857C62.6341 68.5652 60.451 67.1547 59.9713 64.9353C59.4934 62.7159 60.9007 60.5293 63.1167 60.0489C65.3326 59.5693 67.5157 60.9798 67.9954 63.1992C68.4742 65.4186 67.066 67.6052 64.8501 68.0857Z" fill="#A2ECFB"/>
</svg>

Before

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.
-171
View File
@@ -1,171 +0,0 @@
@import './base.css';
body {
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background-image: url('./wavy-lines.svg');
background-size: cover;
user-select: none;
}
code {
font-weight: 600;
padding: 3px 5px;
border-radius: 2px;
background-color: var(--color-background-mute);
font-family:
ui-monospace,
SFMono-Regular,
SF Mono,
Menlo,
Consolas,
Liberation Mono,
monospace;
font-size: 85%;
}
#app {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
margin-bottom: 80px;
}
.logo {
margin-bottom: 20px;
-webkit-user-drag: none;
height: 128px;
width: 128px;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 1.2em #6988e6aa);
}
.creator {
font-size: 14px;
line-height: 16px;
color: var(--ev-c-text-2);
font-weight: 600;
margin-bottom: 10px;
}
.text {
font-size: 28px;
color: var(--ev-c-text-1);
font-weight: 700;
line-height: 32px;
text-align: center;
margin: 0 10px;
padding: 16px 0;
}
.tip {
font-size: 16px;
line-height: 24px;
color: var(--ev-c-text-2);
font-weight: 600;
}
.svelte {
background: -webkit-linear-gradient(315deg, #ff3e00 35%, #647eff);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
font-weight: 700;
}
.ts {
background: -webkit-linear-gradient(315deg, #3178c6 45%, #f0dc4e);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
font-weight: 700;
}
.actions {
display: flex;
padding-top: 32px;
margin: -6px;
flex-wrap: wrap;
justify-content: flex-start;
}
.action {
flex-shrink: 0;
padding: 6px;
}
.action a {
cursor: pointer;
text-decoration: none;
display: inline-block;
border: 1px solid transparent;
text-align: center;
font-weight: 600;
white-space: nowrap;
border-radius: 20px;
padding: 0 20px;
line-height: 38px;
font-size: 14px;
border-color: var(--ev-button-alt-border);
color: var(--ev-button-alt-text);
background-color: var(--ev-button-alt-bg);
}
.action a:hover {
border-color: var(--ev-button-alt-hover-border);
color: var(--ev-button-alt-hover-text);
background-color: var(--ev-button-alt-hover-bg);
}
.versions {
position: absolute;
bottom: 30px;
margin: 0 auto;
padding: 15px 0;
font-family: 'Menlo', 'Lucida Console', monospace;
display: inline-flex;
overflow: hidden;
align-items: center;
border-radius: 22px;
background-color: #202127;
backdrop-filter: blur(24px);
}
.versions li {
display: block;
float: left;
border-right: 1px solid var(--ev-c-gray-1);
padding: 0 20px;
font-size: 14px;
line-height: 14px;
opacity: 0.8;
&:last-child {
border: none;
}
}
@media (max-width: 720px) {
.text {
font-size: 20px;
}
}
@media (max-width: 620px) {
.versions {
display: none;
}
}
@media (max-width: 350px) {
.tip,
.actions {
display: none;
}
}
-25
View File
@@ -1,25 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1422 800" opacity="0.3">
<defs>
<linearGradient x1="50%" y1="0%" x2="50%" y2="100%" id="oooscillate-grad">
<stop stop-color="hsl(206, 75%, 49%)" stop-opacity="1" offset="0%"></stop>
<stop stop-color="hsl(331, 90%, 56%)" stop-opacity="1" offset="100%"></stop>
</linearGradient>
</defs>
<g stroke-width="1" stroke="url(#oooscillate-grad)" fill="none" stroke-linecap="round">
<path d="M 0 448 Q 355.5 -100 711 400 Q 1066.5 900 1422 448" opacity="0.05"></path>
<path d="M 0 420 Q 355.5 -100 711 400 Q 1066.5 900 1422 420" opacity="0.11"></path>
<path d="M 0 392 Q 355.5 -100 711 400 Q 1066.5 900 1422 392" opacity="0.18"></path>
<path d="M 0 364 Q 355.5 -100 711 400 Q 1066.5 900 1422 364" opacity="0.24"></path>
<path d="M 0 336 Q 355.5 -100 711 400 Q 1066.5 900 1422 336" opacity="0.30"></path>
<path d="M 0 308 Q 355.5 -100 711 400 Q 1066.5 900 1422 308" opacity="0.37"></path>
<path d="M 0 280 Q 355.5 -100 711 400 Q 1066.5 900 1422 280" opacity="0.43"></path>
<path d="M 0 252 Q 355.5 -100 711 400 Q 1066.5 900 1422 252" opacity="0.49"></path>
<path d="M 0 224 Q 355.5 -100 711 400 Q 1066.5 900 1422 224" opacity="0.56"></path>
<path d="M 0 196 Q 355.5 -100 711 400 Q 1066.5 900 1422 196" opacity="0.62"></path>
<path d="M 0 168 Q 355.5 -100 711 400 Q 1066.5 900 1422 168" opacity="0.68"></path>
<path d="M 0 140 Q 355.5 -100 711 400 Q 1066.5 900 1422 140" opacity="0.75"></path>
<path d="M 0 112 Q 355.5 -100 711 400 Q 1066.5 900 1422 112" opacity="0.81"></path>
<path d="M 0 84 Q 355.5 -100 711 400 Q 1066.5 900 1422 84" opacity="0.87"></path>
<path d="M 0 56 Q 355.5 -100 711 400 Q 1066.5 900 1422 56" opacity="0.94"></path>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

@@ -1,182 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte'
import logoImage from '../lib/assets/images/splash.png'
let inputEl = $state<HTMLInputElement | null>(null)
let query = $state('')
const api = window.spotlightAPI
const submit = () => {
const q = query.trim()
if (!q || !api) return
api.submitQuery(q)
query = ''
}
onMount(() => {
inputEl?.focus()
window.addEventListener('focus', () => {
query = ''
inputEl?.focus()
})
})
</script>
<svelte:window on:keydown={(e) => e.key === 'Escape' && api?.closeSpotlight()} />
<div class="wrapper">
<div class="bar">
<img class="logo" src={logoImage} alt="" />
<input
bind:this={inputEl}
bind:value={query}
type="text"
placeholder="What can I help you with today?"
autocomplete="off"
spellcheck="false"
onkeydown={(e) => {
if (e.key === 'Enter' && !e.isComposing) {
e.preventDefault()
submit()
}
}}
/>
<button class="send" class:active={query.trim().length > 0} aria-label="Send" onclick={submit}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 19V5" />
<path d="M5 12l7-7 7 7" />
</svg>
</button>
</div>
</div>
<style>
@font-face {
font-family: 'Archivo';
src: url('../lib/assets/fonts/Archivo-Variable.ttf');
font-display: swap;
}
:global(*) { margin: 0; padding: 0; box-sizing: border-box; }
:global(html), :global(body), :global(#app) {
height: 100%;
width: 100%;
background: transparent;
overflow: hidden;
user-select: none;
-webkit-font-smoothing: antialiased;
}
.wrapper {
height: 100%;
width: 100%;
padding: 10px 14px 24px 14px;
display: flex;
align-items: center;
}
.bar {
width: 100%;
height: 52px;
display: flex;
align-items: center;
gap: 10px;
padding: 0 9px 0 15px;
border-radius: 14px;
font-family: 'Archivo', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
-webkit-app-region: drag;
background: #f5f5f7;
color: #1d1d1f;
border: 0.5px solid rgba(0, 0, 0, 0.08);
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.06),
0 8px 24px rgba(0, 0, 0, 0.1);
}
@media (prefers-color-scheme: dark) {
.bar {
background: #1a1a1c;
color: #fafafa;
border-color: rgba(255, 255, 255, 0.08);
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.2),
0 8px 24px rgba(0, 0, 0, 0.35);
}
}
.logo {
width: 25px;
height: 25px;
flex-shrink: 0;
object-fit: contain;
opacity: 0.8;
}
@media (prefers-color-scheme: dark) {
.logo { filter: invert(1); }
}
input {
flex: 1;
min-width: 0;
border: none;
outline: none;
background: transparent;
font-family: inherit;
font-size: 17px;
font-weight: 450;
line-height: 1;
letter-spacing: -0.01em;
color: #1d1d1f;
-webkit-app-region: no-drag;
}
input::placeholder {
color: rgba(0, 0, 0, 0.22);
}
@media (prefers-color-scheme: dark) {
input { color: #fafafa; }
input::placeholder { color: rgba(255, 255, 255, 0.18); }
}
.send {
width: 30px;
height: 30px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border: none;
outline: none;
border-radius: 10px;
cursor: pointer;
transition: all 0.15s ease;
-webkit-app-region: no-drag;
background: rgba(0, 0, 0, 0.05);
color: rgba(0, 0, 0, 0.18);
}
.send.active {
background: #1d1d1f;
color: #fff;
}
.send.active:hover { opacity: 0.8; }
.send.active:active { transform: scale(0.92); }
@media (prefers-color-scheme: dark) {
.send {
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.15);
}
.send.active {
background: #fafafa;
color: #1d1d1f;
}
}
</style>
@@ -1,9 +0,0 @@
<script lang="ts">
const versions = window.electron.process.versions
</script>
<ul class="versions">
<li class="electron-version">Electron v{versions.electron}</li>
<li class="chrome-version">Chromium v{versions.chrome}</li>
<li class="node-version">Node v{versions.node}</li>
</ul>
-25
View File
@@ -1,25 +0,0 @@
import tippy, { type Props } from 'tippy.js'
import 'tippy.js/dist/tippy.css'
import 'tippy.js/themes/translucent.css'
export function tooltip(node: HTMLElement, content: string | Partial<Props>) {
const options: Partial<Props> =
typeof content === 'string'
? { content, placement: 'bottom', theme: 'translucent', delay: [500, 0] }
: { placement: 'bottom', theme: 'translucent', delay: [500, 0], ...content }
const instance = tippy(node, options)
return {
update(newContent: string | Partial<Props>) {
const newOptions: Partial<Props> =
typeof newContent === 'string'
? { content: newContent }
: newContent
instance.setProps(newOptions)
},
destroy() {
instance.destroy()
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1022 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 782 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

@@ -0,0 +1,116 @@
<script lang="ts">
import { onMount } from "svelte";
import Launching from "./Launching.svelte";
import logoImage from "../assets/images/splash.png";
import General from "./Controls/General.svelte";
import About from "./Controls/About.svelte";
import { info } from "../stores";
let selectedTab = $state("general");
onMount(async () => {});
</script>
{#if $info?.reachable ?? false}
<div
class="flex flex-col w-full h-full relative text-gray-850 dark:text-gray-100"
>
<div
class="pt-3 pb-1.5 pl-22 pr-4 w-full drag-region flex flex-row gap-3 items-center justify-between"
>
<div class=" font-medium">Controls</div>
<div>
<img
src={logoImage}
class="w-6 rounded-full dark:invert"
alt="logo"
/>
</div>
</div>
<div class=" absolute w-full top-0 left-0 right-0 z-10">
<div class="h-6 drag-region"></div>
</div>
<hr class=" my-1 border-gray-850" />
<div
class="flex flex-col sm:flex-row w-full h-full pb-2 sm:space-x-4 px-4 pt-1"
>
<div
id="admin-settings-tabs-container"
class="tabs flex flex-row overflow-x-auto gap-2 max-w-full sm:gap-0.5 sm:flex-col sm:flex-none sm:w-26 dark:text-gray-200 text-sm font-medium text-left scrollbar-none"
>
<button
id="general"
class="px-0.5 py-1 min-w-fit rounded-lg sm:flex-none flex text-right transition {selectedTab ===
'general'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
onclick={() => {
selectedTab = "general";
}}
>
<div class=" self-center mr-2">
<svg
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M8.34 1.804A1 1 0 019.32 1h1.36a1 1 0 01.98.804l.295 1.473c.497.144.971.342 1.416.587l1.25-.834a1 1 0 011.262.125l.962.962a1 1 0 01.125 1.262l-.834 1.25c.245.445.443.919.587 1.416l1.473.294a1 1 0 01.804.98v1.361a1 1 0 01-.804.98l-1.473.295a6.95 6.95 0 01-.587 1.416l.834 1.25a1 1 0 01-.125 1.262l-.962.962a1 1 0 01-1.262.125l-1.25-.834a6.953 6.953 0 01-1.416.587l-.294 1.473a1 1 0 01-.98.804H9.32a1 1 0 01-.98-.804l-.295-1.473a6.957 6.957 0 01-1.416-.587l-1.25.834a1 1 0 01-1.262-.125l-.962-.962a1 1 0 01-.125-1.262l.834-1.25a6.957 6.957 0 01-.587-1.416l-1.473-.294A1 1 0 011 10.68V9.32a1 1 0 01.804-.98l1.473-.295c.144-.497.342-.971.587-1.416l-.834-1.25a1 1 0 01.125-1.262l.962-.962A1 1 0 015.38 3.03l1.25.834a6.957 6.957 0 011.416-.587l.294-1.473zM13 10a3 3 0 11-6 0 3 3 0 016 0z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class=" self-center">{"General"}</div>
</button>
<button
id="about"
class="px-0.5 py-1 min-w-fit rounded-lg md:flex-none flex text-left transition {selectedTab ===
'about'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
onclick={() => {
selectedTab = "about";
}}
>
<div class=" self-center mr-2">
<svg
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class=" self-center">{"About"}</div>
</button>
</div>
<div
class="flex-1 pt-1 sm:mt-0 overflow-y-scroll pr-1 scrollbar-hidden"
>
{#if selectedTab === "general"}
<General info={$info} />
{:else if selectedTab === "about"}
<About />
{/if}
</div>
</div>
</div>
{:else}
<Launching />
{/if}
@@ -0,0 +1,115 @@
<script>
import { onMount } from "svelte";
import Versions from "../Versions.svelte";
import Spinner from "../common/Spinner.svelte";
let version = $state(null);
onMount(async () => {
// Fetch the version from the main process
version = await window.electronAPI.getVersion();
});
</script>
{#if version}
<div
id="tab-about"
class="flex flex-col h-full justify-between space-y-3 text-sm mb-6"
>
<div class=" space-y-3 overflow-y-scroll max-h-[28rem] lg:max-h-full">
<div>
<div
class=" mb-1 text-sm font-medium flex space-x-2 items-center"
>
<div>Open WebUI Desktop Version</div>
</div>
<div class="flex w-full justify-between items-center">
<div
class="flex flex-col text-xs text-gray-700 dark:text-gray-200"
>
<div class="flex gap-1">
v{version}
</div>
<button
class=" underline flex items-center space-x-1 text-xs text-gray-500 dark:text-gray-500 cursor-pointer"
onclick={() => {
window.electronAPI.openInBrowser(
"https://desktop.openwebui.com"
);
}}
>
<div>{"See what's new"}</div>
</button>
</div>
</div>
</div>
<hr class=" border-gray-100 dark:border-gray-850" />
<div class="flex space-x-1">
<a href="https://discord.gg/5rJgQTnV4s" target="_blank">
<img
alt="Discord"
src="https://img.shields.io/badge/Discord-Open_WebUI-blue?logo=discord&logoColor=white"
/>
</a>
<a href="https://twitter.com/OpenWebUI" target="_blank">
<img
alt="X (formerly Twitter) Follow"
src="https://img.shields.io/twitter/follow/OpenWebUI"
/>
</a>
<a
href="https://github.com/open-webui/open-webui"
target="_blank"
>
<img
alt="Github Repo"
src="https://img.shields.io/github/stars/open-webui/open-webui?style=social&label=Star us on Github"
/>
</a>
</div>
<div class="mt-1 text-xs text-gray-400 dark:text-gray-500">
Emoji graphics provided by
<a href="https://github.com/jdecked/twemoji" target="_blank"
>Twemoji</a
>, licensed under
<a
href="https://creativecommons.org/licenses/by/4.0/"
target="_blank">CC-BY 4.0</a
>.
</div>
<div>
<div class="text-xs text-gray-400 dark:text-gray-500">
Copyright (c) {new Date().getFullYear()}
<a
href="https://openwebui.com"
target="_blank"
class="underline">Open WebUI (Timothy Jaeryang Baek)</a
>
All rights reserved.
</div>
</div>
<div class="text-xs text-transparent">
<Versions />
</div>
</div>
</div>
{:else}
<div
class="flex flex-row w-full h-full relative text-gray-850 dark:text-gray-100 drag-region"
>
<div class="flex-1 w-full flex justify-center relative">
<div class="m-auto">
<Spinner />
</div>
</div>
</div>
{/if}
@@ -0,0 +1,272 @@
<script lang="ts">
import { toast } from "svelte-sonner";
import { onMount } from "svelte";
import { copyToClipboard } from "../../utils";
import Switch from "../common/Switch.svelte";
import Spinner from "../common/Spinner.svelte";
import ConfirmDialog from "../common/ConfirmDialog.svelte";
let { info } = $props();
let config = $state(null);
let serveOnLocalNetwork = $state(false);
let autoUpdate = $state(true);
let showConfirm = $state(false);
const onOpen = async () => {
try {
await window.electronAPI.openInBrowser(info?.url);
} catch (error) {
toast.error("Failed to open URL in browser");
}
};
const onUpdate = async () => {
try {
await window.electronAPI.setConfig({
...config,
serveOnLocalNetwork: serveOnLocalNetwork,
autoUpdate: autoUpdate,
});
toast.success("Configuration updated successfully");
} catch (error) {
toast.error("Failed to update configuration");
}
};
const resetHandler = async () => {};
onMount(async () => {
config = await window.electronAPI.getConfig();
serveOnLocalNetwork = config?.serveOnLocalNetwork ?? false;
autoUpdate = config?.autoUpdate ?? true;
});
</script>
<ConfirmDialog
bind:showConfirm
title="Factory Reset"
message="Are you sure you want to reset the app? This will remove all configurations, user data, and the bundled Python environment, restoring the app to its original state."
confirmLabel="Reset"
onConfirm={async () => {
try {
await window.electronAPI.resetApp();
toast.success(
"App has been reset successfully. Please restart the app."
);
} catch (error) {
toast.error("Failed to reset the app");
}
}}
/>
{#if config}
<div class="text-sm">
<div class="text-sm font-medium">Server Settings</div>
<div class="flex flex-col space-y-1 mt-2">
<div>
<div class="flex flex-row items-center justify-between">
<div
class="flex flex-row items-center space-x-1.5 text-green-100"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="size-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418"
/>
</svg>
<div>Reachable at</div>
</div>
<div class="flex flex-row items-center">
<button
class="p-1 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-850 transition cursor-pointer flex items-center px-2.5 text-xs"
aria-label="copy"
onclick={() => {
copyToClipboard(info?.url || "");
toast.success("URL copied to clipboard");
}}
>
<div class=" flex items-center pr-2">
<span class="relative flex size-2">
<span
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"
/>
<span
class="relative inline-flex rounded-full size-2 bg-green-500"
/>
</span>
</div>
<div class="">
{info?.url}
</div>
</button>
<button
class="p-1 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-850 transition cursor-pointer"
aria-label="copy"
onclick={() => {
onOpen();
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="size-4"
>
<path
d="M6.22 8.72a.75.75 0 0 0 1.06 1.06l5.22-5.22v1.69a.75.75 0 0 0 1.5 0v-3.5a.75.75 0 0 0-.75-.75h-3.5a.75.75 0 0 0 0 1.5h1.69L6.22 8.72Z"
/>
<path
d="M3.5 6.75c0-.69.56-1.25 1.25-1.25H7A.75.75 0 0 0 7 4H4.75A2.75 2.75 0 0 0 2 6.75v4.5A2.75 2.75 0 0 0 4.75 14h4.5A2.75 2.75 0 0 0 12 11.25V9a.75.75 0 0 0-1.5 0v2.25c0 .69-.56 1.25-1.25 1.25h-4.5c-.69 0-1.25-.56-1.25-1.25v-4.5Z"
/>
</svg>
</button>
</div>
</div>
</div>
<!-- <div>
<div class="flex flex-row items-center justify-between">
<div>Port</div>
<div>
<input
type="text"
class="w-20 px-2 py-1 rounded text-right"
bind:value={port}
readonly
/>
</div>
</div>
</div> -->
<div>
<div class="flex flex-row items-center justify-between">
<div
class="flex flex-row items-center space-x-1.5 text-green-100"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="size-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M8.288 15.038a5.25 5.25 0 0 1 7.424 0M5.106 11.856c3.807-3.808 9.98-3.808 13.788 0M1.924 8.674c5.565-5.565 14.587-5.565 20.152 0M12.53 18.22l-.53.53-.53-.53a.75.75 0 0 1 1.06 0Z"
/>
</svg>
<div>Serve on local network</div>
</div>
<div>
<Switch
id="network"
bind:state={serveOnLocalNetwork}
onChange={async () => {
await onUpdate();
await window.electronAPI.restartServer();
}}
/>
</div>
</div>
<div class="text-xs text-gray-500 mt-0.5">
Allow other devices on your local network to access the
server.
</div>
</div>
</div>
<hr class="my-3 border-gray-300 dark:border-gray-850" />
<div class="text-sm font-medium">App</div>
<div class="flex flex-col space-y-1 mt-2">
<div>
<div class="flex flex-row items-center justify-between">
<div
class="flex flex-row items-center space-x-1.5 text-green-100"
>
<div>Automatic updates</div>
</div>
<div>
<Switch
id="auto-updates"
bind:state={autoUpdate}
onChange={() => {
onUpdate();
}}
/>
</div>
</div>
<div class="text-xs text-gray-500 mt-0.5">
Turn off to disable automatic updates on startup.
</div>
</div>
</div>
<div class="flex flex-col space-y-1 mt-2">
<div>
<div class="flex flex-row items-center justify-between">
<div
class="flex flex-row items-center space-x-1.5 text-green-100"
>
<div>Factory Reset</div>
</div>
<div>
<button
class="text-xs cursor-pointer"
onclick={() => {
showConfirm = true;
}}
>
Reset
</button>
</div>
</div>
<div class="text-xs text-gray-500 mt-0.5">
Warning: Resetting the app will remove everything, including
all configurations, user data, and the bundled Python
environment. This action will fully restore the app to its
original state.
</div>
</div>
</div>
</div>
{:else}
<div
class="flex flex-row w-full h-full relative text-gray-850 dark:text-gray-100 drag-region"
>
<div class="flex-1 w-full flex justify-center relative">
<div class="m-auto">
<Spinner />
</div>
</div>
</div>
{/if}
@@ -0,0 +1,54 @@
<script lang="ts">
import { toast } from "svelte-sonner";
import Tooltip from "../common/Tooltip.svelte";
import { copyToClipboard } from "../../utils";
export let logs = [];
</script>
<div class="relative max-w-full w-full px-3">
{#if logs.length > 0}
<div
class="absolute top-0 right-0 p-1 bg-transparent text-xs font-mono"
>
<Tooltip content="Copy">
<button
class="text-xs cursor-pointer"
type="button"
onclick={async () => {
await copyToClipboard(logs.join("\n"));
toast.success("Logs copied to clipboard");
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2.3"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184"
/>
</svg>
</button>
</Tooltip>
</div>
{/if}
<div
class="text-xs font-mono text-left max-h-40 overflow-auto max-w-full w-full flex flex-col-reverse scrollbar-hidden no-drag-region"
>
{#each logs.reverse() as log, idx}
<div
class="text-xs font-mono whitespace-pre-wrap text-wrap max-w-full w-full"
>
{log}
</div>
{/each}
</div>
</div>
@@ -0,0 +1,236 @@
<script lang="ts">
import { onMount, tick } from "svelte";
import { fly } from "svelte/transition";
import Logs from "./setup/Logs.svelte";
import Spinner from "./common/Spinner.svelte";
import ArrowRightCircle from "./icons/ArrowRightCircle.svelte";
import logoImage from "../assets/images/splash.png";
import galaxyImage from "../assets/images/galaxy.jpg";
import greenImage from "../assets/images/green.jpg";
import adamImage from "../assets/images/adam.jpg";
import earthImage from "../assets/images/earth.jpg";
import nasaImage from "../assets/images/nasa.jpg";
import neomImage from "../assets/images/neom.jpg";
let { installed = $bindable() } = $props();
let images = [
galaxyImage,
greenImage,
adamImage,
earthImage,
nasaImage,
neomImage,
];
let mounted = $state(false);
let currentTime = Date.now();
let showLogs = $state(false);
let installing = $state(false);
const continueHandler = async () => {
if (window?.electronAPI) {
installing = true;
const pythonStatus = await window.electronAPI.getPythonStatus();
console.log("Python Status:", pythonStatus);
if (!pythonStatus) {
await window.electronAPI.installPython();
}
const packageStatus = await window.electronAPI.getPackageStatus();
console.log("Package Status:", packageStatus);
if (!packageStatus) {
await window.electronAPI.installPackage();
}
// Wait for the installation to complete
await tick();
if (
(await window.electronAPI.getPythonStatus()) &&
(await window.electronAPI.getPackageStatus())
) {
// Notify the user that the installation is complete
if (!(await window.electronAPI.getServerStatus())) {
await window.electronAPI.startServer();
}
await window.electronAPI.notification(
"Installation Complete",
"Open WebUI is now ready to use."
);
installed = true; // Update the installed state
} else {
// Handle the case where installation failed
await window.electronAPI.notification(
"Installation Failed",
"There was an error during the installation process."
);
}
installing = false;
}
};
let selectedImageIdx = $state(0);
onMount(() => {
mounted = true;
const imageInterval = setInterval(() => {
selectedImageIdx = (selectedImageIdx + 1) % 5;
}, 10000);
const interval = setInterval(() => {
currentTime = Date.now();
}, 1000); // Update every second
return () => {
clearInterval(interval); // Cleanup interval on destroy
};
});
</script>
<div
class="flex flex-row w-full h-full relative text-gray-850 dark:text-gray-100 p-1"
>
<div class="fixed right-0 my-5 mx-6 z-50">
<div class="flex space-x-2">
<button
class=" self-center cursor-pointer outline-none"
onclick={() => (showLogs = !showLogs)}
>
<img
src={logoImage}
class=" w-6 rounded-full dark:invert"
alt="logo"
/>
</button>
</div>
</div>
{#each images as image, index (index)}
<div
class="image w-full h-full absolute top-0 left-0 bg-cover bg-center transition-opacity duration-1000"
style="opacity: {selectedImageIdx === index
? 1
: 0}; background-image: url({image})"
></div>
{/each}
<div
class="w-full h-full absolute top-0 left-0 bg-gradient-to-t from-20% from-white dark:from-black to-transparent"
></div>
<div
class="w-full h-full absolute top-0 left-0 backdrop-blur-sm bg-white dark:bg-black opacity-50"
></div>
<div class=" absolute w-full top-0 left-0 right-0 z-10">
<div class="h-6 drag-region"></div>
</div>
<div class="flex-1 w-full flex justify-center relative">
<div
class="m-auto flex flex-col justify-center text-center max-w-2xl w-full"
>
{#if mounted}
<div
class=" font-medium text-5xl md:text-6xl xl:text-7xl text-center mb-4 xl:mb-5 font-secondary"
in:fly={{ duration: 750, y: 20 }}
>
Open WebUI
</div>
<div
class=" text-sm xl:text-base text-center mb-3"
in:fly={{ delay: 250, duration: 750, y: 10 }}
>
To get started with Open WebUI, click Continue.
</div>
{/if}
{#if showLogs}
<Logs />
{/if}
</div>
<div class="absolute bottom-0 pb-10">
<div class="flex justify-center mt-8">
<div class="flex flex-col justify-center items-center">
{#if installing}
<div class="flex flex-col gap-3 text-center">
<Spinner className="size-5" />
<div class=" font-secondary xl:text-lg -mt-0.5">
Installing...
</div>
<div
class=" font-default text-xs"
in:fly={{
delay: 100,
duration: 500,
y: 10,
}}
>
This might take a few minutes, Well notify you
when its ready.
</div>
<!-- {#if $serverLogs.length > 0}
<div
class="text-[0.5rem] text-gray-500 font-mono text-center line-clamp-1 px-10"
>
{$serverLogs.at(-1)}
</div>
{/if} -->
</div>
{:else if mounted}
<button
class="relative z-20 flex p-1 rounded-full bg-black/5 hover:bg-black/10 dark:bg-white/5 dark:hover:bg-white/10 transition font-medium text-sm cursor-pointer"
onclick={() => {
continueHandler();
}}
in:fly={{
delay: 500,
duration: 750,
y: 10,
}}
>
<ArrowRightCircle className="size-6" />
</button>
<div
class="mt-1.5 font-primary text-base font-medium"
in:fly={{
delay: 500,
duration: 750,
y: 10,
}}
>
{`Continue`}
</div>
<!-- <button
class="text-xs mt-3 text-gray-500 cursor-pointer"
in:fly={{
delay: 500,
duration: 750,
y: 10,
}}
>
To connect to an existing server, click here.
</button> -->
{/if}
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,106 @@
<script lang="ts">
import { onMount } from "svelte";
import Spinner from "./common/Spinner.svelte";
import logoImage from "../assets/images/splash.png";
import galaxyImage from "../assets/images/galaxy.jpg";
import greenImage from "../assets/images/green.jpg";
import adamImage from "../assets/images/adam.jpg";
import earthImage from "../assets/images/earth.jpg";
import nasaImage from "../assets/images/nasa.jpg";
import neomImage from "../assets/images/neom.jpg";
import { fly } from "svelte/transition";
let images = [
galaxyImage,
greenImage,
adamImage,
earthImage,
nasaImage,
neomImage,
];
let startTime = $state(null);
let currentTime = $state(null);
let selectedImageIdx = $state(0);
onMount(() => {
startTime = Date.now();
currentTime = Date.now();
setInterval(async () => {
currentTime = Date.now();
}, 1000);
const imageInterval = setInterval(() => {
selectedImageIdx = (selectedImageIdx + 1) % 5;
}, 10000);
});
</script>
<div
class="flex flex-row w-full h-full relative text-gray-850 dark:text-gray-100 p-1"
>
<div class="fixed right-0 my-5 mx-6 z-50">
<div class="flex space-x-2">
<button class=" self-center cursor-pointer outline-none">
<img
src={logoImage}
class=" w-6 rounded-full dark:invert"
alt="logo"
/>
</button>
</div>
</div>
{#each images as image, index (index)}
<div
class="image w-full h-full absolute top-0 left-0 bg-cover bg-center transition-opacity duration-1000"
style="opacity: {selectedImageIdx === index
? 1
: 0}; background-image: url({image})"
></div>
{/each}
<div
class="w-full h-full absolute top-0 left-0 bg-gradient-to-t from-20% from-white dark:from-black to-transparent"
></div>
<div
class="w-full h-full absolute top-0 left-0 backdrop-blur-sm bg-white dark:bg-black opacity-50"
></div>
<div class=" absolute w-full top-0 left-0 right-0 z-10">
<div class="h-6 drag-region"></div>
</div>
<div class="flex-1 w-full flex justify-center relative">
<div
class="m-auto flex flex-col justify-center text-center max-w-2xl w-full"
>
<div class="flex-1 w-full flex justify-center relative">
<div class="m-auto max-w-2xl w-full">
<div class="flex flex-col gap-3 text-center">
<Spinner className="size-5" />
<div class=" font-secondary xl:text-lg">
Launching Open WebUI...
</div>
{#if currentTime - startTime > 10000}
<div
class=" font-default text-xs"
in:fly={{ duration: 500, y: 10 }}
>
If it's your first time, it might take a few
minutes to start.
</div>
{/if}
</div>
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,17 @@
<script lang="ts">
import splashImage from "../assets/images/splash.png";
</script>
<div
class="flex flex-row w-full h-full relative text-gray-850 dark:text-gray-100 drag-region"
>
<div class="flex-1 w-full flex justify-center relative">
<div class="m-auto">
<img
src={splashImage}
class="size-18 rounded-full dark:invert"
alt="logo"
/>
</div>
</div>
</div>
-142
View File
@@ -1,142 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte'
import { fade } from 'svelte/transition'
import { connections, config, appInfo } from '../stores'
import { tooltip } from '../actions/tooltip'
import i18n from '../i18n'
import Connections from './Main/Connections.svelte'
import Settings from './Main/Settings.svelte'
let visible = $state(false)
let settingsOpen = $state(false)
let sidebarOpen = $state(true)
let activeConnectionName = $state('')
onMount(async () => {
connections.set(await window.electronAPI.getConnections())
const cfg = await window.electronAPI.getConfig()
config.set(cfg)
sidebarOpen = cfg?.showSidebar ?? true
setTimeout(() => {
visible = true
}, 50)
})
const toggleSidebar = () => {
sidebarOpen = !sidebarOpen
window.electronAPI.setConfig({ showSidebar: sidebarOpen })
}
</script>
{#if visible}
<div
class="h-full w-full flex flex-col bg-[#f5f5f7] dark:bg-[#0a0a0a] text-[#1d1d1f] dark:text-[#fafafa] relative"
in:fade={{ duration: 200 }}
>
<!-- Persistent top bar -->
<div
class="flex items-center shrink-0 drag-region {$appInfo?.platform === 'darwin'
? 'h-10'
: 'h-8'}"
>
<div
class="flex items-center gap-3 {$appInfo?.platform === 'darwin'
? 'pl-25'
: 'pl-3'} pr-2 shrink-0 translate-y-[0.5px]"
>
<button
class="opacity-70 hover:opacity-100 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] no-drag"
onclick={toggleSidebar}
use:tooltip={sidebarOpen ? $i18n.t('sidebar.tooltip.closeSidebar') : $i18n.t('sidebar.tooltip.openSidebar')}
>
<svg
class="w-[15px] h-[15px]"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3.75 3.75h16.5v16.5H3.75V3.75zM9 3.75v16.5"
/>
</svg>
</button>
{#if activeConnectionName}
<button
class="opacity-40 hover:opacity-80 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] no-drag cursor-pointer"
onclick={() => {
const wv = document.querySelector('webview[style*="display: flex"]') as any
if (wv?.goBack) wv.goBack()
}}
>
<svg class="w-[13px] h-[13px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
<button
class="opacity-40 hover:opacity-80 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] no-drag cursor-pointer"
onclick={() => {
const wv = document.querySelector('webview[style*="display: flex"]') as any
if (wv?.goForward) wv.goForward()
}}
>
<svg class="w-[13px] h-[13px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</button>
{/if}
</div>
<div class="flex-1 flex items-center justify-center">
<span class="text-[11px] opacity-80">{activeConnectionName || $i18n.t('app.name')}</span>
</div>
<div class="pr-3 flex items-center shrink-0 translate-y-[0.5px]">
{#if activeConnectionName}
<button
class="opacity-40 hover:opacity-80 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] no-drag cursor-pointer"
onclick={() => {
const wv = document.querySelector('webview[style*="display: flex"]') as any
if (wv?.reload) wv.reload()
}}
use:tooltip={$i18n.t('common.refresh')}
>
<svg class="w-[13px] h-[13px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M20.015 4.356v4.992m0 0h-4.992m4.993 0l-3.181-3.183a8.25 8.25 0 00-13.803 3.7" />
</svg>
</button>
{/if}
</div>
</div>
<!-- Content area below top bar -->
<div class="flex-1 min-h-0">
<Connections
{sidebarOpen}
bind:activeConnectionName
onOpenSettings={() => (settingsOpen = true)}
/>
</div>
{#if settingsOpen}
<div
class="absolute inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
in:fade={{ duration: 150 }}
out:fade={{ duration: 100 }}
onclick={() => (settingsOpen = false)}
role="button"
tabindex="-1"
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="w-[calc(100%-32px)] h-[calc(100%-32px)] max-w-[900px] max-h-[600px] rounded-3xl overflow-hidden shadow-2xl border border-black/[0.08] dark:border-white/[0.08]"
in:fade={{ duration: 150 }}
onclick={(e) => e.stopPropagation()}
>
<Settings onClose={() => (settingsOpen = false)} />
</div>
</div>
{/if}
</div>
{/if}
@@ -1,531 +0,0 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte'
import { fade } from 'svelte/transition'
import { connections, config, serverInfo, appState } from '../../stores'
import i18n from '../../i18n'
import Sidebar from './Connections/Sidebar.svelte'
import Content from './Connections/Content.svelte'
import StatusBar from './Connections/StatusBar.svelte'
import LogPanel from './Connections/LogPanel.svelte'
interface Props {
onOpenSettings: () => void
sidebarOpen: boolean
activeConnectionName?: string
}
let {
onOpenSettings,
sidebarOpen,
activeConnectionName = $bindable('')
}: Props = $props()
let isLocalConnection = $state(false)
let showingLogs = $state(false)
let url = $state('')
let connecting = $state(false)
let error = $state('')
let view = $state('welcome') // welcome | install | connected
let autoInstall = $state(false)
let installPhase = $state('idle') // idle | working | error
let installError = $state('')
let toastVisible = $state(false)
let toastTimeout: ReturnType<typeof setTimeout> | null = null
let installStatus = $state('')
let settingsOpen = $state(false)
let connectedUrl = $state('')
let activeConnectionId = $state('')
let connectingId = $state('')
let openConnections: Map<string, string> = $state(new Map())
let localInstalled = $state(false)
let openTerminalInstalled = $state(false)
let showAddConnectionModal = $state(false)
// Active log panel
let activeLog = $state<'server' | 'open-terminal' | 'llama-server' | null>(null)
const serverStatus = $derived($serverInfo?.status)
const serverReachable = $derived($serverInfo?.reachable)
const isInitializing = $derived($appState === 'initializing')
const hasLocal = $derived(($connections ?? []).some((c) => c.type === 'local'))
const localConn = $derived(($connections ?? []).find((c) => c.type === 'local'))
const remoteConnections = $derived(($connections ?? []).filter((c) => c.type !== 'local'))
// Open Terminal state
let openTerminalStatus = $state<string | null>(null)
let openTerminalInfo = $state<{ url?: string; apiKey?: string } | null>(null)
// Llama Server state
let llamaCppStatus = $state<string | null>(null)
let llamaCppInfo = $state<{ url?: string; pid?: number } | null>(null)
let llamaCppSetupStatus = $state('')
const startInstall = async (options?: { installOpenTerminal?: boolean; installLlamaCpp?: boolean }) => {
installPhase = 'working'
installError = ''
installStatus = ''
toastVisible = false
try {
// Check disk space before installing (minimum 5 GB)
const MINIMUM_DISK_BYTES = 5 * 1024 * 1024 * 1024
const disk = await window.electronAPI.getDiskSpace()
if (disk?.free >= 0 && disk.free < MINIMUM_DISK_BYTES) {
const availableGB = (disk.free / (1024 * 1024 * 1024)).toFixed(1)
throw new Error(`Not enough disk space. At least 5 GB is required (${availableGB} GB available).`)
}
// Ensure Python and uv are installed before attempting package install
const pythonReady = await window.electronAPI.getPythonStatus()
if (!pythonReady) {
const pythonOk = await window.electronAPI.installPython()
if (!pythonOk) throw new Error('Failed to install Python. Please try again.')
}
const ok = await window.electronAPI.installPackage()
if (!ok) throw new Error($i18n.t('error.installFailedGeneric'))
// Start optional services after packages are installed to avoid
// concurrent uv installs fighting over the lockfile
if (options?.installOpenTerminal) {
toggleOpenTerminal()
}
if (options?.installLlamaCpp) {
toggleLlamaCpp()
}
installStatus = $i18n.t('main.install.startingServer')
await window.electronAPI.startServer()
const info = await window.electronAPI.getServerInfo()
installStatus = $i18n.t('main.install.settingUpConnection')
await window.electronAPI.addConnection({
id: 'local',
name: 'Local',
type: 'local',
url: info?.url || 'http://127.0.0.1:8080'
})
await window.electronAPI.setDefaultConnection('local')
connections.set(await window.electronAPI.getConnections())
config.set(await window.electronAPI.getConfig())
// Wait for server to actually be reachable before showing connected view
installStatus = $i18n.t('main.install.launchingOpenWebUI')
const maxWait = 120000
const pollInterval = 2000
const startTime = Date.now()
let reachable = false
while (Date.now() - startTime < maxWait) {
const si = await window.electronAPI.getServerInfo()
if (si?.reachable) {
reachable = true
break
}
await new Promise((r) => setTimeout(r, pollInterval))
}
if (!reachable) {
throw new Error('Server did not become reachable. Please try again.')
}
// Now connect — the server is ready
installStatus = ''
localInstalled = true
await connect('local')
installPhase = 'idle'
} catch (e: any) {
installPhase = 'error'
installError = e?.message || $i18n.t('error.somethingWentWrong')
toastVisible = true
if (toastTimeout) clearTimeout(toastTimeout)
toastTimeout = setTimeout(() => { toastVisible = false }, 5000)
}
}
const addConnection = async () => {
if (!url.trim()) return
let u = url.trim()
if (!u.startsWith('http')) u = 'https://' + u
error = ''
try {
new URL(u)
} catch {
error = $i18n.t('setup.invalidUrl')
return
}
connecting = true
try {
const valid = await window.electronAPI.validateUrl(u)
if (!valid) {
error = $i18n.t('setup.couldNotReachServer')
connecting = false
return
}
await window.electronAPI.addConnection({
id: crypto.randomUUID(),
name: new URL(u).hostname,
type: 'remote',
url: u
})
connections.set(await window.electronAPI.getConnections())
config.set(await window.electronAPI.getConfig())
url = ''
error = ''
showAddConnectionModal = false
view = 'welcome'
} catch {
error = $i18n.t('setup.connectionFailed')
} finally {
connecting = false
}
}
const connect = async (id: string) => {
showingLogs = false
// Toggle: clicking the active connection unselects it
if (activeConnectionId === id && view === 'connected') {
activeConnectionId = ''
connectedUrl = ''
view = 'welcome'
return
}
if (openConnections.has(id)) {
activeConnectionId = id
connectedUrl = openConnections.get(id)!
view = 'connected'
return
}
connectingId = id
try {
const result = await window.electronAPI.connectTo(id)
if (result?.url) {
openConnections.set(result.connectionId, result.url)
openConnections = new Map(openConnections) // trigger reactivity
connectedUrl = result.url
activeConnectionId = result.connectionId
view = 'connected'
}
} finally {
connectingId = ''
}
}
const disconnect = () => {
activeConnectionId = ''
connectedUrl = ''
view = 'welcome'
}
const remove = async (id: string) => {
await window.electronAPI.removeConnection(id)
connections.set(await window.electronAPI.getConnections())
config.set(await window.electronAPI.getConfig())
if (activeConnectionId === id) {
disconnect()
}
openConnections.delete(id)
openConnections = new Map(openConnections)
}
// Sync active connection info to parent
$effect(() => {
const conn = ($connections ?? []).find((c) => c.id === activeConnectionId)
activeConnectionName = conn?.name ?? ''
isLocalConnection = conn?.type === 'local'
})
// React to showingLogs from parent — open the server log panel
// Only react when parent sets showingLogs to true; don't close on false
// (the status bar manages its own open/close via activeLog)
$effect(() => {
if (showingLogs) {
activeLog = 'server'
}
})
// Sync back: when panel closes, tell parent
$effect(() => {
if (activeLog === null) {
showingLogs = false
}
})
const openGithub = () => {
settingsOpen = false
window.electronAPI?.openInBrowser?.('https://github.com/open-webui/desktop')
}
// ── Log panel PTY helpers ─────────────────────────────
const getConnectPty = (log: string) => {
return (callback: (data: string) => void) => {
if (log === 'server') {
window.electronAPI.connectPty(callback)
} else if (log === 'open-terminal') {
window.electronAPI.connectOpenTerminalPty(callback)
} else if (log === 'llama-server') {
window.electronAPI.connectLlamaCppPty(callback)
}
}
}
const getDisconnectPty = (log: string) => {
return () => {
if (log === 'server') {
window.electronAPI.disconnectPty()
} else if (log === 'open-terminal') {
window.electronAPI?.disconnectOpenTerminalPty?.()
} else if (log === 'llama-server') {
window.electronAPI?.disconnectLlamaCppPty?.()
}
}
}
const getOnWrite = (log: string) => {
if (log === 'server') {
return (data: string) => window.electronAPI.writePty(data)
}
return undefined
}
const getOnResize = (log: string) => {
if (log === 'server') {
return (cols: number, rows: number) => window.electronAPI.resizePty(cols, rows)
}
return undefined
}
// ── Status bar log selection ──────────────────────────
const selectLog = (log: string) => {
activeLog = activeLog === log ? null : (log as typeof activeLog)
}
// Listen for events from main process
onMount(() => {
window.electronAPI.onData((data: any) => {
if (data.type === 'connection:open' && data.data?.url) {
const connId = data.data.connectionId ?? ''
const incomingUrl = data.data.url
const alreadyOpen = openConnections.has(connId)
openConnections.set(connId, incomingUrl)
openConnections = new Map(openConnections)
connectedUrl = incomingUrl
activeConnectionId = connId
// If the webview for this connection already exists in the DOM,
// updating the map value won't cause it to re-navigate (Electron
// webview `src` changes on an existing element are ignored). We
// need to explicitly call loadURL so that e.g. the spotlight ?q=
// parameter actually reaches the Open WebUI instance.
if (alreadyOpen) {
requestAnimationFrame(() => {
const container = document.querySelector('.content-webview-container')
if (!container) return
const wv = container.querySelector(
`webview[partition="persist:connection-${connId}"]`
) as any
if (wv?.loadURL) {
wv.loadURL(incomingUrl)
}
})
}
// Don't switch to connected view during active install — the install
// flow handles its own transition after confirming reachability.
if (installPhase !== 'working') {
view = 'connected'
}
}
if (data.type === 'status:open-terminal') {
openTerminalStatus = data.data
}
if (data.type === 'open-terminal:ready') {
openTerminalInfo = data.data
openTerminalStatus = 'started'
}
if (data.type === 'status:llamacpp') {
llamaCppStatus = data.data
}
if (data.type === 'status:llamacpp-setup') {
llamaCppSetupStatus = data.data ?? ''
}
if (data.type === 'llamacpp:ready') {
llamaCppInfo = data.data
llamaCppStatus = 'started'
llamaCppSetupStatus = ''
}
if (data.type === 'status:install') {
installStatus = data.data ?? ''
}
})
// Check current Open Terminal state on mount
window.electronAPI.getOpenTerminalInfo().then((info: any) => {
if (info?.status) {
openTerminalStatus = info.status
openTerminalInfo = info
}
})
// Check if Open Terminal package is installed
window.electronAPI.getOpenTerminalStatus().then((installed: boolean) => {
openTerminalInstalled = installed
})
// Check if Open WebUI package is installed
window.electronAPI.getPackageVersion('open-webui').then((v: string | null) => {
localInstalled = v !== null
})
// Check llama-server state on mount
window.electronAPI.getLlamaCppInfo().then((info: any) => {
if (info?.status) {
llamaCppStatus = info.status
}
if (info?.binaryPath || info?.status) {
llamaCppInfo = info
}
})
})
const toggleOpenTerminal = async () => {
if (openTerminalStatus === 'starting') return
if (openTerminalStatus === 'started') {
openTerminalStatus = 'stopping'
await window.electronAPI.stopOpenTerminal()
openTerminalStatus = null
openTerminalInfo = null
} else {
openTerminalStatus = 'starting'
const result = await window.electronAPI.startOpenTerminal()
if (result) {
openTerminalInfo = result
openTerminalStatus = 'started'
} else {
openTerminalStatus = 'failed'
}
}
}
const toggleLlamaCpp = async () => {
if (llamaCppStatus === 'starting' || llamaCppStatus === 'setting-up') return
if (llamaCppStatus === 'started') {
llamaCppStatus = 'stopping'
await window.electronAPI.stopLlamaCpp()
llamaCppStatus = null
llamaCppInfo = null
} else {
llamaCppStatus = 'starting'
const result = await window.electronAPI.startLlamaCpp()
if (result) {
llamaCppInfo = result
llamaCppStatus = 'started'
} else {
llamaCppStatus = 'failed'
}
}
}
</script>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="h-full w-full flex flex-col bg-[#f5f5f7] dark:bg-[#0a0a0a] text-[#1d1d1f] dark:text-[#fafafa]" in:fade={{ duration: 200 }}>
<div class="flex-1 min-h-0 flex">
{#if sidebarOpen}
<Sidebar
{activeConnectionId}
{connectingId}
{localConn}
{localInstalled}
{remoteConnections}
{serverStatus}
{serverReachable}
bind:settingsOpen
onConnect={connect}
onDisconnect={disconnect}
onAddView={() => { showAddConnectionModal = true }}
{onOpenSettings}
onRename={async (id, name) => {
await window.electronAPI.updateConnection(id, { name })
connections.set(await window.electronAPI.getConnections())
}}
onRemove={remove}
{openGithub}
/>
{/if}
<Content
{sidebarOpen}
bind:view
{activeConnectionId}
{connectingId}
{openConnections}
{localConn}
{localInstalled}
{remoteConnections}
bind:installPhase
bind:installError
bind:installStatus
bind:toastVisible
bind:url
bind:connecting
bind:error
bind:showAddConnectionModal
bind:autoInstall
onStartInstall={startInstall}
onAddConnection={addConnection}
onSetView={(v) => { view = v }}
/>
</div>
{#if activeLog}
<LogPanel
{activeLog}
serviceReady={activeLog === 'server'
? serverStatus === 'started'
: activeLog === 'open-terminal'
? openTerminalStatus === 'started'
: llamaCppStatus === 'started'}
statusText={activeLog === 'server'
? (serverStatus === 'starting' ? 'Starting Open WebUI…' : serverStatus === 'running' && !serverReachable ? 'Waiting for server…' : installStatus || '')
: activeLog === 'open-terminal'
? (openTerminalStatus === 'stopping' ? 'Stopping Open Terminal…' : openTerminalStatus === 'starting' ? 'Starting Open Terminal…' : '')
: (llamaCppStatus === 'stopping' ? 'Stopping llama-server…' : llamaCppSetupStatus || (llamaCppStatus === 'starting' ? 'Starting llama-server…' : llamaCppStatus === 'setting-up' ? 'Setting up llama.cpp…' : ''))}
connectPty={getConnectPty(activeLog)}
disconnectPty={getDisconnectPty(activeLog)}
readonly={activeLog !== 'server'}
onWrite={getOnWrite(activeLog)}
onResize={getOnResize(activeLog)}
onStop={activeLog === 'open-terminal' ? toggleOpenTerminal : activeLog === 'llama-server' ? toggleLlamaCpp : undefined}
onClose={() => { activeLog = null; showingLogs = false }}
/>
{/if}
<StatusBar
{serverStatus}
{serverReachable}
{openTerminalStatus}
{llamaCppStatus}
openWebuiInstalled={localInstalled}
{openTerminalInstalled}
llamaCppInstalled={!!llamaCppInfo?.binaryPath}
{activeLog}
onSelectLog={selectLog}
onStartServer={async () => {
if (!localInstalled) {
// Not installed — trigger full install (handles Python/uv + package)
startInstall()
return
}
// Already installed — start the server
await window.electronAPI.startServer()
// Force-refresh serverInfo immediately (don't wait for 3s poll)
const info = await window.electronAPI.getServerInfo()
serverInfo.set(info)
}}
onToggleOpenTerminal={toggleOpenTerminal}
onToggleLlamaCpp={toggleLlamaCpp}
/>
</div>
@@ -1,114 +0,0 @@
<script lang="ts">
import { fade, scale } from 'svelte/transition'
import i18n from '../../../i18n'
interface Props {
url: string
connecting: boolean
error: string
onConnect: () => void
onCancel: () => void
}
let {
url = $bindable(''),
connecting = $bindable(false),
error = $bindable(''),
onConnect,
onCancel
}: Props = $props()
</script>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-[100] flex items-center justify-center"
transition:fade={{ duration: 150 }}
onmousedown={onCancel}
>
<div class="absolute inset-0 bg-black/50 backdrop-blur-sm"></div>
<div
class="relative mx-4 w-full max-w-lg overflow-hidden rounded-3xl bg-white shadow-2xl dark:bg-gray-950"
transition:scale={{ start: 0.97, duration: 180 }}
onmousedown={(e) => e.stopPropagation()}
>
<!-- Visual header -->
<div
class="relative flex h-36 items-center justify-center overflow-hidden bg-gradient-to-br from-gray-900 via-gray-800 to-black dark:from-white dark:via-gray-100 dark:to-gray-200"
>
<div
class="absolute inset-0 bg-gradient-to-r from-transparent via-white/5 to-transparent"
></div>
<div class="relative z-10 text-center">
<div class="mb-2.5 flex justify-center">
<div class="rounded-full bg-white/10 p-3 dark:bg-black/10">
<svg
class="w-6 h-6 text-white dark:text-gray-900"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5a17.92 17.92 0 01-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418"
/>
</svg>
</div>
</div>
<h2 class="text-lg font-semibold tracking-tight text-white dark:text-gray-900">
{$i18n.t('setup.newConnection')}
</h2>
<p class="mt-1 text-xs text-white/60 dark:text-gray-900/50">
{$i18n.t('setup.newConnectionDesc')}
</p>
</div>
</div>
<!-- Body -->
<div class="px-6 py-5">
<label class="block text-[11px] text-gray-400 dark:text-gray-500"
>{$i18n.t('setup.connectionManager.serverUrl')}</label
>
<input
type="text"
bind:value={url}
placeholder="https://"
class="w-full py-2 text-[14px] text-[#1d1d1f] dark:text-[#fafafa] placeholder:opacity-20 outline-none bg-transparent border-none border-b border-black/[0.08] dark:border-white/[0.08]"
onkeydown={(e) => e.key === 'Enter' && onConnect()}
/>
{#if error}
<p class="mt-2 text-[11px] text-red-400">{error}</p>
{/if}
</div>
<!-- Footer -->
<div class="px-5 pb-5 flex flex-col gap-2">
<button
class="w-full rounded-xl bg-gray-900 dark:bg-white px-4 py-2.5 text-sm font-medium text-white dark:text-gray-900 transition-all duration-200 hover:bg-gray-800 dark:hover:bg-gray-100 active:scale-[0.98] border-none cursor-pointer disabled:opacity-40 disabled:cursor-default disabled:active:scale-100"
onclick={onConnect}
disabled={connecting || !url.trim()}
>
{#if connecting}
<span class="inline-flex items-center gap-2">
<span
class="w-3.5 h-3.5 rounded-full border-2 border-white/30 dark:border-black/30 border-t-white dark:border-t-black animate-spin inline-block"
></span>
{$i18n.t('common.connecting')}
</span>
{:else}
{$i18n.t('common.connect')}
{/if}
</button>
<button
class="w-full rounded-xl px-4 py-2 text-sm text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition bg-transparent border-none cursor-pointer"
onclick={onCancel}
>
{$i18n.t('common.cancel')}
</button>
</div>
</div>
</div>
@@ -1,388 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte'
import { fade, fly } from 'svelte/transition'
import { connections, config, serverInfo, appState } from '../../../stores'
import i18n from '../../../i18n'
import LocalInstall from '../../Setup/LocalInstall.svelte'
import GetStartedModal from './GetStartedModal.svelte'
import AddConnectionModal from './AddConnectionModal.svelte'
import landingVideo from '../../../../assets/landing.mp4'
interface Props {
sidebarOpen: boolean
view: string
activeConnectionId: string
connectingId: string
openConnections: Map<string, string>
localConn: any
localInstalled: boolean
remoteConnections: any[]
installPhase: string
installError: string
installStatus: string
toastVisible: boolean
url: string
connecting: boolean
error: string
autoInstall: boolean
onStartInstall: (options?: { installOpenTerminal?: boolean; installLlamaCpp?: boolean }) => void
onAddConnection: () => void
onSetView: (v: string) => void
showAddConnectionModal: boolean
}
let {
sidebarOpen,
view,
activeConnectionId,
connectingId,
openConnections,
localConn,
localInstalled,
remoteConnections,
installPhase = $bindable('idle'),
installError = $bindable(''),
installStatus = $bindable(''),
toastVisible = $bindable(false),
url = $bindable(''),
connecting = $bindable(false),
error = $bindable(''),
autoInstall = $bindable(false),
onStartInstall,
onAddConnection,
onSetView,
showAddConnectionModal = $bindable(false)
}: Props = $props()
let showGetStartedModal = $state(false)
const isInitializing = $derived($appState === 'initializing')
const insufficientStorage = $derived(
$appState?.startsWith('insufficient-storage:')
? $appState.split(':')[1]
: null
)
const installFailed = $derived(
$appState?.startsWith('install-failed:')
? $appState.substring('install-failed:'.length)
: null
)
// Track webview loading per connection
let webviewLoading: Map<string, boolean> = $state(new Map())
// Content preload path for webview bridge
let contentPreloadPath: string = $state('')
// Server is starting up (local)
const serverStarting = $derived(
localConn && localInstalled && (
$serverInfo?.status === 'starting' ||
($serverInfo?.status === 'running' && !$serverInfo?.reachable)
)
)
const isLoading = $derived(
connectingId !== '' ||
serverStarting ||
(view === 'connected' && activeConnectionId && (webviewLoading.get(activeConnectionId) ?? true))
)
// Attach load event listeners and IPC forwarding to webviews
onMount(async () => {
// Fetch the content preload path once
contentPreloadPath = await window.electronAPI.getContentPreloadPath()
// Forward main:data events from the main process into all active webviews
window.electronAPI.onData((data: any) => {
const container = document.querySelector('.content-webview-container')
if (!container) return
const webviews = container.querySelectorAll('webview')
webviews.forEach((wv: any) => {
try {
wv.send('desktop:event', data)
} catch (_) {
// webview may not be ready yet
}
})
})
const observer = new MutationObserver(() => {
const container = document.querySelector('.content-webview-container')
if (!container) return
const webviews = container.querySelectorAll('webview')
webviews.forEach((wv: any) => {
if (wv._loadListenerAttached) return
wv._loadListenerAttached = true
const connId = wv.getAttribute('partition')?.replace('persist:connection-', '') ?? ''
if (!connId) return
// Mark loading when navigation starts
wv.addEventListener('did-start-loading', () => {
webviewLoading.set(connId, true)
webviewLoading = new Map(webviewLoading)
})
// Clear loading when done
wv.addEventListener('did-stop-loading', () => {
webviewLoading.set(connId, false)
webviewLoading = new Map(webviewLoading)
})
// Handle IPC messages from the webview guest (Open WebUI → desktop)
wv.addEventListener('ipc-message', async (event: any) => {
if (event.channel === 'webview:send') {
const requestData = event.args?.[0]
if (!requestData) return
try {
const response = await window.electronAPI[requestData.type]?.(requestData)
if (requestData._requestId) {
wv.send('desktop:response', {
_responseId: requestData._requestId,
data: response
})
}
} catch (e) {
console.error('webview:send handler error:', e)
}
} else if (event.channel === 'webview:load') {
const page = event.args?.[0]
if (page) onSetView(page === 'home' ? 'welcome' : page)
}
})
})
})
const target = document.querySelector('.content-webview-container')
if (target) {
observer.observe(target, { childList: true, subtree: true })
}
return () => observer.disconnect()
})
</script>
<div
class="flex-1 flex flex-col min-w-0 overflow-clip bg-[#eee] dark:bg-[#111] border-t relative content-webview-container {sidebarOpen
? 'border-l border-black/[0.08] dark:border-white/[0.08] rounded-tl-xl'
: 'border-black/[0.08] dark:border-white/[0.10]'}"
>
<!-- Webviews — all open connections stay alive, only active one visible -->
{#each [...openConnections] as [connId, connUrl] (connId)}
<webview
src={connUrl}
class="flex-1 min-h-0 border-none"
style="display: {view === 'connected' && activeConnectionId === connId ? 'flex' : 'none'}"
allowpopups
partition="persist:connection-{connId}"
preload={contentPreloadPath}
></webview>
{/each}
<!-- Loading overlay for webview -->
{#if isLoading}
<div class="absolute inset-0 z-10 flex items-center justify-center bg-[#eee] dark:bg-[#111]" transition:fade={{ duration: 200 }}>
<div class="flex flex-col items-center gap-3">
<div class="w-6 h-6 rounded-full border-2 border-black/10 dark:border-white/15 border-t-black/50 dark:border-t-white/50 animate-spin"></div>
<span class="text-[11px] opacity-30">{$i18n.t('common.loading')}</span>
</div>
</div>
{/if}
{#if view !== 'connected'}
{#if insufficientStorage}
<div class="px-5 py-2.5 flex items-center gap-3 bg-red-500/[0.06] border-b border-red-500/10">
<div class="flex-1">
<div class="text-[12px] text-red-400 font-medium">{$i18n.t('main.notEnoughDiskSpace')}</div>
<div class="text-[11px] opacity-40 mt-0.5">
{$i18n.t('main.diskSpaceRequired', { available: insufficientStorage })}
</div>
</div>
<button
class="shrink-0 text-[11px] px-3 py-1 rounded-lg bg-black/[0.04] dark:bg-white/[0.06] opacity-60 hover:opacity-90 transition border-none text-[#1d1d1f] dark:text-[#fafafa] cursor-pointer"
onclick={async () => {
const MINIMUM_DISK_BYTES = 5 * 1024 * 1024 * 1024
const disk = await window.electronAPI.getDiskSpace()
if (disk?.free >= 0 && disk.free < MINIMUM_DISK_BYTES) {
const gb = (disk.free / (1024 * 1024 * 1024)).toFixed(1)
appState.set(`insufficient-storage:${gb}`)
return
}
appState.set('initializing')
window.electronAPI.installPython().then(() => appState.set('ready')).catch((e: any) => {
appState.set(`install-failed:${e?.message || 'Python installation failed. Please try again.'}`)
})
}}
>
{$i18n.t('common.retry')}
</button>
</div>
{:else if installFailed}
<div class="px-5 py-2.5 flex items-center gap-3 bg-red-500/[0.06] border-b border-red-500/10">
<div class="flex-1">
<div class="text-[12px] text-red-400 font-medium">{$i18n.t('error.installFailedGeneric')}</div>
<div class="text-[11px] opacity-40 mt-0.5 line-clamp-2">
{installFailed}
</div>
</div>
<button
class="shrink-0 text-[11px] px-3 py-1 rounded-lg bg-black/[0.04] dark:bg-white/[0.06] opacity-60 hover:opacity-90 transition border-none text-[#1d1d1f] dark:text-[#fafafa] cursor-pointer"
onclick={async () => {
const MINIMUM_DISK_BYTES = 5 * 1024 * 1024 * 1024
const disk = await window.electronAPI.getDiskSpace()
if (disk?.free >= 0 && disk.free < MINIMUM_DISK_BYTES) {
const gb = (disk.free / (1024 * 1024 * 1024)).toFixed(1)
appState.set(`insufficient-storage:${gb}`)
return
}
appState.set('initializing')
window.electronAPI.installPython().then(() => appState.set('ready')).catch((e: any) => {
appState.set(`install-failed:${e?.message || 'Python installation failed. Please try again.'}`)
})
}}
>
{$i18n.t('common.retry')}
</button>
</div>
{:else if isInitializing}
<div class="px-5 py-1.5 text-[11px] opacity-25">
{$i18n.t('setup.settingUp')}{$serverInfo?.status ? ` ${$serverInfo.status}` : ''}
</div>
{/if}
<div class="flex-1 flex items-center justify-center px-6 relative overflow-hidden">
{#if view === 'welcome'}
{#if remoteConnections.length > 0 || (localConn && localInstalled)}
<div class="text-center max-w-[320px]" in:fade={{ duration: 200 }}>
<div class="text-lg opacity-80 mb-1.5">{$i18n.t('app.name')}</div>
<div class="text-[12px] opacity-30 mb-6">
{$i18n.t('main.selectConnection')}
</div>
</div>
{:else}
<!-- Theme-responsive hero section -->
<div class="absolute inset-0 bg-[#fafafa] dark:bg-[#111]">
<!-- Video background -->
<video
autoplay
muted
loop
playsinline
class="absolute inset-0 w-full h-full object-cover opacity-30 dark:opacity-40 pointer-events-none"
>
<source src={landingVideo} type="video/mp4" />
</video>
<!-- Gradient overlay -->
<div class="absolute inset-0 bg-gradient-to-t from-[#fafafa] dark:from-[#111] via-[#fafafa]/30 dark:via-[#111]/30 to-transparent pointer-events-none"></div>
<!-- Content positioned bottom-left -->
<div class="absolute bottom-0 left-0 right-0 p-10" in:fade={{ duration: 300 }}>
<div class="max-w-sm">
<div class="text-3xl font-medium mb-3 tracking-tight text-[#1d1d1f] dark:text-[#fafafa]">{$i18n.t('app.name')}</div>
<div class="text-base opacity-50 mb-8 leading-relaxed text-[#1d1d1f] dark:text-[#fafafa]">
{$i18n.t('main.heroDescription')}
</div>
{#if !localInstalled}
<button
class="inline-flex items-center gap-2 bg-black dark:bg-white px-6 py-2 rounded-xl text-white dark:text-black text-[13px] transition hover:bg-gray-800 dark:hover:bg-gray-100 border-none disabled:opacity-50"
onclick={() => {
if (installPhase === 'error') {
onStartInstall()
} else {
showGetStartedModal = true
}
}}
disabled={installPhase === 'working'}
>
{#if installPhase === 'working'}
<div class="w-3.5 h-3.5 rounded-full border-2 border-white/30 dark:border-black/30 border-t-white dark:border-t-black animate-spin"></div>
{$i18n.t('common.installing')}
{:else if installPhase === 'error'}
{$i18n.t('common.retry')}
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M20.015 4.356v4.992m0 0h-4.992m4.993 0l-3.181-3.183a8.25 8.25 0 00-13.803 3.7" />
</svg>
{:else}
{$i18n.t('main.getStarted')}
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
{/if}
</button>
{#if installPhase === 'working' && installStatus}
<div class="mt-3 text-[12px] opacity-40 font-mono line-clamp-1" in:fade={{ duration: 200 }}>
{installStatus}
</div>
{/if}
{/if}
{#if installPhase !== 'working'}
<div class="mt-6">
<button
class="text-sm opacity-40 hover:opacity-70 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa]"
onclick={() => { showAddConnectionModal = true }}
>
{$i18n.t('setup.connectToServer')}
</button>
</div>
{/if}
</div>
</div>
</div>
{/if}
<!-- Error toast -->
{#if toastVisible && installError}
<div
class="absolute top-4 left-1/2 -translate-x-1/2 z-50 bg-red-500/90 backdrop-blur-sm text-white text-[12px] px-4 py-2 rounded-xl shadow-lg"
in:fly={{ y: -10, duration: 200 }}
out:fade={{ duration: 150 }}
>
{installError}
</div>
{/if}
{:else if view === 'install'}
<div class="w-full max-w-[260px]">
<LocalInstall
autoStart={autoInstall}
onBack={() => { autoInstall = false; onSetView('welcome') }}
onComplete={async () => {
connections.set(await window.electronAPI.getConnections())
config.set(await window.electronAPI.getConfig())
onSetView('welcome')
}}
/>
</div>
{/if}
</div>
{/if}
{#if showGetStartedModal}
<GetStartedModal
onContinue={(options) => {
showGetStartedModal = false
onStartInstall(options)
}}
onCancel={() => { showGetStartedModal = false }}
/>
{/if}
{#if showAddConnectionModal}
<AddConnectionModal
bind:url
bind:connecting
bind:error
onConnect={() => {
onAddConnection()
}}
onCancel={() => {
showAddConnectionModal = false
error = ''
}}
/>
{/if}
</div>
@@ -1,98 +0,0 @@
<script lang="ts">
import { fade, scale } from 'svelte/transition'
import i18n from '../../../i18n'
import Switch from '../../common/Switch.svelte'
interface Props {
onContinue: (options: { installOpenTerminal: boolean; installLlamaCpp: boolean }) => void
onCancel: () => void
}
let { onContinue, onCancel }: Props = $props()
let installOpenTerminal = $state(true)
let installLlamaCpp = $state(true)
</script>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-[100] flex items-center justify-center"
transition:fade={{ duration: 150 }}
onmousedown={onCancel}
>
<div class="absolute inset-0 bg-black/50 backdrop-blur-sm"></div>
<div
class="relative mx-4 w-full max-w-xl overflow-hidden rounded-3xl bg-white shadow-2xl dark:bg-gray-950"
transition:scale={{ start: 0.97, duration: 180 }}
onmousedown={(e) => e.stopPropagation()}
>
<!-- Visual header -->
<div
class="relative flex h-36 items-center justify-center overflow-hidden bg-gradient-to-br from-gray-900 via-gray-800 to-black dark:from-white dark:via-gray-100 dark:to-gray-200"
>
<div class="absolute inset-0 bg-gradient-to-r from-transparent via-white/5 to-transparent"></div>
<div class="relative z-10 text-center">
<div class="mb-2.5 flex justify-center">
<div class="rounded-full bg-white/10 p-3 dark:bg-black/10">
<svg class="w-6 h-6 text-white dark:text-gray-900" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25a2.25 2.25 0 0 1-2.25-2.25v-2.25Z" />
</svg>
</div>
</div>
<h2 class="text-lg font-semibold tracking-tight text-white dark:text-gray-900">
{$i18n.t('main.getStarted.title')}
</h2>
<p class="mt-1 text-xs text-white/60 dark:text-gray-900/50">
{$i18n.t('main.getStarted.description')}
</p>
</div>
</div>
<!-- Options -->
<div class="px-6 py-4 flex flex-col divide-y divide-gray-100/30 dark:divide-gray-800/15">
<div class="py-3.5 flex items-center justify-between gap-4">
<div>
<div class="text-[13px] font-medium text-gray-700 dark:text-gray-300">{$i18n.t('main.getStarted.openTerminal')}</div>
<div class="text-[11px] text-gray-400 dark:text-gray-500 mt-0.5">{$i18n.t('main.getStarted.openTerminalDesc')}</div>
</div>
<Switch
checked={installOpenTerminal}
onchange={(v) => { installOpenTerminal = v }}
/>
</div>
<div class="py-3.5 flex items-center justify-between gap-4">
<div>
<div class="text-[13px] font-medium text-gray-700 dark:text-gray-300 flex items-center gap-1.5">
{$i18n.t('main.getStarted.llamaCpp')}
<span class="text-[9px] opacity-30 uppercase tracking-wide">{$i18n.t('common.experimental')}</span>
</div>
<div class="text-[11px] text-gray-400 dark:text-gray-500 mt-0.5">{$i18n.t('main.getStarted.llamaCppDesc')}</div>
</div>
<Switch
checked={installLlamaCpp}
onchange={(v) => { installLlamaCpp = v }}
/>
</div>
</div>
<!-- Footer -->
<div class="px-5 pb-5 flex flex-col gap-2">
<button
class="w-full rounded-xl bg-gray-900 dark:bg-white px-4 py-2.5 text-sm font-medium text-white dark:text-gray-900 transition-all duration-200 hover:bg-gray-800 dark:hover:bg-gray-100 active:scale-[0.98] border-none cursor-pointer"
onclick={() => onContinue({ installOpenTerminal, installLlamaCpp })}
>
{$i18n.t('main.getStarted.continue')}
</button>
<button
class="w-full rounded-xl px-4 py-2 text-sm text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition bg-transparent border-none cursor-pointer"
onclick={onCancel}
>
{$i18n.t('common.cancel')}
</button>
</div>
</div>
</div>
@@ -1,193 +0,0 @@
<script lang="ts">
import { fly } from 'svelte/transition'
import i18n from '../../../i18n'
import { tooltip } from '../../../actions/tooltip'
import LogViewer from '../../common/LogViewer.svelte'
interface Props {
activeLog: 'server' | 'open-terminal' | 'llama-server'
serviceReady: boolean
statusText?: string
connectPty: (callback: (data: string) => void) => void
disconnectPty: () => void
readonly?: boolean
onWrite?: (data: string) => void
onResize?: (cols: number, rows: number) => void
onStop?: () => Promise<void>
onClose: () => void
}
let {
activeLog,
serviceReady,
statusText = '',
connectPty,
disconnectPty,
readonly = false,
onWrite,
onResize,
onStop,
onClose
}: Props = $props()
let stopping = $state(false)
let panelHeight = $state(250)
let dragging = $state(false)
let startY = 0
let startHeight = 0
let copied = $state(false)
let refreshKey = $state(0)
let logViewerRef: LogViewer | undefined = $state()
const logLabels: Record<string, () => string> = {
'server': () => $i18n.t('statusBar.server'),
'open-terminal': () => $i18n.t('sidebar.openTerminal'),
'llama-server': () => $i18n.t('sidebar.llamaCpp')
}
const onDragStart = (e: MouseEvent) => {
dragging = true
startY = e.clientY
startHeight = panelHeight
// Overlay prevents webview from capturing mouse events during drag
const overlay = document.createElement('div')
overlay.style.cssText = 'position:fixed;inset:0;z-index:9999;cursor:ns-resize;'
document.body.appendChild(overlay)
const onDragMove = (e: MouseEvent) => {
const delta = startY - e.clientY
panelHeight = Math.max(120, Math.min(600, startHeight + delta))
}
const onDragEnd = () => {
dragging = false
overlay.remove()
window.removeEventListener('mousemove', onDragMove)
window.removeEventListener('mouseup', onDragEnd)
}
window.addEventListener('mousemove', onDragMove)
window.addEventListener('mouseup', onDragEnd)
}
const copyLogs = () => {
const text = logViewerRef?.getBufferText?.()
if (text) {
navigator.clipboard.writeText(text)
copied = true
setTimeout(() => { copied = false }, 1500)
}
}
</script>
<div
class="shrink-0 flex flex-col border-t border-black/[0.1] dark:border-white/[0.1] bg-[#0a0a0a] overflow-hidden"
style="height: {panelHeight}px"
in:fly={{ y: 60, duration: 200 }}
out:fly={{ y: 60, duration: 150 }}
>
<!-- Resize edge -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="h-0 shrink-0 cursor-ns-resize relative"
onmousedown={onDragStart}
>
<div class="absolute -top-[3px] left-0 right-0 h-[6px] z-10"></div>
</div>
<!-- Header bar -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="flex items-center justify-between px-3 py-1 shrink-0 border-b border-white/[0.06] cursor-pointer" onclick={onClose}>
<div class="flex items-center gap-2">
<span class="text-[11px] uppercase tracking-wider text-white/40 font-medium">{logLabels[activeLog]?.() ?? activeLog}</span>
</div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="flex items-center gap-0.5" onclick={(e) => e.stopPropagation()}>
<!-- Stop button (Open Terminal / llama.cpp only) -->
{#if onStop && serviceReady}
<button
class="p-1 rounded-md hover:bg-white/[0.08] transition bg-transparent border-none cursor-pointer {stopping ? 'opacity-30 pointer-events-none' : 'opacity-40 hover:opacity-80'} text-white"
disabled={stopping}
onclick={async () => {
stopping = true
try {
await onStop()
} finally {
stopping = false
onClose()
}
}}
use:tooltip={$i18n.t('common.stop')}
>
{#if stopping}
<div class="w-3.5 h-3.5 rounded-full border-[1.5px] border-white/30 border-t-transparent animate-spin"></div>
{:else}
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M5.636 5.636a9 9 0 1012.728 0M12 3v9" />
</svg>
{/if}
</button>
{/if}
<!-- Copy button -->
<button
class="p-1 rounded-md opacity-40 hover:opacity-80 hover:bg-white/[0.08] transition bg-transparent border-none text-white cursor-pointer"
onclick={copyLogs}
title={$i18n.t('logs.copyLogs')}
>
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
{#if copied}
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
{:else}
<path stroke-linecap="round" stroke-linejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9.75a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" />
{/if}
</svg>
</button>
<!-- Refresh button -->
<button
class="p-1 rounded-md opacity-40 hover:opacity-80 hover:bg-white/[0.08] transition bg-transparent border-none text-white cursor-pointer"
onclick={() => { disconnectPty(); refreshKey++ }}
title={$i18n.t('common.refresh')}
>
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M20.015 4.356v4.992m0 0h-4.992m4.993 0l-3.181-3.183a8.25 8.25 0 00-13.803 3.7" />
</svg>
</button>
<!-- Close button -->
<button
class="p-1 rounded-md opacity-40 hover:opacity-80 hover:bg-white/[0.08] transition bg-transparent border-none text-white cursor-pointer"
onclick={onClose}
title={$i18n.t('common.close')}
>
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<!-- Log content -->
<div class="flex-1 min-h-0 relative overflow-hidden">
{#if serviceReady}
{#key `${activeLog}-${refreshKey}`}
<LogViewer
bind:this={logViewerRef}
connect={connectPty}
disconnect={disconnectPty}
{readonly}
{onWrite}
{onResize}
/>
{/key}
{:else}
<div class="absolute inset-0 flex items-center justify-center bg-[#0a0a0a]">
<div class="flex flex-col items-center gap-3">
<div class="w-5 h-5 rounded-full border-2 border-white/15 border-t-white/50 animate-spin"></div>
<span class="text-[11px] text-white/50">{statusText || $i18n.t('common.loading')}</span>
</div>
</div>
{/if}
</div>
</div>
@@ -1,500 +0,0 @@
<script lang="ts">
import { fly, fade } from 'svelte/transition'
import { connections, config, appInfo, serverInfo } from '../../../stores'
import i18n from '../../../i18n'
interface Props {
activeConnectionId: string
connectingId: string
localConn: any
localInstalled: boolean
remoteConnections: any[]
serverStatus: string | undefined
serverReachable: boolean | undefined
settingsOpen: boolean
onConnect: (id: string) => void
onDisconnect: () => void
onAddView: () => void
onOpenSettings: () => void
onRename: (id: string, name: string) => void
onRemove: (id: string) => void
openGithub: () => void
}
let {
activeConnectionId,
connectingId,
localConn,
localInstalled,
remoteConnections,
serverStatus,
serverReachable,
settingsOpen = $bindable(false),
onConnect,
onDisconnect,
onAddView,
onOpenSettings,
onRename,
onRemove,
openGithub
}: Props = $props()
// Inline rename state
let editingId = $state<string | null>(null)
let editValue = $state('')
let menuOpenId = $state<string | null>(null)
const startRename = (id: string, currentName: string) => {
editingId = id
editValue = currentName
}
const commitRename = () => {
if (editingId && editValue.trim()) {
onRename(editingId, editValue.trim())
}
editingId = null
editValue = ''
}
const cancelRename = () => {
editingId = null
editValue = ''
}
</script>
<div
class="w-[200px] shrink-0 flex flex-col bg-[#f5f5f7] dark:bg-[#0a0a0a] relative"
in:fly={{ x: -200, duration: 200 }}
>
<!-- Connections header -->
<div class="flex items-center justify-between px-4 pt-2 pb-1.5">
<span class="text-[10px] tracking-wider uppercase opacity-60"
>{$i18n.t('sidebar.connections')}</span
>
<button
class="opacity-25 hover:opacity-60 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] leading-none"
onclick={() => {
onAddView()
}}
title={$i18n.t('sidebar.addConnection')}
>
<svg
class="w-[14px] h-[14px]"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
</button>
</div>
<!-- Connection list -->
<div class="flex-1 min-h-0 overflow-y-auto px-2">
<!-- Pinned: Open WebUI (local) -->
{#if localConn && localInstalled}
{@const isServerLoading =
connectingId === localConn.id ||
serverStatus === 'starting' ||
(serverStatus === 'running' && !serverReachable)}
{@const isLocalDisabled = !serverReachable && !isServerLoading}
<div
class="w-full px-2.5 py-1.5 rounded-xl group flex items-center gap-2 transition-colors {isLocalDisabled
? 'opacity-40 cursor-default'
: 'cursor-pointer'} {activeConnectionId === localConn.id || isServerLoading
? 'bg-black/[0.06] dark:bg-white/[0.06]'
: isLocalDisabled
? ''
: 'hover:bg-black/[0.03] dark:bg-white/[0.05]'}"
role="button"
tabindex="0"
onclick={() => !isLocalDisabled && onConnect(localConn.id)}
onkeydown={(e) => e.key === 'Enter' && !isLocalDisabled && onConnect(localConn.id)}
>
{#if connectingId === localConn.id || serverStatus === 'starting' || (serverStatus === 'running' && !serverReachable)}
<div class="w-[14px] h-[14px] shrink-0 flex items-center justify-center">
<div
class="w-2.5 h-2.5 rounded-full border-[1.5px] border-black/20 dark:border-white/30 border-t-transparent animate-spin"
></div>
</div>
{:else if serverReachable}
<div class="w-[14px] h-[14px] shrink-0 flex items-center justify-center">
<div
class="w-2 h-2 rounded-full bg-emerald-400 shadow-[0_0_6px_rgba(52,211,153,0.5)]"
></div>
</div>
{:else}
<div class="w-[14px] h-[14px] shrink-0 flex items-center justify-center">
<div class="w-2 h-2 rounded-full bg-black/10 dark:bg-white/15"></div>
</div>
{/if}
{#if editingId === localConn.id}
<!-- svelte-ignore a11y_autofocus -->
<input
type="text"
class="text-[12px] bg-transparent text-[#1d1d1f] dark:text-[#fafafa] px-0 py-0 border-none outline-none rounded-md flex-1 min-w-0"
bind:value={editValue}
autofocus
onfocus={(e) => e.currentTarget.select()}
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => {
e.stopPropagation()
if (e.key === 'Enter') commitRename()
if (e.key === 'Escape') cancelRename()
}}
onblur={commitRename}
/>
{:else}
<span
class="text-[12px] {activeConnectionId === localConn.id
? 'opacity-90'
: 'opacity-100'} transition-opacity truncate flex-1 min-w-0"
>{localConn.name ?? 'Open WebUI'}</span
>
{/if}
<div class="ml-auto relative shrink-0">
<button
class="opacity-20 hover:opacity-70 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-0.5 leading-none"
onclick={(e) => {
e.stopPropagation()
menuOpenId = menuOpenId === 'local' ? null : 'local'
}}
>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20">
<path
d="M6 10a2 2 0 11-4 0 2 2 0 014 0zM12 10a2 2 0 11-4 0 2 2 0 014 0zM18 10a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
</button>
{#if menuOpenId === 'local'}
<div
class="fixed inset-0 z-40"
onclick={(e) => {
e.stopPropagation()
menuOpenId = null
}}
></div>
<div
class="absolute right-0 top-6 z-50 w-[160px] bg-white dark:bg-[#1a1a1a]/90 backdrop-blur-xl border border-black/[0.08] dark:border-white/[0.08] rounded-2xl shadow-2xl py-0.5 overflow-hidden"
in:fly={{ y: -4, duration: 150 }}
out:fade={{ duration: 100 }}
>
<div class="py-1 px-1.5">
<button
class="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] opacity-50 hover:opacity-90 hover:bg-black/[0.04] dark:bg-white/[0.06] transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl"
onclick={(e) => {
e.stopPropagation()
menuOpenId = null
startRename(localConn.id, localConn.name ?? 'Open WebUI')
}}
>
<svg
class="w-[14px] h-[14px] shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487z"
/>
</svg>
{$i18n.t('common.rename')}
</button>
<button
class="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] opacity-50 hover:opacity-90 hover:bg-black/[0.04] dark:bg-white/[0.06] transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl"
onclick={(e) => {
e.stopPropagation()
menuOpenId = null
window.electronAPI?.openInBrowser?.(localConn.url)
}}
>
<svg
class="w-[14px] h-[14px] shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"
/>
</svg>
{$i18n.t('sidebar.openInBrowser')}
</button>
</div>
</div>
{/if}
</div>
</div>
{/if}
{#if localConn && localInstalled && remoteConnections.length > 0}
<div class="my-1 mx-2 border-t border-black/[0.04] dark:border-white/[0.04]"></div>
{/if}
{#each remoteConnections as conn (conn.id)}
<div
class="w-full px-2.5 py-1.5 rounded-xl group flex items-center gap-2 transition-colors cursor-pointer {activeConnectionId ===
conn.id
? 'bg-black/[0.06] dark:bg-white/[0.06]'
: 'hover:bg-black/[0.03] dark:bg-white/[0.05]'}"
role="button"
tabindex="0"
onclick={() => editingId !== conn.id && onConnect(conn.id)}
onkeydown={(e) => e.key === 'Enter' && editingId !== conn.id && onConnect(conn.id)}
>
{#if connectingId === conn.id}
<div class="w-[14px] h-[14px] shrink-0 flex items-center justify-center">
<div
class="w-2.5 h-2.5 rounded-full border-[1.5px] border-black/20 dark:border-white/30 border-t-transparent animate-spin"
></div>
</div>
{:else}
<svg
class="w-[14px] h-[14px] shrink-0 opacity-30"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5a17.92 17.92 0 01-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418"
/>
</svg>
{/if}
{#if editingId === conn.id}
<!-- svelte-ignore a11y_autofocus -->
<input
type="text"
class="text-[12px] bg-transparent text-[#1d1d1f] dark:text-[#fafafa] px-0 py-0 border-none outline-none rounded-md flex-1 min-w-0"
bind:value={editValue}
autofocus
onfocus={(e) => e.currentTarget.select()}
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => {
e.stopPropagation()
if (e.key === 'Enter') commitRename()
if (e.key === 'Escape') cancelRename()
}}
onblur={commitRename}
/>
{:else}
<span
class="text-[12px] {activeConnectionId === conn.id
? 'opacity-90'
: 'opacity-100'} transition-opacity truncate flex-1 min-w-0">{conn.name}</span
>
{/if}
<!-- Three-dots menu -->
<div class="ml-auto relative shrink-0">
<button
class="opacity-20 hover:opacity-70 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-0.5 leading-none"
onclick={(e) => {
e.stopPropagation()
menuOpenId = menuOpenId === conn.id ? null : conn.id
}}
>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20">
<path
d="M6 10a2 2 0 11-4 0 2 2 0 014 0zM12 10a2 2 0 11-4 0 2 2 0 014 0zM18 10a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
</button>
{#if menuOpenId === conn.id}
<div
class="fixed inset-0 z-40"
onclick={(e) => {
e.stopPropagation()
menuOpenId = null
}}
></div>
<div
class="absolute right-0 top-6 z-50 w-[160px] bg-white dark:bg-[#1a1a1a]/90 backdrop-blur-xl border border-black/[0.08] dark:border-white/[0.08] rounded-2xl shadow-2xl py-0.5 overflow-hidden"
in:fly={{ y: -4, duration: 150 }}
out:fade={{ duration: 100 }}
>
<div class="py-1 px-1.5">
<button
class="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] opacity-50 hover:opacity-90 hover:bg-black/[0.04] dark:bg-white/[0.06] transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl"
onclick={(e) => {
e.stopPropagation()
menuOpenId = null
startRename(conn.id, conn.name)
}}
>
<svg
class="w-[14px] h-[14px] shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487z"
/>
</svg>
{$i18n.t('common.rename')}
</button>
<button
class="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] opacity-50 hover:opacity-90 hover:bg-black/[0.04] dark:bg-white/[0.06] transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl"
onclick={(e) => {
e.stopPropagation()
menuOpenId = null
window.electronAPI?.openInBrowser?.(conn.url)
}}
>
<svg
class="w-[14px] h-[14px] shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"
/>
</svg>
{$i18n.t('sidebar.openInBrowser')}
</button>
</div>
<div class="mx-3 border-t border-black/[0.06] dark:border-white/[0.06]"></div>
<div class="py-1 px-1.5">
<button
class="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] opacity-50 hover:opacity-90 hover:bg-red-500/10 transition bg-transparent border-none text-red-400 rounded-xl"
onclick={(e) => {
e.stopPropagation()
menuOpenId = null
onRemove(conn.id)
}}
>
<svg
class="w-[14px] h-[14px] shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
{$i18n.t('common.delete')}
</button>
</div>
</div>
{/if}
</div>
</div>
{/each}
</div>
<!-- Settings popover -->
{#if settingsOpen}
<div class="fixed inset-0 z-40" onclick={() => (settingsOpen = false)}></div>
<div
class="absolute bottom-12 left-2 right-2 z-50 bg-white dark:bg-[#1a1a1a]/90 backdrop-blur-xl border border-black/[0.08] dark:border-white/[0.08] rounded-2xl shadow-lg py-0.5 overflow-hidden"
in:fly={{ y: 8, duration: 150 }}
out:fade={{ duration: 100 }}
>
<div class="px-3.5 py-2.5 border-b border-black/[0.06] dark:border-white/[0.06]">
<div class="text-[11px] opacity-40">{$i18n.t('app.desktop')}</div>
<div class="text-[10px] opacity-20 mt-0.5">{$appInfo?.version ?? ''}</div>
</div>
<div class="py-1 px-1.5">
<button
class="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] opacity-50 hover:opacity-90 hover:bg-black/4 dark:hover:bg-white/4 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl"
onclick={() => {
settingsOpen = false
onOpenSettings()
}}
>
<svg
class="w-[14px] h-[14px] shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 010 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 010-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
{$i18n.t('sidebar.settings')}
</button>
<button
class="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] opacity-50 hover:opacity-90 hover:bg-black/4 dark:hover:bg-white/4 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl"
onclick={openGithub}
>
<svg
class="w-[14px] h-[14px] shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"
/>
</svg>
{$i18n.t('sidebar.github')}
</button>
</div>
</div>
{/if}
<!-- Settings button (bottom) -->
<div class="px-2 pb-3">
<button
class="w-full flex items-center gap-2 px-2 py-[6px] rounded-xl text-[12px] opacity-80 hover:opacity-70 hover:bg-black/4 dark:hover:bg-white/4 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] text-left"
onclick={() => (settingsOpen = !settingsOpen)}
>
<svg
class="w-[14px] h-[14px] shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 010 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 010-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28z"
/>
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
{$i18n.t('sidebar.settings')}
</button>
</div>
</div>
@@ -1,181 +0,0 @@
<script lang="ts">
import { fade } from 'svelte/transition'
import i18n from '../../../i18n'
import { tooltip } from '../../../actions/tooltip'
import { appInfo } from '../../../stores'
import trayIcon from '../../../../../../../resources/tray.png'
interface Props {
serverStatus: string | undefined
serverReachable: boolean | undefined
openTerminalStatus: string | null
llamaCppStatus: string | null
openWebuiInstalled: boolean
openTerminalInstalled: boolean
llamaCppInstalled: boolean
activeLog: string | null
onSelectLog: (log: string) => void
onStartServer: () => void
onToggleOpenTerminal: () => void
onToggleLlamaCpp: () => void
}
let {
serverStatus,
serverReachable,
openTerminalStatus,
llamaCppStatus,
openWebuiInstalled,
openTerminalInstalled,
llamaCppInstalled,
activeLog,
onSelectLog,
onStartServer,
onToggleOpenTerminal,
onToggleLlamaCpp
}: Props = $props()
// Derived server state
const serverRunning = $derived(serverStatus === 'started' && serverReachable)
const serverStarting = $derived(
serverStatus === 'starting' || (serverStatus === 'started' && !serverReachable)
)
const otRunning = $derived(openTerminalStatus === 'started')
const otStarting = $derived(openTerminalStatus === 'starting' || openTerminalStatus === 'stopping')
const otFailed = $derived(openTerminalStatus === 'failed')
const lsRunning = $derived(llamaCppStatus === 'started')
const lsStarting = $derived(
llamaCppStatus === 'starting' || llamaCppStatus === 'setting-up' || llamaCppStatus === 'stopping'
)
const lsFailed = $derived(llamaCppStatus === 'failed')
// Derived visibility — show each section only when installed or active
const showServer = $derived(openWebuiInstalled || !!serverStatus)
const showTerminal = $derived(openTerminalInstalled || !!openTerminalStatus)
const showLlama = $derived(llamaCppInstalled || !!llamaCppStatus)
</script>
<div
class="shrink-0 flex items-center gap-1 px-3 h-7 border-t border-black/[0.08] dark:border-white/[0.08] bg-[#ebebed] dark:bg-[#111111]"
in:fade={{ duration: 150 }}
>
<!-- Open WebUI logo mark -->
<img src={trayIcon} alt="" class="w-3.5 opacity-30 mx-0.5 shrink-0 invert dark:invert-0" />
{#if showServer}
<!-- Open WebUI status -->
<button
class="flex items-center gap-1.5 px-2 py-0.5 rounded-md text-[11px] transition-all bg-transparent border-none cursor-pointer text-[#1d1d1f] dark:text-[#fafafa] {activeLog === 'server'
? 'bg-black/[0.08] dark:bg-white/[0.1] opacity-90'
: 'opacity-50 hover:opacity-80 hover:bg-black/[0.04] dark:hover:bg-white/[0.06]'}"
onclick={() => {
if (!serverRunning && !serverStarting) {
onStartServer()
}
onSelectLog('server')
}}
use:tooltip={serverRunning
? $i18n.t('statusBar.serverRunning')
: serverStarting
? $i18n.t('common.starting')
: $i18n.t('statusBar.serverStopped')}
>
<div class="w-[7px] h-[7px] shrink-0 rounded-full {serverRunning
? 'bg-emerald-400 shadow-[0_0_5px_rgba(52,211,153,0.6)]'
: serverStarting
? 'bg-amber-400 animate-pulse'
: 'bg-black/15 dark:bg-white/20'}">
</div>
<span>{$i18n.t('statusBar.server')}</span>
</button>
{/if}
{#if showTerminal}
{#if showServer}
<div class="w-px h-3 bg-black/[0.08] dark:bg-white/[0.08] mx-0.5"></div>
{/if}
<!-- Open Terminal status -->
<button
class="flex items-center gap-1.5 px-2 py-0.5 rounded-md text-[11px] transition-all bg-transparent border-none cursor-pointer text-[#1d1d1f] dark:text-[#fafafa] {activeLog === 'open-terminal'
? 'bg-black/[0.08] dark:bg-white/[0.1] opacity-90'
: 'opacity-50 hover:opacity-80 hover:bg-black/[0.04] dark:hover:bg-white/[0.06]'}"
onclick={() => {
if (!otRunning && !otStarting) {
onToggleOpenTerminal()
}
onSelectLog('open-terminal')
}}
oncontextmenu={(e) => {
e.preventDefault()
if (otRunning) onToggleOpenTerminal()
}}
use:tooltip={otRunning
? activeLog === 'open-terminal'
? $i18n.t('sidebar.tooltip.hideLogs')
: $i18n.t('sidebar.tooltip.viewLogs')
: otStarting
? $i18n.t('common.starting')
: otFailed
? $i18n.t('sidebar.tooltip.clickToRetry')
: $i18n.t('sidebar.tooltip.startTerminalServer')}
>
<div class="w-[7px] h-[7px] shrink-0 rounded-full {otRunning
? 'bg-emerald-400 shadow-[0_0_5px_rgba(52,211,153,0.6)]'
: otStarting
? 'bg-amber-400 animate-pulse'
: otFailed
? 'bg-red-400'
: 'bg-black/15 dark:bg-white/20'}">
</div>
<span>{$i18n.t('sidebar.openTerminal')}</span>
</button>
{/if}
{#if showLlama}
{#if showServer || showTerminal}
<div class="w-px h-3 bg-black/[0.08] dark:bg-white/[0.08] mx-0.5"></div>
{/if}
<!-- llama.cpp status -->
<button
class="flex items-center gap-1.5 px-2 py-0.5 rounded-md text-[11px] transition-all bg-transparent border-none cursor-pointer text-[#1d1d1f] dark:text-[#fafafa] {activeLog === 'llama-server'
? 'bg-black/[0.08] dark:bg-white/[0.1] opacity-90'
: 'opacity-50 hover:opacity-80 hover:bg-black/[0.04] dark:hover:bg-white/[0.06]'}"
onclick={() => {
if (!lsRunning && !lsStarting) {
onToggleLlamaCpp()
}
onSelectLog('llama-server')
}}
oncontextmenu={(e) => {
e.preventDefault()
if (lsRunning) onToggleLlamaCpp()
}}
use:tooltip={lsRunning
? activeLog === 'llama-server'
? $i18n.t('sidebar.tooltip.hideLogs')
: $i18n.t('sidebar.tooltip.viewLogs')
: lsStarting
? $i18n.t('common.starting')
: lsFailed
? $i18n.t('sidebar.tooltip.clickToRetry')
: $i18n.t('sidebar.tooltip.startLlamaServer')}
>
<div class="w-[7px] h-[7px] shrink-0 rounded-full {lsRunning
? 'bg-emerald-400 shadow-[0_0_5px_rgba(52,211,153,0.6)]'
: lsStarting
? 'bg-amber-400 animate-pulse'
: lsFailed
? 'bg-red-400'
: 'bg-black/15 dark:bg-white/20'}">
</div>
<span>{$i18n.t('sidebar.llamaCpp')}</span>
</button>
{/if}
<!-- Version (right-aligned) -->
<span class="ml-auto text-[10px] opacity-25 select-none">v{$appInfo?.version ?? ''}</span>
</div>
@@ -1,146 +0,0 @@
<script lang="ts">
import { fade } from 'svelte/transition'
import i18n from '../../i18n'
import General from './Settings/General.svelte'
import OpenWebUI from './Settings/OpenWebUI.svelte'
import Connections from './Settings/Connections.svelte'
import OpenTerminal from './Settings/OpenTerminal.svelte'
import InferenceRuntime from './Settings/InferenceRuntime.svelte'
import Models from './Settings/Models.svelte'
import About from './Settings/About.svelte'
interface Props {
onClose: () => void
}
let { onClose }: Props = $props()
let settingsTab = $state('general')
const tabs = [
{
id: 'general',
label: () => $i18n.t('settings.tabs.general'),
icon: 'M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 010 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 010-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28z',
extra: 'M15 12a3 3 0 11-6 0 3 3 0 016 0z'
},
{
id: 'openwebui',
label: () => $i18n.t('settings.tabs.openwebui'),
icon: 'M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
extra:
'M9 3.51a9.012 9.012 0 016 0M9 20.49a9.012 9.012 0 006 0M3.51 9a9.012 9.012 0 000 6M20.49 9a9.012 9.012 0 010 6M12 3v18M3 12h18'
},
{
id: 'terminal',
label: () => $i18n.t('settings.tabs.terminal'),
icon: 'M6.75 7.5l3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0021 18V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v12a2.25 2.25 0 002.25 2.25z'
},
{
id: 'inference',
label: () => $i18n.t('settings.tabs.inference'),
icon: 'M8.25 3v1.5M4.5 8.25H3m18 0h-1.5M4.5 12H3m18 0h-1.5m-15 3.75H3m18 0h-1.5M8.25 19.5V21M12 3v1.5m0 15V21m3.75-18v1.5m0 15V21m-9-1.5h10.5a2.25 2.25 0 002.25-2.25V6.75a2.25 2.25 0 00-2.25-2.25H6.75A2.25 2.25 0 004.5 6.75v10.5a2.25 2.25 0 002.25 2.25zm.75-12h9v9h-9v-9z'
},
{
id: 'models',
label: () => $i18n.t('settings.tabs.models'),
icon: 'M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125'
},
{
id: 'connections',
label: () => $i18n.t('settings.tabs.connections'),
icon: 'M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5a17.92 17.92 0 01-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418'
},
{
id: 'about',
label: () => $i18n.t('settings.tabs.about'),
icon: 'M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z'
}
]
</script>
<div
class="h-full w-full flex bg-[#f5f5f7] dark:bg-[#0a0a0a] text-[#1d1d1f] dark:text-[#fafafa]"
in:fade={{ duration: 150 }}
>
<!-- Settings sidebar -->
<div
class="w-[180px] shrink-0 flex flex-col border-r border-black/[0.06] dark:border-white/[0.06] bg-[#eee] dark:bg-[#111] px-1.5"
>
<div class="h-4 shrink-0"></div>
<div class="px-3 pb-3">
<span class="text-[13px] opacity-60 font-medium">{$i18n.t('settings.title')}</span>
</div>
<div class="flex flex-col gap-0.5 px-1">
{#each tabs as tab}
<button
class="flex items-center gap-2 px-2.5 py-[6px] rounded-2xl text-[12px] transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] text-left w-full {settingsTab ===
tab.id
? 'bg-black/[0.06] dark:bg-white/[0.08] opacity-90'
: 'opacity-40 hover:opacity-70 hover:bg-black/[0.02] '}"
onclick={() => (settingsTab = tab.id)}
>
<svg
class="w-[14px] h-[14px] shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d={tab.icon} />
{#if tab.extra}
<path stroke-linecap="round" stroke-linejoin="round" d={tab.extra} />
{/if}
</svg>
{tab.label()}
</button>
{/each}
</div>
</div>
<div class="flex-1 min-w-0 flex flex-col overflow-hidden">
<!-- Content header -->
<div
class="flex items-center justify-between px-8 pt-5 pb-3 border-b border-black/[0.04] dark:border-white/[0.04]"
>
<span class="text-[15px] opacity-80 font-medium"
>{tabs.find((t) => t.id === settingsTab)?.label() ?? settingsTab}</span
>
<button
class="opacity-30 hover:opacity-70 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa]"
onclick={onClose}
title={$i18n.t('settings.closeSettings')}
>
<svg
class="w-[14px] h-[14px]"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="flex-1 overflow-y-auto px-8 py-4">
{#if settingsTab === 'general'}
<General />
{:else if settingsTab === 'openwebui'}
<OpenWebUI />
{:else if settingsTab === 'connections'}
<Connections />
{:else if settingsTab === 'terminal'}
<OpenTerminal />
{:else if settingsTab === 'inference'}
<InferenceRuntime />
{:else if settingsTab === 'models'}
<Models />
{:else if settingsTab === 'about'}
<About />
{/if}
</div>
</div>
</div>
@@ -1,540 +0,0 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte'
import { appInfo } from '../../../stores'
import i18n from '../../../i18n'
import logoImage from '../../../assets/images/splash-dark.png'
let openWebuiVersion = $state<string | null>(null)
let openTerminalVersion = $state<string | null>(null)
let llamaCppVersion = $state<string | null>(null)
// Update state
type UpdateStatus = 'idle' | 'checking' | 'available' | 'downloading' | 'downloaded' | 'up-to-date' | 'error'
let updateStatus = $state<UpdateStatus>('idle')
let updateVersion = $state<string | null>(null)
let downloadPercent = $state(0)
let updateError = $state<string | null>(null)
let cleanupDataListener: (() => void) | null = null
// Changelog state
let changelogOpen = $state(false)
let changelogLoading = $state(false)
let changelogEntries = $state<{ version: string; date: string; body: string }[]>([])
// ── Easter Egg ────────────────────────────────────────
let clickCount = $state(0)
let clickTimer: ReturnType<typeof setTimeout> | null = null
let easterEggActive = $state(false)
let dismissTimer: ReturnType<typeof setTimeout> | null = null
let showReveal = $state(false)
let typewriterText = $state('')
let showTypewriter = $state(false)
let typewriterTimers: ReturnType<typeof setTimeout>[] = []
const handleVersionClick = () => {
clickCount++
if (clickTimer) clearTimeout(clickTimer)
clickTimer = setTimeout(() => { clickCount = 0 }, 800)
if (clickCount >= 7) {
clickCount = 0
if (clickTimer) clearTimeout(clickTimer)
activateEasterEgg()
}
}
const activateEasterEgg = () => {
easterEggActive = true
showReveal = false
showTypewriter = true
typewriterText = ''
// Go fullscreen
document.documentElement.requestFullscreen?.().catch(() => {})
// Full Matrix intro sequence
const username = $appInfo?.username ?? 'Neo'
const lines = [
`Wake up, ${username}...`,
'The Matrix has you...',
'Follow the white rabbit.',
`Knock, knock, ${username}.`
]
let lineIdx = 0
const typeLine = () => {
if (lineIdx >= lines.length) {
// All lines done — show logo with knock sound
showTypewriter = false
typewriterTimers.push(setTimeout(() => {
showReveal = true
}, 800))
return
}
const line = lines[lineIdx]
let charIdx = 0
typewriterText = ''
showTypewriter = true
const typeChar = () => {
if (charIdx < line.length) {
typewriterText = line.slice(0, charIdx + 1)
charIdx++
typewriterTimers.push(setTimeout(typeChar, 140 + Math.random() * 60))
} else {
// Hold, then clear and move to next line
typewriterTimers.push(setTimeout(() => {
typewriterText = ''
showTypewriter = false
lineIdx++
typewriterTimers.push(setTimeout(typeLine, 600))
}, 1800))
}
}
typewriterTimers.push(setTimeout(typeChar, 400))
}
typewriterTimers.push(setTimeout(typeLine, 1200))
// Auto-dismiss after 25 seconds
dismissTimer = setTimeout(() => dismissEasterEgg(), 25000)
}
const dismissEasterEgg = () => {
showReveal = false
showTypewriter = false
typewriterText = ''
typewriterTimers.forEach(t => clearTimeout(t))
typewriterTimers = []
if (dismissTimer) {
clearTimeout(dismissTimer)
dismissTimer = null
}
// Exit fullscreen first, then remove overlay after transition
if (document.fullscreenElement) {
document.exitFullscreen?.().then(() => {
easterEggActive = false
}).catch(() => {
easterEggActive = false
})
} else {
easterEggActive = false
}
}
onMount(async () => {
openWebuiVersion = await window.electronAPI.getPackageVersion('open-webui')
openTerminalVersion = await window.electronAPI.getPackageVersion('open-terminal')
try {
const info = await window.electronAPI.getLlamaCppInfo()
llamaCppVersion = info?.version ?? null
} catch {}
// Listen for update events from main process
cleanupDataListener = window.electronAPI.onData((data: any) => {
switch (data.type) {
case 'update:checking':
updateStatus = 'checking'
updateError = null
break
case 'update:available':
updateStatus = 'available'
updateVersion = data.data?.version ?? null
break
case 'update:not-available':
updateStatus = 'up-to-date'
break
case 'update:download-progress':
updateStatus = 'downloading'
downloadPercent = Math.round(data.data?.percent ?? 0)
break
case 'update:downloaded':
updateStatus = 'downloaded'
downloadPercent = 100
break
case 'update:error':
updateStatus = 'error'
updateError = data.data?.message ?? 'Unknown error'
break
}
})
})
onDestroy(() => {
cleanupDataListener?.()
if (dismissTimer) clearTimeout(dismissTimer)
if (clickTimer) clearTimeout(clickTimer)
typewriterTimers.forEach(t => clearTimeout(t))
})
const openRelease = (repo: string, version: string, prefix = 'v') => {
window.electronAPI?.openInBrowser?.(`https://github.com/${repo}/releases/tag/${prefix}${version}`)
}
const openGithub = () => {
window.electronAPI?.openInBrowser?.('https://github.com/open-webui/desktop')
}
const handleCheck = async () => {
updateStatus = 'checking'
updateError = null
try {
await window.electronAPI.checkForUpdates()
} catch (e: any) {
updateStatus = 'error'
updateError = e?.message ?? 'Check failed'
}
}
const handleDownload = async () => {
updateStatus = 'downloading'
downloadPercent = 0
try {
await window.electronAPI.downloadUpdate()
} catch (e: any) {
updateStatus = 'error'
updateError = e?.message ?? 'Download failed'
}
}
const handleInstall = () => {
window.electronAPI.installUpdate()
}
const toggleChangelog = async () => {
changelogOpen = !changelogOpen
if (changelogOpen && changelogEntries.length === 0) {
changelogLoading = true
try {
const md = await window.electronAPI.getChangelog()
if (md) parseChangelog(md)
} finally {
changelogLoading = false
}
}
}
const parseChangelog = (md: string) => {
const entries: { version: string; date: string; body: string }[] = []
const sections = md.split(/^## /m).slice(1)
for (const section of sections) {
const headerMatch = section.match(/^\[([^\]]+)\](?:\s*-\s*(.+))?/)
if (!headerMatch) continue
const version = headerMatch[1]
const date = headerMatch[2]?.trim() ?? ''
const body = section.slice(section.indexOf('\n') + 1).trim()
if (version === 'Unreleased' && !body) continue
entries.push({ version, date, body })
}
changelogEntries = entries
}
const renderMarkdown = (md: string): string => {
return md
.replace(/^### (.+)$/gm, '<div class="text-[11px] opacity-50 font-semibold mt-3 mb-1">$1</div>')
.replace(/^- (.+)$/gm, '<div class="text-[11px] opacity-40 pl-2 leading-relaxed">• $1</div>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/`(.+?)`/g, '<code class="text-[10px] bg-white/[0.06] px-1 py-0.5 rounded">$1</code>')
}
</script>
<div class="flex flex-col divide-y divide-white/[0.04]">
<button
class="w-full py-4 flex items-center justify-between bg-transparent border-none cursor-default text-[#1d1d1f] dark:text-[#fafafa]"
onclick={handleVersionClick}
>
<div class="text-[13px] opacity-70">{$i18n.t('settings.about.desktopVersion')}</div>
<div class="text-[12px] opacity-30">{$appInfo?.version ?? $i18n.t('common.unknown')}</div>
</button>
{#if openWebuiVersion}
<button
class="w-full py-4 flex items-center justify-between bg-transparent border-none cursor-pointer group"
onclick={() => openRelease('open-webui/open-webui', openWebuiVersion!)}
>
<div class="text-[13px] opacity-70">{$i18n.t('settings.about.openWebuiVersion')}</div>
<div class="text-[12px] opacity-30 group-hover:opacity-50 transition">{openWebuiVersion}</div>
</button>
{/if}
{#if openTerminalVersion}
<button
class="w-full py-4 flex items-center justify-between bg-transparent border-none cursor-pointer group"
onclick={() => openRelease('open-webui/open-terminal', openTerminalVersion!)}
>
<div class="text-[13px] opacity-70">{$i18n.t('settings.about.openTerminalVersion')}</div>
<div class="text-[12px] opacity-30 group-hover:opacity-50 transition">{openTerminalVersion}</div>
</button>
{/if}
{#if llamaCppVersion}
<button
class="w-full py-4 flex items-center justify-between bg-transparent border-none cursor-pointer group"
onclick={() => openRelease('ggml-org/llama.cpp', llamaCppVersion!, '')}
>
<div class="text-[13px] opacity-70">{$i18n.t('settings.about.llamaCppVersion')}</div>
<div class="text-[12px] opacity-30 group-hover:opacity-50 transition">{llamaCppVersion}</div>
</button>
{/if}
<div class="py-4 flex items-center justify-between">
<div class="text-[13px] opacity-70">{$i18n.t('settings.about.platform')}</div>
<div class="text-[12px] opacity-30">{$appInfo?.platform ?? $i18n.t('common.unknown')}</div>
</div>
<!-- Update section -->
<div class="py-4">
<div class="flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.about.softwareUpdate')}</div>
{#if updateStatus === 'up-to-date'}
<div class="text-[11px] opacity-25 mt-0.5">{$i18n.t('settings.about.upToDate')}</div>
{:else if updateStatus === 'available' && updateVersion}
<div class="text-[11px] opacity-40 mt-0.5">{$i18n.t('settings.about.versionAvailable', { version: updateVersion })}</div>
{:else if updateStatus === 'downloading'}
<div class="text-[11px] opacity-25 mt-0.5">{$i18n.t('settings.about.downloadingPercent', { percent: downloadPercent })}</div>
{:else if updateStatus === 'downloaded'}
<div class="text-[11px] opacity-40 mt-0.5">{$i18n.t('settings.about.updateReady')}</div>
{:else if updateStatus === 'error'}
<div class="text-[11px] text-red-400/60 mt-0.5">{updateError}</div>
{/if}
</div>
<div>
{#if updateStatus === 'idle' || updateStatus === 'up-to-date' || updateStatus === 'error'}
<button
class="text-[12px] opacity-40 hover:opacity-70 px-3 py-1.5 bg-black/[0.04] dark:bg-white/[0.06] transition border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl"
onclick={handleCheck}
>
{$i18n.t('settings.about.checkForUpdates')}
</button>
{:else if updateStatus === 'checking'}
<button
class="text-[12px] opacity-30 px-3 py-1.5 bg-black/[0.04] dark:bg-white/[0.06] border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl pointer-events-none flex items-center gap-1.5"
disabled
>
<svg class="w-3 h-3 animate-spin" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" stroke-dasharray="31.4 31.4" stroke-linecap="round" />
</svg>
{$i18n.t('settings.about.checking')}
</button>
{:else if updateStatus === 'available'}
<button
class="text-[12px] opacity-50 hover:opacity-80 px-3 py-1.5 bg-black/[0.06] dark:bg-white/[0.08] transition border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl"
onclick={handleDownload}
>
{$i18n.t('settings.about.downloadUpdate')}
</button>
{:else if updateStatus === 'downloading'}
<div class="flex items-center gap-2">
<div class="w-24 h-1.5 bg-black/[0.06] dark:bg-white/[0.06] rounded-full overflow-hidden">
<div
class="h-full bg-black/[0.15] dark:bg-white/30 rounded-full transition-all duration-300"
style="width: {downloadPercent}%"
></div>
</div>
<span class="text-[11px] opacity-30">{downloadPercent}%</span>
</div>
{:else if updateStatus === 'downloaded'}
<button
class="text-[12px] opacity-50 hover:opacity-80 px-3 py-1.5 bg-black/[0.06] dark:bg-white/[0.08] transition border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl"
onclick={handleInstall}
>
{$i18n.t('settings.about.restartToUpdate')}
</button>
{/if}
</div>
</div>
</div>
<!-- Changelog section -->
<div class="py-4">
<button
class="text-[12px] opacity-40 hover:opacity-70 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] flex items-center gap-1.5"
onclick={toggleChangelog}
>
<svg
class="w-3 h-3 transition-transform {changelogOpen ? 'rotate-90' : ''}"
fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
{$i18n.t('settings.about.whatsNew')}
</button>
{#if changelogOpen}
<div class="mt-3 max-h-64 overflow-y-auto pr-1">
{#if changelogLoading}
<div class="text-[11px] opacity-25">{$i18n.t('common.loading')}</div>
{:else if changelogEntries.length === 0}
<div class="text-[11px] opacity-25">{$i18n.t('settings.about.noChangelog')}</div>
{:else}
{#each changelogEntries as entry, i}
{#if i > 0}
<div class="border-t border-white/[0.04] my-3"></div>
{/if}
<div>
<div class="flex items-baseline gap-2">
<span class="text-[12px] opacity-60 font-medium">{entry.version}</span>
{#if entry.date}
<span class="text-[10px] opacity-20">{entry.date}</span>
{/if}
</div>
{#if entry.body}
<div class="mt-1">
{@html renderMarkdown(entry.body)}
</div>
{/if}
</div>
{/each}
{/if}
</div>
{/if}
</div>
<div class="py-4">
<button
class="text-[12px] opacity-40 hover:opacity-70 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa]"
onclick={openGithub}
>
{$i18n.t('settings.about.viewOnGithub')}
</button>
</div>
</div>
<div class="text-[10px] opacity-15 mt-4 leading-relaxed">{$i18n.t('settings.about.copyright')}<br />{$i18n.t('settings.about.createdBy')}</div>
<!-- Easter Egg: Matrix Rain Overlay -->
{#if easterEggActive}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="matrix-overlay" onclick={dismissEasterEgg}>
{#if showTypewriter}
<div class="matrix-typewriter">
<span>{typewriterText}</span><span class="cursor"></span>
</div>
{/if}
{#if showReveal}
<div class="matrix-reveal">
<img src={logoImage} alt="Open WebUI" class="matrix-logo-img" />
</div>
{/if}
</div>
{/if}
<style>
.matrix-overlay {
position: fixed;
inset: 0;
z-index: 9999;
background: #000;
cursor: pointer;
animation: matrixFadeIn 1.5s ease-out;
}
.matrix-typewriter {
position: absolute;
top: 80px;
left: 60px;
pointer-events: none;
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', Menlo, monospace;
font-size: 15px;
color: #15b800;
letter-spacing: 1px;
}
.matrix-typewriter .cursor {
animation: blink 0.8s step-end infinite;
opacity: 0.8;
}
.matrix-reveal {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
pointer-events: none;
animation: ghostReveal 3s ease-out both;
}
.matrix-logo-img {
width: 100px;
height: 100px;
object-fit: contain;
filter: drop-shadow(0 0 20px rgba(57, 200, 20, 0.3)) drop-shadow(0 0 40px rgba(57, 200, 20, 0.15))
brightness(0.8) sepia(1) saturate(3) hue-rotate(70deg);
animation: ghostPulse 4s ease-in-out infinite, glitch 8s ease-in-out infinite;
}
@keyframes matrixFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes ghostReveal {
0% {
opacity: 0;
transform: scale(0.95);
filter: blur(8px);
}
60% {
opacity: 0.6;
filter: blur(2px);
}
100% {
opacity: 1;
transform: scale(1);
filter: blur(0);
}
}
@keyframes ghostPulse {
0%, 100% {
filter: drop-shadow(0 0 20px rgba(57, 200, 20, 0.3)) drop-shadow(0 0 40px rgba(57, 200, 20, 0.15))
brightness(0.8) sepia(1) saturate(3) hue-rotate(70deg);
opacity: 0.8;
}
50% {
filter: drop-shadow(0 0 30px rgba(57, 200, 20, 0.5)) drop-shadow(0 0 60px rgba(57, 200, 20, 0.25))
brightness(0.9) sepia(1) saturate(3) hue-rotate(70deg);
opacity: 1;
}
}
@keyframes glitch {
0%, 94%, 100% {
transform: translate(0);
}
95% {
transform: translate(-2px, 1px);
}
96% {
transform: translate(2px, -1px);
}
97% {
transform: translate(0);
}
}
@keyframes blink {
0%, 100% { opacity: 0.8; }
50% { opacity: 0; }
}
@keyframes typewriterFade {
from { opacity: 0; }
to { opacity: 1; }
}
</style>
@@ -1,54 +0,0 @@
<script lang="ts">
import { connections, config } from '../../../stores'
import i18n from '../../../i18n'
const remove = async (id: string) => {
await window.electronAPI.removeConnection(id)
connections.set(await window.electronAPI.getConnections())
config.set(await window.electronAPI.getConfig())
}
</script>
<div class="flex flex-col divide-y divide-white/[0.04]">
{#each $connections as conn}
<div class="py-3 flex items-center justify-between">
<div class="flex items-center gap-2.5 min-w-0">
<svg
class="w-[14px] h-[14px] shrink-0 opacity-30"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
{#if conn.type === 'local'}
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25A2.25 2.25 0 015.25 3h13.5A2.25 2.25 0 0121 5.25z"
/>
{:else}
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3"
/>
{/if}
</svg>
<div class="min-w-0">
<div class="text-[13px] opacity-70 truncate">{conn.name}</div>
<div class="text-[11px] opacity-25 truncate mt-0.5">{conn.url}</div>
</div>
</div>
<button
class="text-[11px] opacity-30 hover:opacity-60 px-2 py-1 bg-transparent transition border-none text-[#1d1d1f] dark:text-[#fafafa] shrink-0"
onclick={() => remove(conn.id)}
>
{$i18n.t('common.remove')}
</button>
</div>
{/each}
{#if ($connections ?? []).length === 0}
<div class="py-6 text-[12px] opacity-20 text-center">{$i18n.t('settings.connections.noConnections')}</div>
{/if}
</div>
@@ -1,501 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte'
import { connections, config } from '../../../stores'
import i18n, { getLanguages, changeLanguage } from '../../../i18n'
import Switch from '../../common/Switch.svelte'
let launchAtLogin = $state(false)
let runInBackground = $state(true)
let resetting = $state(false)
let theme = $state<string>('system')
let advancedOpen = $state(false)
// Env vars editor state
let envEntries = $state<{ key: string; value: string }[]>([])
// Language state
let languages = $state<{ code: string; title: string }[]>([])
let selectedLanguage = $state('en-US')
onMount(async () => {
launchAtLogin = await window.electronAPI.getLaunchAtLogin()
const cfg = await window.electronAPI.getConfig()
runInBackground = cfg?.runInBackground ?? true
const vars = cfg?.envVars ?? {}
envEntries = Object.entries(vars).map(([key, value]) => ({ key, value: value as string }))
theme = cfg?.theme ?? 'system'
applyThemeClass(theme)
// Load languages
languages = await getLanguages()
selectedLanguage = cfg?.language ?? localStorage.getItem('locale') ?? 'en-US'
})
const applyThemeClass = (t: string) => {
let resolved = t
if (t === 'system') {
resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
document.documentElement.classList.remove('light', 'dark')
document.documentElement.classList.add(resolved)
}
const applyTheme = async (newTheme: string) => {
theme = newTheme
applyThemeClass(newTheme)
await window.electronAPI.setConfig({ theme: newTheme })
config.set(await window.electronAPI.getConfig())
}
const setDefault = async (id: string) => {
await window.electronAPI.setDefaultConnection(id)
config.set(await window.electronAPI.getConfig())
}
const saveEnvVars = async () => {
const envVars: Record<string, string> = {}
for (const entry of envEntries) {
const k = entry.key.trim()
if (k) envVars[k] = entry.value
}
await window.electronAPI.setConfig({ envVars })
config.set(await window.electronAPI.getConfig())
}
const addEnvVar = () => {
envEntries = [...envEntries, { key: '', value: '' }]
}
const removeEnvVar = (index: number) => {
envEntries = envEntries.filter((_, i) => i !== index)
saveEnvVars()
}
// Shortcut recorder
let shortcutValue = $state('')
let recording = $state(false)
let shortcutInputEl = $state<HTMLButtonElement | null>(null)
// Spotlight shortcut recorder
let spotlightShortcutValue = $state('')
let spotlightRecording = $state(false)
let spotlightShortcutInputEl = $state<HTMLButtonElement | null>(null)
// Keep shortcut value in sync with config store
$effect(() => {
if ($config?.globalShortcut !== undefined) {
shortcutValue = $config.globalShortcut ?? ''
}
})
$effect(() => {
if ($config?.spotlightShortcut !== undefined) {
spotlightShortcutValue = $config.spotlightShortcut ?? ''
}
})
const keyToElectron = (e: KeyboardEvent): string | null => {
const parts: string[] = []
if (e.metaKey || e.ctrlKey) parts.push('CommandOrControl')
if (e.altKey) parts.push('Alt')
if (e.shiftKey) parts.push('Shift')
// Ignore bare modifier presses
const ignore = ['Control', 'Meta', 'Alt', 'Shift']
if (ignore.includes(e.key)) return null
// Map special keys
const keyMap: Record<string, string> = {
' ': 'Space',
ArrowUp: 'Up',
ArrowDown: 'Down',
ArrowLeft: 'Left',
ArrowRight: 'Right',
Enter: 'Return'
}
const key = keyMap[e.key] ?? (e.key.length === 1 ? e.key.toUpperCase() : e.key)
parts.push(key)
return parts.join('+')
}
const displayShortcut = (accel: string): string => {
if (!accel) return ''
const isMac = navigator.platform.includes('Mac')
return accel
.replace(/CommandOrControl/g, isMac ? '⌘' : 'Ctrl')
.replace(/Alt/g, isMac ? '⌥' : 'Alt')
.replace(/Shift/g, isMac ? '⇧' : 'Shift')
.replace(/\+/g, ' + ')
}
const handleShortcutKeydown = async (e: KeyboardEvent) => {
e.preventDefault()
e.stopPropagation()
if (e.key === 'Escape') {
recording = false
return
}
if (e.key === 'Backspace' || e.key === 'Delete') {
shortcutValue = ''
recording = false
await window.electronAPI.setConfig({ globalShortcut: '' })
config.set(await window.electronAPI.getConfig())
return
}
const accel = keyToElectron(e)
if (accel) {
shortcutValue = accel
recording = false
await window.electronAPI.setConfig({ globalShortcut: accel })
config.set(await window.electronAPI.getConfig())
}
}
const handleSpotlightShortcutKeydown = async (e: KeyboardEvent) => {
e.preventDefault()
e.stopPropagation()
if (e.key === 'Escape') {
spotlightRecording = false
return
}
if (e.key === 'Backspace' || e.key === 'Delete') {
spotlightShortcutValue = ''
spotlightRecording = false
await window.electronAPI.setConfig({ spotlightShortcut: '' })
config.set(await window.electronAPI.getConfig())
return
}
const accel = keyToElectron(e)
if (accel) {
spotlightShortcutValue = accel
spotlightRecording = false
await window.electronAPI.setConfig({ spotlightShortcut: accel })
config.set(await window.electronAPI.getConfig())
}
}
</script>
<div class="flex flex-col divide-y divide-white/[0.04]">
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.general.language')}</div>
<div class="text-[11px] opacity-25 mt-0.5">{$i18n.t('settings.general.languageDesc')}</div>
</div>
<select
class="bg-black/[0.04] dark:bg-white/[0.06] text-[12px] text-[#1d1d1f] dark:text-[#fafafa] px-3 py-1.5 border-none outline-none rounded-xl opacity-60"
onchange={async (e) => {
const lang = (e.target as HTMLSelectElement).value
selectedLanguage = lang
localStorage.setItem('locale', lang)
changeLanguage(lang)
await window.electronAPI.setConfig({ language: lang })
config.set(await window.electronAPI.getConfig())
}}
>
{#each languages as lang}
<option value={lang.code} selected={selectedLanguage === lang.code}>{lang.title}</option>
{/each}
</select>
</div>
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.general.appearance')}</div>
<div class="text-[11px] opacity-25 mt-0.5">{$i18n.t('settings.general.appearanceDesc')}</div>
</div>
<div class="grid grid-cols-3 items-center gap-0.5 rounded-2xl bg-black/[0.04] dark:bg-white/[0.06] p-1 text-[11px]">
<button
class="flex h-6 w-16 items-center justify-center rounded-xl border-none transition {theme === 'system' ? 'bg-black/[0.08] dark:bg-white/[0.12] text-[#1d1d1f] dark:text-[#fafafa]' : 'bg-transparent text-[#1d1d1f] dark:text-[#fafafa] opacity-40 hover:opacity-70'}"
onclick={() => applyTheme('system')}
>
{$i18n.t('common.auto')}
</button>
<button
class="flex h-6 w-16 items-center justify-center rounded-xl border-none transition {theme === 'light' ? 'bg-black/[0.08] dark:bg-white/[0.12] text-[#1d1d1f] dark:text-[#fafafa]' : 'bg-transparent text-[#1d1d1f] dark:text-[#fafafa] opacity-40 hover:opacity-70'}"
onclick={() => applyTheme('light')}
aria-label={$i18n.t('settings.general.light')}
>
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z" />
</svg>
</button>
<button
class="flex h-6 w-16 items-center justify-center rounded-xl border-none transition {theme === 'dark' ? 'bg-black/[0.08] dark:bg-white/[0.12] text-[#1d1d1f] dark:text-[#fafafa]' : 'bg-transparent text-[#1d1d1f] dark:text-[#fafafa] opacity-40 hover:opacity-70'}"
onclick={() => applyTheme('dark')}
aria-label={$i18n.t('settings.general.dark')}
>
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z" />
</svg>
</button>
</div>
</div>
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.general.defaultConnection')}</div>
<div class="text-[11px] opacity-25 mt-0.5">{$i18n.t('settings.general.defaultConnectionDesc')}</div>
</div>
<select
class="bg-black/[0.04] dark:bg-white/[0.06] text-[12px] text-[#1d1d1f] dark:text-[#fafafa] px-3 py-1.5 border-none outline-none rounded-xl opacity-60"
onchange={(e) => setDefault((e.target as HTMLSelectElement).value)}
>
<option value="">{$i18n.t('common.none')}</option>
{#each $connections as conn}
<option value={conn.id} selected={$config?.defaultConnectionId === conn.id}
>{conn.name}</option
>
{/each}
</select>
</div>
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.general.launchAtLogin')}</div>
<div class="text-[11px] opacity-25 mt-0.5">{$i18n.t('settings.general.launchAtLoginDesc')}</div>
</div>
<Switch
checked={launchAtLogin}
label={$i18n.t('settings.general.toggleLaunchAtLogin')}
onchange={async (value) => {
launchAtLogin = value
await window.electronAPI.setLaunchAtLogin(launchAtLogin)
}}
/>
</div>
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.general.runInBackground')}</div>
<div class="text-[11px] opacity-25 mt-0.5">{$i18n.t('settings.general.runInBackgroundDesc')}</div>
</div>
<Switch
checked={runInBackground}
label={$i18n.t('settings.general.toggleRunInBackground')}
onchange={async (value) => {
runInBackground = value
await window.electronAPI.setConfig({ runInBackground })
config.set(await window.electronAPI.getConfig())
}}
/>
</div>
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.general.globalShortcut')}</div>
<div class="text-[11px] opacity-25 mt-0.5">
{#if recording}
{$i18n.t('settings.general.globalShortcutRecording')}
{:else}
{$i18n.t('settings.general.globalShortcutDesc')}
{/if}
</div>
</div>
<div class="flex items-center gap-1.5">
<button
bind:this={shortcutInputEl}
class="text-[12px] px-3 py-1.5 border-none outline-none rounded-xl transition min-w-[80px] text-center
{recording
? 'bg-black/[0.08] dark:bg-white/[0.10] text-[#1d1d1f] dark:text-[#fafafa] opacity-80 animate-pulse'
: 'bg-black/[0.04] dark:bg-white/[0.06] text-[#1d1d1f] dark:text-[#fafafa] opacity-60 hover:opacity-80'}"
onclick={() => {
recording = true
shortcutInputEl?.focus()
}}
onkeydown={(e) => {
if (recording) handleShortcutKeydown(e)
}}
onblur={() => {
recording = false
}}
>
{#if recording}
<span class="text-[11px]">{$i18n.t('settings.general.pressShortcut')}</span>
{:else if shortcutValue}
{displayShortcut(shortcutValue)}
{:else}
<span class="opacity-40">{$i18n.t('common.disabled')}</span>
{/if}
</button>
{#if shortcutValue && !recording}
<button
class="opacity-20 hover:opacity-50 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-0.5 shrink-0"
onclick={async () => {
shortcutValue = ''
await window.electronAPI.setConfig({ globalShortcut: '' })
config.set(await window.electronAPI.getConfig())
}}
>
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/if}
</div>
</div>
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.general.spotlightShortcut')}</div>
<div class="text-[11px] opacity-25 mt-0.5">
{#if spotlightRecording}
{$i18n.t('settings.general.globalShortcutRecording')}
{:else}
{$i18n.t('settings.general.spotlightShortcutDesc')}
{/if}
</div>
</div>
<div class="flex items-center gap-1.5">
<button
bind:this={spotlightShortcutInputEl}
class="text-[12px] px-3 py-1.5 border-none outline-none rounded-xl transition min-w-[80px] text-center
{spotlightRecording
? 'bg-black/[0.08] dark:bg-white/[0.10] text-[#1d1d1f] dark:text-[#fafafa] opacity-80 animate-pulse'
: 'bg-black/[0.04] dark:bg-white/[0.06] text-[#1d1d1f] dark:text-[#fafafa] opacity-60 hover:opacity-80'}"
onclick={() => {
spotlightRecording = true
spotlightShortcutInputEl?.focus()
}}
onkeydown={(e) => {
if (spotlightRecording) handleSpotlightShortcutKeydown(e)
}}
onblur={() => {
spotlightRecording = false
}}
>
{#if spotlightRecording}
<span class="text-[11px]">{$i18n.t('settings.general.pressShortcut')}</span>
{:else if spotlightShortcutValue}
{displayShortcut(spotlightShortcutValue)}
{:else}
<span class="opacity-40">{$i18n.t('common.disabled')}</span>
{/if}
</button>
{#if spotlightShortcutValue && !spotlightRecording}
<button
class="opacity-20 hover:opacity-50 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-0.5 shrink-0"
onclick={async () => {
spotlightShortcutValue = ''
await window.electronAPI.setConfig({ spotlightShortcut: '' })
config.set(await window.electronAPI.getConfig())
}}
>
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/if}
</div>
</div>
<!-- Advanced (collapsed by default) -->
<div class="py-4">
<button
class="flex items-center gap-1.5 bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-0 cursor-pointer"
onclick={() => { advancedOpen = !advancedOpen }}
>
<svg
class="w-3 h-3 opacity-30 transition-transform duration-200 {advancedOpen ? 'rotate-90' : ''}"
fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
<span class="text-[13px] opacity-50">{$i18n.t('common.advanced')}</span>
</button>
{#if advancedOpen}
<div class="flex flex-col divide-y divide-white/[0.04] mt-1">
<!-- Environment variables -->
<div class="py-4">
<div class="flex items-center justify-between mb-3">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.general.environmentVariables')}</div>
<div class="text-[11px] opacity-25 mt-0.5">{$i18n.t('settings.general.environmentVariablesDesc')}</div>
</div>
<button
class="text-[11px] opacity-30 hover:opacity-60 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa]"
onclick={addEnvVar}
>
{$i18n.t('common.add')}
</button>
</div>
{#if envEntries.length > 0}
<div class="flex flex-col gap-2">
{#each envEntries as entry, i}
<div class="flex items-center gap-2">
<input
type="text"
placeholder={$i18n.t('settings.general.keyPlaceholder')}
class="bg-black/[0.04] dark:bg-white/[0.06] text-[12px] text-[#1d1d1f] dark:text-[#fafafa] px-2.5 py-1.5 border-none outline-none rounded-lg opacity-60 flex-1 min-w-0 font-mono"
value={entry.key}
oninput={(e) => { envEntries[i].key = (e.target as HTMLInputElement).value }}
onblur={saveEnvVars}
/>
<span class="text-[11px] opacity-20">=</span>
<input
type="text"
placeholder="value"
class="bg-black/[0.04] dark:bg-white/[0.06] text-[12px] text-[#1d1d1f] dark:text-[#fafafa] px-2.5 py-1.5 border-none outline-none rounded-lg opacity-60 flex-[2] min-w-0 font-mono"
value={entry.value}
oninput={(e) => { envEntries[i].value = (e.target as HTMLInputElement).value }}
onblur={saveEnvVars}
/>
<button
class="opacity-20 hover:opacity-50 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-0.5 shrink-0"
onclick={() => removeEnvVar(i)}
>
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/each}
</div>
{:else}
<div class="text-[11px] opacity-15">{$i18n.t('settings.general.noEnvVars')}</div>
{/if}
</div>
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.general.factoryReset')}</div>
<div class="text-[11px] opacity-25 mt-0.5">
{$i18n.t('settings.general.factoryResetDesc')}
</div>
</div>
<button
class="text-[12px] opacity-40 hover:opacity-70 px-3 py-1.5 bg-black/[0.04] dark:bg-white/[0.06] transition border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl flex items-center gap-1.5 {resetting ? 'pointer-events-none opacity-30' : ''}"
disabled={resetting}
onclick={async () => {
if (
confirm(
$i18n.t('settings.general.factoryResetConfirm')
)
) {
resetting = true
await window.electronAPI.resetApp()
window.location.reload()
}
}}
>
{#if resetting}
<svg class="w-3 h-3 animate-spin" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" stroke-dasharray="31.4 31.4" stroke-linecap="round" />
</svg>
{$i18n.t('common.resetting')}
{:else}
{$i18n.t('common.reset')}
{/if}
</button>
</div>
</div>
{/if}
</div>
</div>
@@ -1,532 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte'
import { config } from '../../../stores'
import i18n from '../../../i18n'
import Switch from '../../common/Switch.svelte'
let lsInfo = $state<{ url?: string; status?: string; pid?: number; binaryPath?: string } | null>(null)
let stopping = $state(false)
let starting = $state(false)
let restarting = $state(false)
let settingUp = $state(false)
let loaded = $state(false)
let setupStatus = $state('')
let uninstalling = $state(false)
let installing = $state(false)
type UpdateStatus = 'idle' | 'checking' | 'available' | 'updating' | 'up-to-date' | 'error'
let updateStatus = $state<UpdateStatus>('idle')
let updateInfo = $state<{ currentVersion: string | null; latestVersion: string | null; updateAvailable: boolean } | null>(null)
let updateError = $state<string | null>(null)
onMount(async () => {
lsInfo = await window.electronAPI.getLlamaCppInfo()
loaded = true
window.electronAPI.onData((data: any) => {
if (data.type === 'status:llamacpp') {
lsInfo = { ...lsInfo, status: data.data }
}
if (data.type === 'status:llamacpp-setup') {
setupStatus = data.data ?? ''
}
if (data.type === 'llamacpp:ready') {
lsInfo = { ...lsInfo, ...data.data, status: 'started' }
}
})
})
const isRunning = $derived(lsInfo?.status === 'started')
const updateConfig = async (key: string, value: any) => {
const current = $config ?? {}
const llamaCpp = { ...(current.llamaCpp ?? {}), [key]: value }
await window.electronAPI.setConfig({ llamaCpp })
config.set(await window.electronAPI.getConfig())
}
const platform = $derived((() => {
const info = navigator.userAgent
if (info.includes('Mac')) return 'darwin'
if (info.includes('Win')) return 'win32'
return 'linux'
})())
const variantOptions = $derived((() => {
const autoOption = { value: 'auto', label: $i18n.t('settings.inference.variantAuto') }
if (platform === 'darwin') return [autoOption, { value: 'cpu', label: $i18n.t('settings.inference.variantDefaultMetal') }]
if (platform === 'win32') return [
autoOption,
{ value: 'cpu', label: $i18n.t('settings.inference.variantCPU') },
{ value: 'cuda-12.4', label: 'CUDA 12.4' },
{ value: 'cuda-13.1', label: 'CUDA 13.1' },
{ value: 'vulkan', label: 'Vulkan' }
]
return [
autoOption,
{ value: 'cpu', label: $i18n.t('settings.inference.variantCPU') },
{ value: 'vulkan', label: 'Vulkan' },
{ value: 'rocm', label: 'ROCm' }
]
})())
const setupServer = async () => {
settingUp = true
setupStatus = ''
try {
await window.electronAPI.setupLlamaCpp()
lsInfo = await window.electronAPI.getLlamaCppInfo()
} catch (e) {
console.error('Failed to setup llama-server:', e)
}
settingUp = false
setupStatus = ''
}
const startServer = async () => {
starting = true
setupStatus = ''
try {
const result = await window.electronAPI.startLlamaCpp()
lsInfo = await window.electronAPI.getLlamaCppInfo()
} catch (e) {
console.error('Failed to start llama-server:', e)
}
starting = false
setupStatus = ''
}
const stopServer = async () => {
stopping = true
try {
await window.electronAPI.stopLlamaCpp()
lsInfo = await window.electronAPI.getLlamaCppInfo()
} catch (e) {
console.error('Failed to stop llama-server:', e)
}
stopping = false
}
const restartServer = async () => {
restarting = true
try {
await window.electronAPI.stopLlamaCpp()
await window.electronAPI.startLlamaCpp()
lsInfo = await window.electronAPI.getLlamaCppInfo()
} catch (e) {
console.error('Failed to restart llama-server:', e)
}
restarting = false
}
const checkUpdate = async () => {
updateStatus = 'checking'
updateError = null
try {
const res = await window.electronAPI.checkLlamaCppUpdate()
updateInfo = res
updateStatus = res.updateAvailable ? 'available' : 'up-to-date'
} catch (e: any) {
updateStatus = 'error'
updateError = e?.message ?? 'Check failed'
}
}
const doUpdate = async () => {
updateStatus = 'updating'
try {
lsInfo = await window.electronAPI.updateLlamaCpp()
updateStatus = 'idle'
updateInfo = null
} catch (e: any) {
updateStatus = 'error'
updateError = e?.message ?? 'Update failed'
}
}
const downloadModel = async () => {
if (!downloadRepo.trim() || !downloadFile.trim()) return
downloading = true
downloadProgress = 0
try {
await window.electronAPI.downloadHfModel(downloadRepo.trim(), downloadFile.trim())
} catch (e) {
console.error('Failed to download model:', e)
downloading = false
}
}
const removeModel = async (repo: string, filename: string) => {
deleting = `${repo}/${filename}`
try {
await window.electronAPI.deleteHfModel(repo, filename)
models = await window.electronAPI.listHfModels()
} catch (e) {
console.error('Failed to delete model:', e)
}
deleting = null
}
const installed = $derived(!!lsInfo?.binaryPath)
const formatSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`
}
</script>
{#if !loaded}
<div class="py-6 text-[12px] opacity-20 text-center">{$i18n.t('common.loading')}</div>
{:else if !installed}
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-40 flex items-center gap-1.5">
{$i18n.t('settings.inference.notInstalled')}
<span class="text-[9px] opacity-30 uppercase tracking-wide">{$i18n.t('common.experimental')}</span>
</div>
<div class="text-[11px] opacity-20 mt-0.5">{$i18n.t('settings.inference.notInstalledDesc')}</div>
</div>
<button
class="text-[12px] opacity-40 hover:opacity-70 px-3 py-1.5 bg-black/[0.04] dark:bg-white/[0.06] transition border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl flex items-center gap-1.5 {installing ? 'pointer-events-none opacity-20' : ''}"
disabled={installing}
onclick={async () => {
installing = true
try {
await window.electronAPI.startLlamaCpp()
lsInfo = await window.electronAPI.getLlamaCppInfo()
} catch (e) {
console.error('Failed to install:', e)
}
installing = false
}}
>
{#if installing}
<div class="w-2.5 h-2.5 rounded-full border-[1.5px] border-black/20 dark:border-white/30 border-t-transparent animate-spin"></div>
{$i18n.t('common.installing')}
{:else}
{$i18n.t('common.install')}
{/if}
</button>
</div>
<!-- Version -->
<div class="py-4 flex items-center justify-between border-t border-white/[0.04]">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.inference.version')}</div>
<div class="text-[11px] opacity-25 mt-0.5">{$i18n.t('settings.inference.versionDesc')}</div>
</div>
<input
type="text"
class="bg-black/[0.04] dark:bg-white/[0.06] text-[12px] text-[#1d1d1f] dark:text-[#fafafa] px-3 py-1.5 border-none outline-none rounded-xl opacity-60 w-24 text-right font-mono"
value={$config?.llamaCpp?.version ?? 'latest'}
onchange={(e) => updateConfig('version', (e.target as HTMLInputElement).value.trim() || 'latest')}
/>
</div>
<!-- Variant -->
<div class="py-4 flex items-center justify-between border-t border-white/[0.04]">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.inference.variant')}</div>
<div class="text-[11px] opacity-25 mt-0.5">{$i18n.t('settings.inference.variantDesc')}</div>
</div>
<select
class="bg-black/[0.04] dark:bg-white/[0.06] text-[12px] text-[#1d1d1f] dark:text-[#fafafa] px-3 py-1.5 border-none outline-none rounded-xl opacity-60"
onchange={(e) => updateConfig('variant', (e.target as HTMLSelectElement).value)}
>
{#each variantOptions as opt}
<option value={opt.value} selected={($config?.llamaCpp?.variant ?? 'auto') === opt.value}>{opt.label}</option>
{/each}
</select>
</div>
{:else}
<div class="flex flex-col divide-y divide-white/[0.04]">
<!-- Server status & controls -->
<div class="py-4">
<div class="flex items-center justify-between mb-3">
<div>
<div class="text-[13px] opacity-70 flex items-center gap-1.5">
{$i18n.t('settings.inference.llamaServer')}
<span class="text-[9px] opacity-30 uppercase tracking-wide">{$i18n.t('common.experimental')}</span>
</div>
<div class="text-[11px] opacity-25 mt-0.5">
{$i18n.t('settings.inference.llamaServerDesc')}
</div>
</div>
<div class="flex items-center gap-1.5">
{#if isRunning}
<div class="w-1.5 h-1.5 rounded-full bg-emerald-400"></div>
<span class="text-[12px] opacity-50">{$i18n.t('common.running')}</span>
{:else if lsInfo?.status === 'starting' || lsInfo?.status === 'setting-up'}
<div class="w-1.5 h-1.5 rounded-full bg-amber-400/60 animate-pulse"></div>
<span class="text-[12px] opacity-30 capitalize">{lsInfo?.status === 'setting-up' ? $i18n.t('settings.inference.settingUp') : $i18n.t('common.starting')}</span>
{:else if lsInfo?.status === 'failed'}
<div class="w-1.5 h-1.5 rounded-full bg-red-400/70"></div>
<span class="text-[12px] opacity-30">{$i18n.t('common.failed')}</span>
{:else}
<div class="w-1.5 h-1.5 rounded-full bg-black/15 dark:bg-white/20"></div>
<span class="text-[12px] opacity-30">{$i18n.t('common.stopped')}</span>
{/if}
</div>
</div>
{#if setupStatus}
<div class="text-[11px] opacity-30 mb-3 font-mono truncate">{setupStatus}</div>
{/if}
<div class="flex items-center gap-2">
{#if isRunning}
<button
class="text-[12px] opacity-40 hover:opacity-70 px-3 py-1.5 bg-black/[0.04] dark:bg-white/[0.06] transition border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl flex items-center gap-1.5 {stopping ? 'pointer-events-none opacity-20' : ''}"
disabled={stopping}
onclick={stopServer}
>
{#if stopping}
<div class="w-2.5 h-2.5 rounded-full border-[1.5px] border-black/20 dark:border-white/30 border-t-transparent animate-spin"></div>
{$i18n.t('common.stopping')}
{:else}
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M5.636 5.636a9 9 0 1012.728 0M12 3v9" />
</svg>
{$i18n.t('common.stop')}
{/if}
</button>
<button
class="text-[12px] opacity-40 hover:opacity-70 px-3 py-1.5 bg-black/[0.04] dark:bg-white/[0.06] transition border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl flex items-center gap-1.5 {restarting ? 'pointer-events-none opacity-20' : ''}"
disabled={restarting}
onclick={restartServer}
>
{#if restarting}
<div class="w-2.5 h-2.5 rounded-full border-[1.5px] border-black/20 dark:border-white/30 border-t-transparent animate-spin"></div>
{$i18n.t('common.restarting')}
{:else}
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M20.015 4.356v4.992m0 0h-4.992m4.993 0l-3.181-3.183a8.25 8.25 0 00-13.803 3.7" />
</svg>
{$i18n.t('common.restart')}
{/if}
</button>
{:else}
<button
class="text-[12px] opacity-40 hover:opacity-70 px-3 py-1.5 bg-black/[0.04] dark:bg-white/[0.06] transition border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl flex items-center gap-1.5 {starting ? 'pointer-events-none opacity-20' : ''}"
disabled={starting}
onclick={startServer}
>
{#if starting}
<div class="w-2.5 h-2.5 rounded-full border-[1.5px] border-black/20 dark:border-white/30 border-t-transparent animate-spin"></div>
{$i18n.t('common.starting')}
{:else}
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 010 1.972l-11.54 6.347a1.125 1.125 0 01-1.667-.986V5.653z" />
</svg>
{$i18n.t('common.start')}
{/if}
</button>
{/if}
<button
class="text-[12px] opacity-40 hover:opacity-70 px-3 py-1.5 bg-black/[0.04] dark:bg-white/[0.06] transition border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl flex items-center gap-1.5 {settingUp ? 'pointer-events-none opacity-20' : ''}"
disabled={settingUp}
onclick={setupServer}
>
{#if settingUp}
<div class="w-2.5 h-2.5 rounded-full border-[1.5px] border-black/20 dark:border-white/30 border-t-transparent animate-spin"></div>
{$i18n.t('common.downloading')}
{:else}
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
{lsInfo?.binaryPath ? $i18n.t('settings.inference.redownload') : $i18n.t('common.download')}
{/if}
</button>
</div>
</div>
<!-- Running Instance Info -->
{#if isRunning && lsInfo}
<div class="py-4">
<div class="text-[13px] opacity-70 mb-3">{$i18n.t('settings.inference.runningInstance')}</div>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="text-[11px] opacity-30">URL</span>
<button class="text-[12px] opacity-50 font-mono hover:opacity-80 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-0 underline decoration-dotted underline-offset-2 cursor-pointer" onclick={() => window.open(lsInfo.url)}>{lsInfo.url}</button>
</div>
<div class="flex items-center justify-between">
<span class="text-[11px] opacity-30">PID</span>
<span class="text-[12px] opacity-50 font-mono">{lsInfo.pid}</span>
</div>
{#if lsInfo.version}
<div class="flex items-center justify-between">
<span class="text-[11px] opacity-30">{$i18n.t('settings.inference.build')}</span>
<span class="text-[12px] opacity-50 font-mono">{lsInfo.version}</span>
</div>
{/if}
</div>
</div>
{/if}
<!-- Update Section -->
<div class="py-4">
<div class="flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.about.softwareUpdate')}</div>
{#if updateStatus === 'up-to-date'}
<div class="text-[11px] opacity-25 mt-0.5">{$i18n.t('settings.inference.upToDate')}</div>
{:else if updateStatus === 'available' && updateInfo?.latestVersion}
<div class="text-[11px] opacity-40 mt-0.5">{$i18n.t('settings.inference.updateAvailable', { version: updateInfo.latestVersion })}</div>
{:else if updateStatus === 'updating'}
<div class="text-[11px] opacity-25 mt-0.5">{$i18n.t('settings.inference.updating')}</div>
{:else if updateStatus === 'error'}
<div class="text-[11px] text-red-400/60 mt-0.5">{updateError}</div>
{/if}
</div>
<div>
{#if updateStatus === 'idle' || updateStatus === 'up-to-date' || updateStatus === 'error'}
<button
class="text-[12px] opacity-40 hover:opacity-70 px-3 py-1.5 bg-black/[0.04] dark:bg-white/[0.06] transition border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl"
onclick={checkUpdate}
>
{$i18n.t('settings.inference.checkForUpdates')}
</button>
{:else if updateStatus === 'checking'}
<button
class="text-[12px] opacity-30 px-3 py-1.5 bg-black/[0.04] dark:bg-white/[0.06] border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl pointer-events-none flex items-center gap-1.5"
disabled
>
<svg class="w-3 h-3 animate-spin" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" stroke-dasharray="31.4 31.4" stroke-linecap="round" />
</svg>
{$i18n.t('settings.inference.checking')}
</button>
{:else if updateStatus === 'available'}
<button
class="text-[12px] opacity-50 hover:opacity-80 px-3 py-1.5 bg-black/[0.06] dark:bg-white/[0.08] transition border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl"
onclick={doUpdate}
>
{$i18n.t('common.update')}
</button>
{:else if updateStatus === 'updating'}
<button
class="text-[12px] opacity-30 px-3 py-1.5 bg-black/[0.04] dark:bg-white/[0.06] border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl pointer-events-none flex items-center gap-1.5"
disabled
>
<div class="w-2.5 h-2.5 rounded-full border-2 border-black/20 dark:border-white/30 border-t-transparent animate-spin"></div>
{$i18n.t('common.updating')}
</button>
{/if}
</div>
</div>
</div>
<!-- Start on Launch -->
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.inference.startOnLaunch')}</div>
<div class="text-[11px] opacity-25 mt-0.5">{$i18n.t('settings.inference.startOnLaunchDesc')}</div>
</div>
<Switch
checked={$config?.llamaCpp?.enabled ?? false}
onToggle={() => updateConfig('enabled', !($config?.llamaCpp?.enabled ?? false))}
/>
</div>
<!-- Version -->
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.inference.version')}</div>
<div class="text-[11px] opacity-25 mt-0.5">{$i18n.t('settings.inference.versionDesc')}</div>
</div>
<input
type="text"
class="bg-black/[0.04] dark:bg-white/[0.06] text-[12px] text-[#1d1d1f] dark:text-[#fafafa] px-3 py-1.5 border-none outline-none rounded-xl opacity-60 w-24 text-right font-mono"
value={$config?.llamaCpp?.version ?? 'latest'}
onchange={(e) => updateConfig('version', (e.target as HTMLInputElement).value.trim() || 'latest')}
/>
</div>
<!-- Variant -->
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.inference.variant')}</div>
<div class="text-[11px] opacity-25 mt-0.5">{$i18n.t('settings.inference.variantDesc')}</div>
</div>
<select
class="bg-black/[0.04] dark:bg-white/[0.06] text-[12px] text-[#1d1d1f] dark:text-[#fafafa] px-3 py-1.5 border-none outline-none rounded-xl opacity-60"
onchange={(e) => updateConfig('variant', (e.target as HTMLSelectElement).value)}
>
{#each variantOptions as opt}
<option value={opt.value} selected={($config?.llamaCpp?.variant ?? 'auto') === opt.value}>{opt.label}</option>
{/each}
</select>
</div>
<!-- Port -->
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.inference.port')}</div>
<div class="text-[11px] opacity-25 mt-0.5">{$i18n.t('settings.inference.portDesc')}</div>
</div>
<input
type="number"
class="bg-black/[0.04] dark:bg-white/[0.06] text-[12px] text-[#1d1d1f] dark:text-[#fafafa] px-3 py-1.5 border-none outline-none rounded-xl opacity-60 w-20 text-right"
value={$config?.llamaCpp?.port ?? 18881}
onchange={(e) => updateConfig('port', parseInt((e.target as HTMLInputElement).value) || 18881)}
/>
</div>
<!-- Extra Arguments -->
<div class="py-4 flex items-center justify-between gap-4">
<div class="shrink-0">
<div class="text-[13px] opacity-70">{$i18n.t('settings.inference.extraArguments')}</div>
<div class="text-[11px] opacity-25 mt-0.5">{$i18n.t('settings.inference.extraArgumentsDesc')}</div>
</div>
<input
type="text"
class="bg-black/[0.04] dark:bg-white/[0.06] text-[12px] text-[#1d1d1f] dark:text-[#fafafa] px-3 py-1.5 border-none outline-none rounded-xl opacity-60 min-w-0 flex-1 max-w-[280px] text-right font-mono"
placeholder={$i18n.t('settings.inference.extraArgumentsPlaceholder')}
value={($config?.llamaCpp?.extraArgs ?? []).join(' ')}
onchange={(e) => {
const val = (e.target as HTMLInputElement).value.trim()
updateConfig('extraArgs', val ? val.split(/\s+/) : [])
}}
/>
</div>
<!-- Uninstall -->
{#if lsInfo?.binaryPath}
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.inference.uninstall')}</div>
<div class="text-[11px] opacity-25 mt-0.5">{$i18n.t('settings.inference.uninstallDesc')}</div>
</div>
<button
class="text-[12px] opacity-40 hover:opacity-70 px-3 py-1.5 bg-black/[0.04] dark:bg-white/[0.06] transition border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl flex items-center gap-1.5 {uninstalling ? 'pointer-events-none opacity-20' : ''}"
disabled={uninstalling}
onclick={async () => {
if (confirm($i18n.t('settings.inference.uninstallConfirm'))) {
uninstalling = true
try {
await window.electronAPI.uninstallLlamaCpp()
lsInfo = await window.electronAPI.getLlamaCppInfo()
} catch (e) {
console.error('Failed to uninstall llama.cpp:', e)
}
uninstalling = false
}
}}
>
{#if uninstalling}
<div class="w-2.5 h-2.5 rounded-full border-[1.5px] border-black/20 dark:border-white/30 border-t-transparent animate-spin"></div>
{$i18n.t('common.uninstalling')}
{:else}
{$i18n.t('common.uninstall')}
{/if}
</button>
</div>
{/if}
</div>
{/if}
@@ -1,370 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte'
import i18n from '../../../i18n'
interface HfModel {
repo: string
filename: string
filepath: string
size: number
downloadedAt: string
}
interface HfRepoResult {
id: string
author: string
modelId: string
downloads: number
likes: number
}
interface HfFileInfo {
filename: string
size: number
}
// State
let models = $state<HfModel[]>([])
let loaded = $state(false)
let deleting = $state<string | null>(null)
let searchError = $state('')
let modelsDir = $state('')
// Search state
let searchQuery = $state('')
let searchResults = $state<HfRepoResult[]>([])
let searching = $state(false)
let searchTimer: ReturnType<typeof setTimeout> | null = null
// Repo browser state
let selectedRepo = $state<string | null>(null)
let repoFiles = $state<HfFileInfo[]>([])
let loadingFiles = $state(false)
// Download state — track active download in the "Downloaded" section
let activeDownload = $state<{ repo: string; filename: string; percent: number } | null>(null)
onMount(async () => {
models = await window.electronAPI.listHfModels()
modelsDir = await window.electronAPI.getHfModelsDir() || ''
loaded = true
window.electronAPI.onData((data: any) => {
if (data.type === 'status:huggingface-download') {
const d = data.data
if (d?.status === 'downloading') {
activeDownload = { repo: d.repo, filename: d.filename, percent: d.percent ?? 0 }
}
if (d?.status === 'done') {
activeDownload = null
window.electronAPI.listHfModels().then((m: HfModel[]) => { models = m })
}
if (d?.status === 'failed') {
activeDownload = null
}
}
})
})
const onSearchInput = (e: Event) => {
const q = (e.target as HTMLInputElement).value
searchQuery = q
searchError = ''
if (searchTimer) clearTimeout(searchTimer)
if (!q.trim()) {
searchResults = []
searching = false
return
}
searching = true
searchTimer = setTimeout(async () => {
try {
searchResults = await window.electronAPI.searchHfModels(q.trim())
} catch (e: any) {
console.error('Search failed:', e)
searchError = e?.message ?? 'Search failed'
searchResults = []
}
searching = false
}, 400)
}
const selectRepo = async (repoId: string) => {
selectedRepo = repoId
loadingFiles = true
repoFiles = []
try {
repoFiles = await window.electronAPI.getHfRepoFiles(repoId)
} catch (e) {
console.error('Failed to load files:', e)
}
loadingFiles = false
}
const backToSearch = () => {
selectedRepo = null
repoFiles = []
}
const startDownload = async (repo: string, filename: string, size?: number) => {
activeDownload = { repo, filename, percent: 0 }
try {
await window.electronAPI.downloadHfModel(repo, filename, undefined, size)
} catch (e) {
console.error('Failed to download model:', e)
activeDownload = null
}
}
const cancelDownload = async () => {
try {
await window.electronAPI.cancelHfDownload()
} catch (e) {
console.error('Failed to cancel download:', e)
}
activeDownload = null
}
const removeModel = async (repo: string, filename: string) => {
deleting = `${repo}/${filename}`
try {
await window.electronAPI.deleteHfModel(repo, filename)
models = await window.electronAPI.listHfModels()
} catch (e) {
console.error('Failed to delete model:', e)
}
deleting = null
}
const isDownloaded = (repo: string, filename: string): boolean => {
return models.some((m) => m.repo === repo && m.filename === filename)
}
const isDownloading = (repo: string, filename: string): boolean => {
return activeDownload?.repo === repo && activeDownload?.filename === filename
}
const formatSize = (bytes: number): string => {
if (!bytes) return ''
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`
}
const formatDownloads = (n: number): string => {
if (n < 1000) return `${n}`
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`
return `${(n / 1_000_000).toFixed(1)}M`
}
</script>
{#if !loaded}
<div class="py-6 text-[12px] opacity-20 text-center">{$i18n.t('common.loading')}</div>
{:else}
<div class="flex flex-col divide-y divide-white/[0.04]">
<!-- Models directory -->
<div class="py-4 flex items-center justify-between gap-4">
<div class="shrink-0">
<div class="text-[13px] opacity-70">{$i18n.t('settings.models.modelsDirectory')}</div>
<div class="text-[11px] opacity-25 mt-0.5">{$i18n.t('settings.models.modelsHint')}</div>
</div>
<button class="text-[12px] font-mono hover:opacity-80 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-0 underline decoration-dotted underline-offset-2 cursor-pointer flex items-center gap-1.5 min-w-0 truncate" onclick={() => { if (modelsDir) window.electronAPI.openPath(modelsDir) }}>
<span class="truncate">{modelsDir || '…'}</span>
<svg class="w-3 h-3 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
</button>
</div>
<!-- Downloaded models + active download -->
<div class="py-4">
<div class="text-[12px] opacity-50 mb-2">{$i18n.t('settings.models.downloadedModels')}</div>
{#if models.length > 0 || activeDownload}
<div class="flex flex-col gap-1.5">
<!-- Active download in progress -->
{#if activeDownload}
<div class="px-2.5 py-2 bg-black/[0.03] dark:bg-white/[0.04] rounded-xl">
<div class="flex items-center justify-between gap-2 mb-1.5">
<div class="min-w-0 flex-1">
<div class="text-[12px] opacity-60 truncate font-mono">{activeDownload.filename}</div>
<div class="text-[10px] opacity-25 truncate">{activeDownload.repo} · {$i18n.t('common.downloading')}</div>
</div>
<button
class="opacity-30 hover:opacity-70 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-1 shrink-0"
onclick={cancelDownload}
title={$i18n.t('settings.models.cancelDownload')}
>
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="w-full h-1 bg-black/[0.06] dark:bg-white/[0.06] rounded-full overflow-hidden">
<div
class="h-full bg-emerald-400/80 rounded-full"
style="width: {activeDownload.percent}%"
></div>
</div>
<div class="text-[10px] opacity-25 mt-1 text-right font-mono">{activeDownload.percent.toFixed(1)}%</div>
</div>
{/if}
<!-- Completed downloads -->
{#each models as model}
<div class="flex items-center justify-between gap-2 px-2.5 py-2 bg-black/[0.03] dark:bg-white/[0.04] rounded-xl">
<div class="min-w-0 flex-1">
<div class="text-[12px] opacity-60 truncate font-mono">{model.filename}</div>
<div class="text-[10px] opacity-25 truncate">{model.repo} · {formatSize(model.size)}</div>
</div>
<button
class="opacity-20 hover:opacity-60 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-1 shrink-0 {deleting === `${model.repo}/${model.filename}` ? 'pointer-events-none' : ''}"
onclick={() => removeModel(model.repo, model.filename)}
title={$i18n.t('settings.models.deleteModel')}
>
{#if deleting === `${model.repo}/${model.filename}`}
<div class="w-3 h-3 rounded-full border-[1.5px] border-black/20 dark:border-white/30 border-t-transparent animate-spin"></div>
{:else}
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
{/if}
</button>
</div>
{/each}
</div>
{:else}
<div class="text-[11px] opacity-40 py-3">{$i18n.t('settings.models.noModels')}</div>
{/if}
</div>
<!-- Download from HF -->
<div class="py-4">
<div class="text-[12px] opacity-50 mb-2">
{#if selectedRepo}
<button
class="opacity-50 hover:opacity-80 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-0 text-[12px] flex items-center gap-1"
onclick={backToSearch}
>
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
{$i18n.t('common.back')}
</button>
{:else}
{$i18n.t('settings.models.downloadFromHF')}
{/if}
</div>
{#if selectedRepo}
<!-- Repo file browser -->
<div class="mb-2">
<div class="text-[12px] opacity-60 font-mono truncate mb-2">{selectedRepo}</div>
</div>
{#if loadingFiles}
<div class="flex items-center gap-2 py-3 justify-center">
<div class="w-3 h-3 rounded-full border-[1.5px] border-black/20 dark:border-white/30 border-t-transparent animate-spin"></div>
<span class="text-[11px] opacity-30">{$i18n.t('settings.models.loadingFiles')}</span>
</div>
{:else if repoFiles.length === 0}
<div class="text-[11px] opacity-20 text-center py-3">{$i18n.t('settings.models.noGgufFiles')}</div>
{:else}
<div class="flex flex-col gap-1">
{#each repoFiles as file}
{@const downloaded = isDownloaded(selectedRepo, file.filename)}
{@const dlActive = isDownloading(selectedRepo, file.filename)}
<div class="flex items-center justify-between gap-2 px-2.5 py-2 bg-black/[0.03] dark:bg-white/[0.04] rounded-xl">
<div class="min-w-0 flex-1">
<div class="text-[12px] opacity-50 truncate font-mono">{file.filename}</div>
<div class="text-[10px] opacity-25">{formatSize(file.size)}</div>
</div>
{#if downloaded}
<span class="text-[10px] opacity-30 shrink-0 px-2">{$i18n.t('settings.models.downloaded')}</span>
{:else if dlActive}
<div class="flex items-center gap-1.5 shrink-0">
<div class="w-2.5 h-2.5 rounded-full border-[1.5px] border-black/20 dark:border-white/30 border-t-transparent animate-spin"></div>
<span class="text-[10px] opacity-40 font-mono">{activeDownload?.percent?.toFixed(0) ?? 0}%</span>
</div>
{:else}
<button
class="opacity-30 hover:opacity-70 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-1 shrink-0 {activeDownload ? 'pointer-events-none opacity-10' : ''}"
onclick={() => startDownload(selectedRepo, file.filename, file.size)}
disabled={!!activeDownload}
title={$i18n.t('common.download')}
>
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
</button>
{/if}
</div>
{/each}
</div>
{/if}
{:else}
<!-- Search -->
<div class="relative mb-2">
<svg class="w-3.5 h-3.5 absolute left-2.5 top-1/2 -translate-y-1/2 opacity-25 pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
</svg>
<input
type="text"
class="bg-black/[0.04] dark:bg-white/[0.06] text-[12px] text-[#1d1d1f] dark:text-[#fafafa] pl-8 pr-3 py-2 border-none outline-none rounded-xl opacity-70 w-full"
placeholder={$i18n.t('settings.models.searchPlaceholder')}
value={searchQuery}
oninput={onSearchInput}
/>
{#if searching}
<div class="w-3 h-3 rounded-full border-[1.5px] border-black/20 dark:border-white/30 border-t-transparent animate-spin absolute right-2.5 top-1/2 -translate-y-1/2"></div>
{/if}
</div>
{#if searchError}
<div class="text-[11px] text-red-400/70 text-center py-2">{searchError}</div>
{:else if searchResults.length > 0}
<div class="flex flex-col gap-1 max-h-[300px] overflow-y-auto">
{#each searchResults as repo}
<button
class="flex items-center justify-between gap-2 px-2.5 py-2 bg-black/[0.03] dark:bg-white/[0.04] hover:bg-black/[0.06] dark:hover:bg-white/[0.08] rounded-xl transition border-none text-left w-full text-[#1d1d1f] dark:text-[#fafafa]"
onclick={() => selectRepo(repo.id)}
>
<div class="min-w-0 flex-1">
<div class="text-[12px] opacity-60 truncate">{repo.id}</div>
<div class="text-[10px] opacity-25 flex items-center gap-2 mt-0.5">
<span class="flex items-center gap-0.5">
<svg class="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
{formatDownloads(repo.downloads)}
</span>
<span class="flex items-center gap-0.5">
<svg class="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z" />
</svg>
{repo.likes}
</span>
</div>
</div>
<svg class="w-3 h-3 opacity-20 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</button>
{/each}
</div>
{:else if searchQuery.trim() && !searching}
<div class="text-[11px] opacity-20 text-center py-3">{$i18n.t('settings.models.noReposFound')}</div>
{:else if !searchQuery.trim()}
<div class="text-[11px] opacity-20 text-center py-3">{$i18n.t('settings.models.searchForModels')}</div>
{/if}
{/if}
</div>
</div>
{/if}
@@ -1,363 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte'
import { config } from '../../../stores'
import i18n from '../../../i18n'
import Switch from '../../common/Switch.svelte'
let otInfo = $state<{ url?: string; apiKey?: string; status?: string; pid?: number } | null>(null)
let otApiKeyCopied = $state(false)
let version = $state<string | null>(null)
let stopping = $state(false)
let starting = $state(false)
let restarting = $state(false)
let updating = $state(false)
let loaded = $state(false)
let uninstalling = $state(false)
let installing = $state(false)
onMount(async () => {
otInfo = await window.electronAPI.getOpenTerminalInfo()
version = await window.electronAPI.getPackageVersion('open-terminal')
loaded = true
})
const installed = $derived(version !== null)
const isRunning = $derived(otInfo?.status === 'started')
const updateOtConfig = async (key: string, value: any) => {
const current = $config ?? {}
const openTerminal = { ...(current.openTerminal ?? {}), [key]: value }
await window.electronAPI.setConfig({ openTerminal })
config.set(await window.electronAPI.getConfig())
}
const stopTerminal = async () => {
stopping = true
try {
await window.electronAPI.stopOpenTerminal()
otInfo = await window.electronAPI.getOpenTerminalInfo()
} catch (e) {
console.error('Failed to stop Open Terminal:', e)
}
stopping = false
}
const startTerminal = async () => {
starting = true
try {
await window.electronAPI.startOpenTerminal()
otInfo = await window.electronAPI.getOpenTerminalInfo()
} catch (e) {
console.error('Failed to start Open Terminal:', e)
}
starting = false
}
const restartTerminal = async () => {
restarting = true
try {
await window.electronAPI.stopOpenTerminal()
await window.electronAPI.startOpenTerminal()
otInfo = await window.electronAPI.getOpenTerminalInfo()
} catch (e) {
console.error('Failed to restart Open Terminal:', e)
}
restarting = false
}
const updatePackage = async () => {
updating = true
try {
if (isRunning) {
await window.electronAPI.stopOpenTerminal()
}
// Reinstall to get latest
await window.electronAPI.startOpenTerminal()
otInfo = await window.electronAPI.getOpenTerminalInfo()
version = await window.electronAPI.getPackageVersion('open-terminal')
} catch (e) {
console.error('Failed to update Open Terminal:', e)
}
updating = false
}
const copyOtApiKey = async () => {
if (otInfo?.apiKey) {
await navigator.clipboard.writeText(otInfo.apiKey)
otApiKeyCopied = true
setTimeout(() => { otApiKeyCopied = false }, 2000)
}
}
</script>
{#if !loaded}
<div class="py-6 text-[12px] opacity-20 text-center">{$i18n.t('common.loading')}</div>
{:else if !installed}
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-40">{$i18n.t('settings.terminal.notInstalled')}</div>
<div class="text-[11px] opacity-20 mt-0.5">{$i18n.t('settings.terminal.notInstalledDesc')}</div>
</div>
<button
class="text-[12px] opacity-40 hover:opacity-70 px-3 py-1.5 bg-black/[0.04] dark:bg-white/[0.06] transition border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl flex items-center gap-1.5 {installing ? 'pointer-events-none opacity-20' : ''}"
disabled={installing}
onclick={async () => {
installing = true
try {
await window.electronAPI.startOpenTerminal()
otInfo = await window.electronAPI.getOpenTerminalInfo()
version = await window.electronAPI.getPackageVersion('open-terminal')
} catch (e) {
console.error('Failed to install:', e)
}
installing = false
}}
>
{#if installing}
<div class="w-2.5 h-2.5 rounded-full border-[1.5px] border-black/20 dark:border-white/30 border-t-transparent animate-spin"></div>
{$i18n.t('common.installing')}
{:else}
{$i18n.t('common.install')}
{/if}
</button>
</div>
{:else}
<div class="flex flex-col divide-y divide-white/[0.04]">
<!-- Server status & controls -->
<div class="py-4">
<div class="flex items-center justify-between mb-3">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.terminal.server')}</div>
<div class="text-[11px] opacity-25 mt-0.5">
{#if version}v{version} · {/if}{$i18n.t('settings.terminal.instance')}
</div>
</div>
<div class="flex items-center gap-1.5">
{#if isRunning}
<div class="w-1.5 h-1.5 rounded-full bg-emerald-400"></div>
<span class="text-[12px] opacity-50">{$i18n.t('common.running')}</span>
{:else if otInfo?.status === 'stopped' || !otInfo?.status}
<div class="w-1.5 h-1.5 rounded-full bg-black/15 dark:bg-white/20"></div>
<span class="text-[12px] opacity-30">{$i18n.t('common.stopped')}</span>
{:else}
<div class="w-1.5 h-1.5 rounded-full bg-amber-400/60"></div>
<span class="text-[12px] opacity-30 capitalize">{otInfo?.status}</span>
{/if}
</div>
</div>
<div class="flex items-center gap-2">
{#if isRunning}
<button
class="text-[12px] opacity-40 hover:opacity-70 px-3 py-1.5 bg-black/[0.04] dark:bg-white/[0.06] transition border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl flex items-center gap-1.5 {stopping ? 'pointer-events-none opacity-20' : ''}"
disabled={stopping}
onclick={stopTerminal}
>
{#if stopping}
<div class="w-2.5 h-2.5 rounded-full border-[1.5px] border-black/20 dark:border-white/30 border-t-transparent animate-spin"></div>
{$i18n.t('common.stopping')}
{:else}
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M5.636 5.636a9 9 0 1012.728 0M12 3v9" />
</svg>
{$i18n.t('common.stop')}
{/if}
</button>
<button
class="text-[12px] opacity-40 hover:opacity-70 px-3 py-1.5 bg-black/[0.04] dark:bg-white/[0.06] transition border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl flex items-center gap-1.5 {restarting ? 'pointer-events-none opacity-20' : ''}"
disabled={restarting}
onclick={restartTerminal}
>
{#if restarting}
<div class="w-2.5 h-2.5 rounded-full border-[1.5px] border-black/20 dark:border-white/30 border-t-transparent animate-spin"></div>
{$i18n.t('common.restarting')}
{:else}
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M20.015 4.356v4.992m0 0h-4.992m4.993 0l-3.181-3.183a8.25 8.25 0 00-13.803 3.7" />
</svg>
{$i18n.t('common.restart')}
{/if}
</button>
{:else}
<button
class="text-[12px] opacity-40 hover:opacity-70 px-3 py-1.5 bg-black/[0.04] dark:bg-white/[0.06] transition border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl flex items-center gap-1.5 {starting ? 'pointer-events-none opacity-20' : ''}"
disabled={starting}
onclick={startTerminal}
>
{#if starting}
<div class="w-2.5 h-2.5 rounded-full border-[1.5px] border-black/20 dark:border-white/30 border-t-transparent animate-spin"></div>
{$i18n.t('common.starting')}
{:else}
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 010 1.972l-11.54 6.347a1.125 1.125 0 01-1.667-.986V5.653z" />
</svg>
{$i18n.t('common.start')}
{/if}
</button>
{/if}
<button
class="text-[12px] opacity-40 hover:opacity-70 px-3 py-1.5 bg-black/[0.04] dark:bg-white/[0.06] transition border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl flex items-center gap-1.5 {updating ? 'pointer-events-none opacity-20' : ''}"
disabled={updating}
onclick={updatePackage}
>
{#if updating}
<div class="w-2.5 h-2.5 rounded-full border-[1.5px] border-black/20 dark:border-white/30 border-t-transparent animate-spin"></div>
{$i18n.t('common.updating')}
{:else}
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
{$i18n.t('common.update')}
{/if}
</button>
</div>
</div>
<!-- Running Instance Info -->
{#if isRunning && otInfo}
<div class="py-4">
<div class="text-[13px] opacity-70 mb-3">{$i18n.t('settings.terminal.runningInstance')}</div>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="text-[11px] opacity-30">URL</span>
<button class="text-[12px] opacity-50 font-mono hover:opacity-80 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-0 underline decoration-dotted underline-offset-2 cursor-pointer" onclick={() => window.open(otInfo.url)}>{otInfo.url}</button>
</div>
{#if otInfo.pid}
<div class="flex items-center justify-between">
<span class="text-[11px] opacity-30">PID</span>
<span class="text-[12px] opacity-50 font-mono">{otInfo.pid}</span>
</div>
{/if}
</div>
</div>
{/if}
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.terminal.startOnLaunch')}</div>
<div class="text-[11px] opacity-25 mt-0.5">
{$i18n.t('settings.terminal.startOnLaunchDesc')}
</div>
</div>
<Switch
checked={$config?.openTerminal?.enabled ?? false}
label={$i18n.t('settings.terminal.toggleStartOnLaunch')}
onchange={(value) => updateOtConfig('enabled', value)}
/>
</div>
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.terminal.port')}</div>
<div class="text-[11px] opacity-25 mt-0.5">{$i18n.t('settings.terminal.portDesc')}</div>
</div>
<input
type="number"
class="bg-black/[0.04] dark:bg-white/[0.06] text-[12px] text-[#1d1d1f] dark:text-[#fafafa] px-3 py-1.5 border-none outline-none rounded-xl opacity-60 w-20 text-right"
value={$config?.openTerminal?.port ?? 8000}
onchange={(e) =>
updateOtConfig('port', parseInt((e.target as HTMLInputElement).value) || 8000)}
/>
</div>
<div class="py-4 flex items-center justify-between gap-4">
<div class="shrink-0">
<div class="text-[13px] opacity-70">{$i18n.t('settings.terminal.workingDirectory')}</div>
<div class="text-[11px] opacity-25 mt-0.5">{$i18n.t('settings.terminal.workingDirectoryDesc')}</div>
</div>
<div class="flex items-center gap-1.5 min-w-0 flex-1 max-w-[280px] justify-end">
<input
type="text"
class="bg-black/[0.04] dark:bg-white/[0.06] text-[12px] text-[#1d1d1f] dark:text-[#fafafa] px-3 py-1.5 border-none outline-none rounded-xl opacity-60 min-w-0 flex-1 text-right font-mono"
placeholder={$i18n.t('settings.terminal.workingDirectoryPlaceholder')}
value={$config?.openTerminal?.cwd ?? ''}
onchange={(e) =>
updateOtConfig('cwd', (e.target as HTMLInputElement).value.trim())}
/>
<button
class="shrink-0 text-[12px] opacity-40 hover:opacity-70 px-2.5 py-1.5 bg-black/[0.04] dark:bg-white/[0.06] transition border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl"
onclick={async () => {
const folder = await window.electronAPI.selectFolder()
if (folder) updateOtConfig('cwd', folder)
}}
>
{$i18n.t('common.browse')}
</button>
</div>
</div>
{#if isRunning && otInfo}
<div class="py-4">
<div class="text-[13px] opacity-70 mb-3">{$i18n.t('settings.terminal.runningInstance')}</div>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="text-[11px] opacity-30">URL</span>
<button class="text-[12px] opacity-50 font-mono hover:opacity-80 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-0 underline decoration-dotted underline-offset-2 cursor-pointer" onclick={() => window.open(otInfo.url)}>{otInfo.url}</button>
</div>
<div class="flex items-center justify-between">
<span class="text-[11px] opacity-30">API Key</span>
<div class="flex items-center gap-1.5">
<span class="text-[12px] opacity-50 font-mono">{otInfo.apiKey?.slice(0, 12)}</span>
<button
class="text-[10px] opacity-30 hover:opacity-60 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa]"
onclick={copyOtApiKey}
>
{otApiKeyCopied ? $i18n.t('common.copied') : $i18n.t('common.copy')}
</button>
</div>
</div>
</div>
</div>
{/if}
<!-- Version Pin -->
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.terminal.version')}</div>
<div class="text-[11px] opacity-25 mt-0.5">{$i18n.t('settings.terminal.versionDesc')}</div>
</div>
<input
type="text"
class="bg-black/[0.04] dark:bg-white/[0.06] text-[12px] text-[#1d1d1f] dark:text-[#fafafa] px-3 py-1.5 border-none outline-none rounded-xl opacity-60 w-28 text-right font-mono"
placeholder="latest"
value={$config?.openTerminal?.version ?? ''}
onchange={(e) => updateOtConfig('version', (e.target as HTMLInputElement).value.trim())}
/>
</div>
<!-- Uninstall -->
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.terminal.uninstall')}</div>
<div class="text-[11px] opacity-25 mt-0.5">{$i18n.t('settings.terminal.uninstallDesc')}</div>
</div>
<button
class="text-[12px] opacity-40 hover:opacity-70 px-3 py-1.5 bg-black/[0.04] dark:bg-white/[0.06] transition border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl flex items-center gap-1.5 {uninstalling ? 'pointer-events-none opacity-20' : ''}"
disabled={uninstalling}
onclick={async () => {
if (confirm($i18n.t('settings.terminal.uninstallConfirm'))) {
uninstalling = true
try {
if (isRunning) await window.electronAPI.stopOpenTerminal()
await window.electronAPI.uninstallPackage('open-terminal')
version = null
otInfo = null
} catch (e) {
console.error('Failed to uninstall:', e)
}
uninstalling = false
}
}}
>
{#if uninstalling}
<div class="w-2.5 h-2.5 rounded-full border-[1.5px] border-black/20 dark:border-white/30 border-t-transparent animate-spin"></div>
{$i18n.t('common.uninstalling')}
{:else}
{$i18n.t('common.uninstall')}
{/if}
</button>
</div>
</div>
{/if}
@@ -1,370 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte'
import { config, serverInfo } from '../../../stores'
import i18n from '../../../i18n'
import Switch from '../../common/Switch.svelte'
let serverStatus = $state<string | null>(null)
let updating = $state(false)
let stopping = $state(false)
let starting = $state(false)
let restarting = $state(false)
let version = $state<string | null>(null)
let serverPid = $state<number | null>(null)
let loaded = $state(false)
let defaultDataPath = $state('')
onMount(async () => {
const info = await window.electronAPI.getServerInfo()
serverStatus = info?.status ?? null
serverPid = info?.pid ?? null
version = await window.electronAPI.getPackageVersion('open-webui')
defaultDataPath = await window.electronAPI.getDefaultDataPath()
loaded = true
})
const installed = $derived(version !== null)
const isRunning = $derived(
serverStatus === 'running' || $serverInfo?.reachable === true
)
let uninstalling = $state(false)
let installing = $state(false)
const updateConfig = async (key: string, value: any) => {
const current = $config ?? {}
const localServer = { ...(current.localServer ?? {}), [key]: value }
await window.electronAPI.setConfig({ localServer })
config.set(await window.electronAPI.getConfig())
}
const stopServer = async () => {
stopping = true
try {
await window.electronAPI.stopServer()
serverStatus = 'stopped'
} catch (e) {
console.error('Failed to stop server:', e)
}
stopping = false
}
const startServer = async () => {
starting = true
try {
await window.electronAPI.startServer()
serverStatus = 'running'
} catch (e) {
console.error('Failed to start server:', e)
}
starting = false
}
const restartServer = async () => {
restarting = true
try {
await window.electronAPI.restartServer()
serverStatus = 'running'
} catch (e) {
console.error('Failed to restart server:', e)
}
restarting = false
}
const updatePackage = async () => {
updating = true
try {
if (isRunning) {
await window.electronAPI.stopServer()
serverStatus = 'stopped'
}
await window.electronAPI.installPackage()
await window.electronAPI.startServer()
serverStatus = 'running'
version = await window.electronAPI.getPackageVersion('open-webui')
} catch (e) {
console.error('Failed to update:', e)
}
updating = false
}
</script>
{#if !loaded}
<div class="py-6 text-[12px] opacity-20 text-center">{$i18n.t('common.loading')}</div>
{:else if !installed}
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-40">{$i18n.t('settings.openwebui.notInstalled')}</div>
<div class="text-[11px] opacity-20 mt-0.5">{$i18n.t('settings.openwebui.notInstalledDesc')}</div>
</div>
<button
class="text-[12px] opacity-40 hover:opacity-70 px-3 py-1.5 bg-black/[0.04] dark:bg-white/[0.06] transition border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl flex items-center gap-1.5 {installing ? 'pointer-events-none opacity-20' : ''}"
disabled={installing}
onclick={async () => {
installing = true
try {
await window.electronAPI.installPackage()
version = await window.electronAPI.getPackageVersion('open-webui')
} catch (e) {
console.error('Failed to install:', e)
}
installing = false
}}
>
{#if installing}
<div class="w-2.5 h-2.5 rounded-full border-[1.5px] border-black/20 dark:border-white/30 border-t-transparent animate-spin"></div>
{$i18n.t('common.installing')}
{:else}
{$i18n.t('common.install')}
{/if}
</button>
</div>
{:else}
<div class="flex flex-col divide-y divide-white/[0.04]">
<!-- Server status & controls -->
<div class="py-4">
<div class="flex items-center justify-between mb-3">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.openwebui.server')}</div>
<div class="text-[11px] opacity-25 mt-0.5">
{#if version}v{version} · {/if}{$i18n.t('settings.openwebui.localInstance')}
</div>
</div>
<div class="flex items-center gap-1.5">
{#if isRunning}
<div class="w-1.5 h-1.5 rounded-full bg-emerald-400"></div>
<span class="text-[12px] opacity-50">{$i18n.t('common.running')}</span>
{:else if serverStatus === 'stopped'}
<div class="w-1.5 h-1.5 rounded-full bg-black/15 dark:bg-white/20"></div>
<span class="text-[12px] opacity-30">{$i18n.t('common.stopped')}</span>
{:else}
<div class="w-1.5 h-1.5 rounded-full bg-amber-400/60"></div>
<span class="text-[12px] opacity-30 capitalize">{serverStatus ?? $i18n.t('common.unknown')}</span>
{/if}
</div>
</div>
<div class="flex items-center gap-2">
{#if isRunning}
<button
class="text-[12px] opacity-40 hover:opacity-70 px-3 py-1.5 bg-black/[0.04] dark:bg-white/[0.06] transition border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl flex items-center gap-1.5 {stopping ? 'pointer-events-none opacity-20' : ''}"
disabled={stopping}
onclick={stopServer}
>
{#if stopping}
<div class="w-2.5 h-2.5 rounded-full border-[1.5px] border-black/20 dark:border-white/30 border-t-transparent animate-spin"></div>
{$i18n.t('common.stopping')}
{:else}
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M5.636 5.636a9 9 0 1012.728 0M12 3v9" />
</svg>
{$i18n.t('common.stop')}
{/if}
</button>
<button
class="text-[12px] opacity-40 hover:opacity-70 px-3 py-1.5 bg-black/[0.04] dark:bg-white/[0.06] transition border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl flex items-center gap-1.5 {restarting ? 'pointer-events-none opacity-20' : ''}"
disabled={restarting}
onclick={restartServer}
>
{#if restarting}
<div class="w-2.5 h-2.5 rounded-full border-[1.5px] border-black/20 dark:border-white/30 border-t-transparent animate-spin"></div>
{$i18n.t('common.restarting')}
{:else}
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M20.015 4.356v4.992m0 0h-4.992m4.993 0l-3.181-3.183a8.25 8.25 0 00-13.803 3.7" />
</svg>
{$i18n.t('common.restart')}
{/if}
</button>
{:else}
<button
class="text-[12px] opacity-40 hover:opacity-70 px-3 py-1.5 bg-black/[0.04] dark:bg-white/[0.06] transition border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl flex items-center gap-1.5 {starting ? 'pointer-events-none opacity-20' : ''}"
disabled={starting}
onclick={startServer}
>
{#if starting}
<div class="w-2.5 h-2.5 rounded-full border-[1.5px] border-black/20 dark:border-white/30 border-t-transparent animate-spin"></div>
{$i18n.t('common.starting')}
{:else}
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 010 1.972l-11.54 6.347a1.125 1.125 0 01-1.667-.986V5.653z" />
</svg>
{$i18n.t('common.start')}
{/if}
</button>
{/if}
<button
class="text-[12px] opacity-40 hover:opacity-70 px-3 py-1.5 bg-black/[0.04] dark:bg-white/[0.06] transition border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl flex items-center gap-1.5 {updating ? 'pointer-events-none opacity-20' : ''}"
disabled={updating}
onclick={updatePackage}
>
{#if updating}
<div class="w-2.5 h-2.5 rounded-full border-[1.5px] border-black/20 dark:border-white/30 border-t-transparent animate-spin"></div>
{$i18n.t('common.updating')}
{:else}
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
{$i18n.t('common.update')}
{/if}
</button>
</div>
</div>
<!-- Running Instance Info -->
{#if isRunning}
<div class="py-4">
<div class="text-[13px] opacity-70 mb-3">{$i18n.t('settings.openwebui.runningInstance')}</div>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="text-[11px] opacity-30">URL</span>
<button class="text-[12px] opacity-50 font-mono hover:opacity-80 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-0 underline decoration-dotted underline-offset-2 cursor-pointer" onclick={() => window.open($serverInfo?.url ?? 'http://127.0.0.1:8080')}>{$serverInfo?.url ?? 'http://127.0.0.1:8080'}</button>
</div>
{#if serverPid}
<div class="flex items-center justify-between">
<span class="text-[11px] opacity-30">PID</span>
<span class="text-[12px] opacity-50 font-mono">{serverPid}</span>
</div>
{/if}
</div>
</div>
{/if}
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.openwebui.startOnLaunch')}</div>
<div class="text-[11px] opacity-25 mt-0.5">
{$i18n.t('settings.openwebui.startOnLaunchDesc')}
</div>
</div>
<Switch
checked={$config?.localServer?.enabled !== false}
label={$i18n.t('settings.openwebui.toggleStartOnLaunch')}
onchange={(value) => updateConfig('enabled', value)}
/>
</div>
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.openwebui.serverPort')}</div>
<div class="text-[11px] opacity-25 mt-0.5">{$i18n.t('settings.openwebui.serverPortDesc')}</div>
</div>
<input
type="number"
class="bg-black/[0.04] dark:bg-white/[0.06] text-[12px] text-[#1d1d1f] dark:text-[#fafafa] px-3 py-1.5 border-none outline-none rounded-xl opacity-60 w-20 text-right"
value={$config?.localServer?.port ?? 8080}
onchange={(e) =>
updateConfig('port', parseInt((e.target as HTMLInputElement).value) || 8080)}
/>
</div>
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.openwebui.serveOnLocalNetwork')}</div>
<div class="text-[11px] opacity-25 mt-0.5">
{$i18n.t('settings.openwebui.serveOnLocalNetworkDesc')}
</div>
</div>
<Switch
checked={$config?.localServer?.serveOnLocalNetwork ?? false}
label={$i18n.t('settings.openwebui.toggleServeOnLocalNetwork')}
onchange={(value) => updateConfig('serveOnLocalNetwork', value)}
/>
</div>
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.openwebui.autoUpdate')}</div>
<div class="text-[11px] opacity-25 mt-0.5">
{$i18n.t('settings.openwebui.autoUpdateDesc')}
</div>
</div>
<Switch
checked={$config?.localServer?.autoUpdate !== false}
label={$i18n.t('settings.openwebui.toggleAutoUpdate')}
onchange={(value) => updateConfig('autoUpdate', value)}
/>
</div>
<!-- Version Pin -->
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.openwebui.version')}</div>
<div class="text-[11px] opacity-25 mt-0.5">{$i18n.t('settings.openwebui.versionDesc')}</div>
</div>
<input
type="text"
class="bg-black/[0.04] dark:bg-white/[0.06] text-[12px] text-[#1d1d1f] dark:text-[#fafafa] px-3 py-1.5 border-none outline-none rounded-xl opacity-60 w-28 text-right font-mono"
placeholder="latest"
value={$config?.localServer?.version ?? ''}
onchange={(e) => updateConfig('version', (e.target as HTMLInputElement).value.trim())}
/>
</div>
<div class="py-4 flex items-center justify-between gap-4">
<div class="shrink-0">
<div class="text-[13px] opacity-70">{$i18n.t('settings.openwebui.dataLocation')}</div>
<div class="text-[11px] opacity-25 mt-0.5">{$i18n.t('settings.openwebui.dataLocationDesc')}</div>
</div>
<div class="flex items-center gap-1.5 min-w-0 flex-1 max-w-[280px] justify-end">
<input
type="text"
class="bg-black/[0.04] dark:bg-white/[0.06] text-[12px] text-[#1d1d1f] dark:text-[#fafafa] px-3 py-1.5 border-none outline-none rounded-xl opacity-60 min-w-0 flex-1 text-right font-mono"
placeholder={defaultDataPath || 'Default'}
value={$config?.dataDir ?? ''}
onchange={async (e) => {
const val = (e.target as HTMLInputElement).value.trim()
await window.electronAPI.setConfig({ dataDir: val })
config.set(await window.electronAPI.getConfig())
}}
/>
<button
class="shrink-0 text-[12px] opacity-40 hover:opacity-70 px-2.5 py-1.5 bg-black/[0.04] dark:bg-white/[0.06] transition border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl"
onclick={async () => {
const folder = await window.electronAPI.selectFolder()
if (folder) {
await window.electronAPI.setConfig({ dataDir: folder })
config.set(await window.electronAPI.getConfig())
}
}}
>
{$i18n.t('common.browse')}
</button>
</div>
</div>
<!-- Uninstall -->
<div class="py-4 flex items-center justify-between">
<div>
<div class="text-[13px] opacity-70">{$i18n.t('settings.openwebui.uninstall')}</div>
<div class="text-[11px] opacity-25 mt-0.5">{$i18n.t('settings.openwebui.uninstallDesc')}</div>
</div>
<button
class="text-[12px] opacity-40 hover:opacity-70 px-3 py-1.5 bg-black/[0.04] dark:bg-white/[0.06] transition border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-xl flex items-center gap-1.5 {uninstalling ? 'pointer-events-none opacity-20' : ''}"
disabled={uninstalling}
onclick={async () => {
if (confirm($i18n.t('settings.openwebui.uninstallConfirm'))) {
uninstalling = true
try {
if (isRunning) await window.electronAPI.stopServer()
await window.electronAPI.uninstallPackage('open-webui')
version = null
} catch (e) {
console.error('Failed to uninstall:', e)
}
uninstalling = false
}
}}
>
{#if uninstalling}
<div class="w-2.5 h-2.5 rounded-full border-[1.5px] border-black/20 dark:border-white/30 border-t-transparent animate-spin"></div>
{$i18n.t('common.uninstalling')}
{:else}
{$i18n.t('common.uninstall')}
{/if}
</button>
</div>
</div>
{/if}
@@ -1,166 +0,0 @@
<script lang="ts">
import { fly, fade } from 'svelte/transition'
import { onMount } from 'svelte'
import { connections, config, appState, appInfo } from '../../stores'
import i18n from '../../i18n'
import logoImage from '../../assets/images/splash.png'
let view = $state('list') // list | add
let url = $state('')
let name = $state('')
let connecting = $state(false)
let error = $state('')
let visible = $state(false)
onMount(async () => {
connections.set(await window.electronAPI.getConnections())
config.set(await window.electronAPI.getConfig())
setTimeout(() => { visible = true }, 50)
})
const add = async () => {
if (!url.trim()) return
let u = url.trim()
if (!u.startsWith('http')) u = 'https://' + u
error = ''
connecting = true
try {
const valid = await window.electronAPI.validateUrl(u)
if (!valid) { error = $i18n.t('setup.connectionManager.unreachable'); connecting = false; return }
await window.electronAPI.addConnection({
id: crypto.randomUUID(),
name: name.trim() || new URL(u).hostname,
type: 'remote',
url: u
})
connections.set(await window.electronAPI.getConnections())
config.set(await window.electronAPI.getConfig())
url = ''; name = ''; view = 'list'
} catch { error = $i18n.t('setup.connectionManager.failed') }
finally { connecting = false }
}
const connect = (id: string) => window.electronAPI.connectTo(id)
const setDefault = async (id: string) => {
await window.electronAPI.setDefaultConnection(id)
config.set(await window.electronAPI.getConfig())
}
const remove = async (id: string) => {
await window.electronAPI.removeConnection(id)
const conns = await window.electronAPI.getConnections()
connections.set(conns)
config.set(await window.electronAPI.getConfig())
if (conns.length === 0) appState.set('setup')
}
</script>
{#if visible}
<div class="h-full flex flex-col bg-[#f5f5f7] dark:bg-[#0a0a0a] text-[#1d1d1f] dark:text-[#fafafa]" in:fade={{ duration: 250 }}>
<!-- Header -->
<div class="flex items-center justify-between {$appInfo?.platform === 'darwin' ? 'pl-[76px]' : 'pl-5'} pr-5 pt-3 pb-2 drag-region">
<div class="text-[13px] opacity-50">{$i18n.t('setup.connectionManager.connections')}</div>
<img src={logoImage} class="w-5 h-5 rounded-full dark:invert opacity-40" alt="logo" />
</div>
<div class="mx-5 border-b border-black/[0.06] dark:border-white/[0.06]"></div>
<!-- Content -->
<div class="flex-1 min-h-0 overflow-y-auto px-5 py-3">
{#if view === 'list'}
<div class="flex flex-col">
{#each $connections as conn, i (conn.id)}
<div
class="w-full py-3 cursor-pointer group flex items-center gap-3 transition-opacity hover:opacity-100 opacity-70 {i > 0 ? 'border-t border-black/[0.04] dark:border-white/[0.04]' : ''}"
role="button"
tabindex="0"
onclick={() => connect(conn.id)}
onkeydown={(e) => e.key === 'Enter' && connect(conn.id)}
in:fly={{ y: 4, duration: 150, delay: i * 30 }}
>
<div class="w-[6px] h-[6px] rounded-full shrink-0 {conn.type === 'local' ? 'bg-green-400/70' : 'bg-black/8 dark:bg-white/10'}"></div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-[13px] truncate">{conn.name}</span>
{#if $config?.defaultConnectionId === conn.id}
<span class="text-[10px] opacity-30">{$i18n.t('common.default')}</span>
{/if}
</div>
<span class="text-[11px] opacity-20 truncate block mt-px">{conn.url}</span>
</div>
<div class="shrink-0 flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
{#if $config?.defaultConnectionId !== conn.id}
<button
class="p-1.5 opacity-20 hover:opacity-60 text-[10px] transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa]"
onclick={(e) => { e.stopPropagation(); setDefault(conn.id) }}
>★</button>
{/if}
<button
class="p-1.5 opacity-20 hover:text-red-400 hover:opacity-80 text-[10px] transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa]"
onclick={(e) => { e.stopPropagation(); remove(conn.id) }}
>✕</button>
</div>
</div>
{:else}
<div class="flex-1 flex items-center justify-center py-16">
<span class="text-[13px] opacity-15">{$i18n.t('setup.connectionManager.noConnections')}</span>
</div>
{/each}
</div>
<!-- Add button -->
<button
class="mt-4 inline-flex items-center gap-2 text-[13px] opacity-40 hover:opacity-70 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa]"
onclick={() => (view = 'add')}
>
{$i18n.t('setup.connectionManager.addConnection')}
</button>
{:else if view === 'add'}
<div in:fade={{ duration: 150 }}>
<button
class="text-[12px] opacity-40 hover:opacity-70 transition mb-6 bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa]"
onclick={() => { view = 'list'; error = '' }}
>
{$i18n.t('common.back')}
</button>
<div class="text-2xl font-light tracking-tight mb-5">{$i18n.t('setup.connectionManager.addConnectionTitle')}</div>
<div class="flex flex-col gap-2.5">
<input
type="text"
bind:value={url}
placeholder={$i18n.t('setup.connectionManager.serverUrl')}
class="w-full px-4 py-2.5 bg-black/[0.04] dark:bg-white/[0.06] text-[13px] text-[#1d1d1f] dark:text-[#fafafa] placeholder:opacity-20 outline-none focus:bg-white/[0.1] transition no-drag border-none"
onkeydown={(e) => e.key === 'Enter' && add()}
/>
<input
type="text"
bind:value={name}
placeholder={$i18n.t('setup.connectionManager.labelOptional')}
class="w-full px-4 py-2.5 bg-black/[0.04] dark:bg-white/[0.06] text-[13px] text-[#1d1d1f] dark:text-[#fafafa] placeholder:opacity-20 outline-none focus:bg-white/[0.1] transition no-drag border-none"
/>
{#if error}
<span class="text-[11px] text-red-400 opacity-80">{error}</span>
{/if}
<button
class="w-fit mt-2 inline-flex items-center gap-2 bg-white px-8 py-2.5 text-black text-[13px] transition hover:bg-gray-100 disabled:opacity-30 border-none"
onclick={add}
disabled={connecting}
>
{connecting ? $i18n.t('setup.connectionManager.adding') : $i18n.t('setup.connectionManager.add')}
{#if !connecting}
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
{/if}
</button>
</div>
</div>
{/if}
</div>
</div>
{/if}
@@ -1,124 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte'
import { fade } from 'svelte/transition'
import { serverInfo, appState } from '../../stores'
import i18n from '../../i18n'
import logoImage from '../../assets/images/splash.png'
let { phase = 'loading' } = $props()
let visible = $state(false)
let installError = $state('')
let videoElement: HTMLVideoElement
// Extract available GB from appState like 'insufficient-storage:2.3'
const availableGB = $derived(
$appState?.startsWith('insufficient-storage:')
? $appState.split(':')[1]
: null
)
// Extract error message from appState like 'install-failed:message'
const installFailedMsg = $derived(
$appState?.startsWith('install-failed:')
? $appState.substring('install-failed:'.length)
: null
)
const retryCheck = async () => {
installError = ''
const api = window?.electronAPI
if (!api) return
const MINIMUM_DISK_BYTES = 5 * 1024 * 1024 * 1024
const disk = await api.getDiskSpace()
if (disk?.free >= 0 && disk.free < MINIMUM_DISK_BYTES) {
const gb = (disk.free / (1024 * 1024 * 1024)).toFixed(1)
appState.set(`insufficient-storage:${gb}`)
return
}
// Enough space now — proceed with Python install
appState.set('initializing')
try {
await api.installPython()
appState.set('ready')
} catch (e: any) {
installError = e?.message || $i18n.t('error.somethingWentWrong')
}
}
onMount(() => {
setTimeout(() => { visible = true }, 100)
if (videoElement) {
videoElement.play().catch(() => {})
}
})
</script>
{#if visible}
<div class="h-full w-full relative overflow-hidden bg-[#f5f5f7] dark:bg-[#0a0a0a]" in:fade={{ duration: 500 }}>
<!-- Video background -->
<div class="absolute inset-0 overflow-hidden">
<video
bind:this={videoElement}
autoplay
muted
loop
playsinline
class="absolute top-1/2 left-1/2 h-auto min-h-full w-auto min-w-full -translate-x-1/2 -translate-y-1/2 object-cover opacity-30"
>
<source src="https://community.s3.openwebui.com/landing.mp4" type="video/mp4" />
</video>
</div>
<div class="relative z-10 h-full flex items-center justify-center">
<div class="flex flex-col items-center gap-5">
<img src={logoImage} class="size-14 rounded-full dark:invert" alt="logo" />
{#if availableGB}
<div class="flex flex-col items-center gap-3 text-center" in:fade={{ duration: 250 }}>
<div class="text-sm text-red-400 opacity-80">
{$i18n.t('error.notEnoughDiskSpace')}
</div>
<div class="text-[11px] text-[#1d1d1f] dark:text-[#fafafa] opacity-30 max-w-[260px] leading-relaxed">
{$i18n.t('error.diskSpaceDetail', { available: availableGB })}
</div>
<button
class="mt-2 inline-flex items-center gap-2 bg-black/[0.04] dark:bg-white/[0.06] px-6 py-2 text-[12px] opacity-60 hover:opacity-90 transition border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-lg cursor-pointer"
onclick={retryCheck}
>
{$i18n.t('common.retry')}
</button>
</div>
{:else if installError || installFailedMsg}
<div class="flex flex-col items-center gap-3 text-center" in:fade={{ duration: 250 }}>
<div class="text-sm text-red-400 opacity-80">
{$i18n.t('error.installFailedGeneric')}
</div>
<div class="text-[11px] text-[#1d1d1f] dark:text-[#fafafa] opacity-30 max-w-[280px] leading-relaxed">
{installError || installFailedMsg}
</div>
<button
class="mt-2 inline-flex items-center gap-2 bg-black/[0.04] dark:bg-white/[0.06] px-6 py-2 text-[12px] opacity-60 hover:opacity-90 transition border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-lg cursor-pointer"
onclick={retryCheck}
>
{$i18n.t('common.retry')}
</button>
</div>
{:else if phase === 'initializing'}
<div class="flex flex-col items-center gap-2 text-center">
<div class="text-sm text-[#1d1d1f] dark:text-[#fafafa] opacity-50">
{$i18n.t('setup.preparingEnvironment')}
</div>
{#if $serverInfo?.status}
<div class="text-[11px] text-[#1d1d1f] dark:text-[#fafafa] opacity-25 max-w-[220px] leading-relaxed">
{$serverInfo.status}
</div>
{/if}
</div>
{/if}
</div>
</div>
</div>
{/if}
@@ -1,109 +0,0 @@
<script lang="ts">
import { fade, fly } from 'svelte/transition'
import { onMount } from 'svelte'
import { connections, config, serverInfo } from '../../stores'
import i18n from '../../i18n'
import logoImage from '../../assets/images/splash.png'
let { onBack, onComplete, autoStart = false } = $props()
let phase = $state(autoStart ? 'working' : 'ready') // ready | working | done | error
let errorMsg = $state('')
const install = async () => {
phase = 'working'
try {
const ok = await window.electronAPI.installPackage()
if (!ok) { phase = 'error'; errorMsg = $i18n.t('setup.install.failed'); return }
await window.electronAPI.startServer()
const info = await window.electronAPI.getServerInfo()
await window.electronAPI.addConnection({
id: 'local',
name: 'Local',
type: 'local',
url: info?.url || 'http://127.0.0.1:8080'
})
await window.electronAPI.setDefaultConnection('local')
connections.set(await window.electronAPI.getConnections())
config.set(await window.electronAPI.getConfig())
phase = 'done'
setTimeout(async () => {
await window.electronAPI.connectTo('local')
onComplete()
}, 800)
} catch (e) {
phase = 'error'
errorMsg = e?.message || $i18n.t('setup.install.somethingWentWrong')
}
}
onMount(() => {
if (autoStart) install()
})
</script>
<div class="flex flex-col" in:fade={{ duration: 200 }}>
<button
class="self-start text-[12px] opacity-40 hover:opacity-70 transition mb-6 bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] disabled:opacity-20"
onclick={onBack}
disabled={phase === 'working'}
>
{$i18n.t('common.back')}
</button>
{#if phase === 'ready'}
<div class="mb-1 text-sm font-normal opacity-50">{$i18n.t('app.name')}</div>
<h1 class="text-2xl font-light tracking-tight mb-2">{$i18n.t('setup.install.title')}</h1>
<p class="text-[12px] opacity-30 mb-8 leading-relaxed">
{$i18n.t('setup.install.description')}
</p>
<button
class="w-fit inline-flex items-center gap-2 bg-white px-8 py-2.5 text-black text-[13px] transition hover:bg-gray-100 border-none"
onclick={install}
>
{$i18n.t('setup.install.continue')}
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
</button>
{:else if phase === 'working'}
<div class="flex flex-col items-center gap-5 py-10" in:fade={{ duration: 250 }}>
<img src={logoImage} class="size-12 rounded-full dark:invert" alt="logo" />
<div class="flex flex-col items-center gap-2 text-center">
<div class="text-sm opacity-60">{$i18n.t('setup.install.installing')}</div>
{#if $serverInfo?.status}
<div class="text-[11px] opacity-30 max-w-[220px] leading-relaxed" in:fade={{ duration: 200 }}>
{$serverInfo.status}
</div>
{:else}
<div class="text-[11px] opacity-20">
{$i18n.t('setup.install.mightTakeMinutes')}
</div>
{/if}
</div>
</div>
{:else if phase === 'done'}
<div class="flex flex-col items-center gap-4 py-10" in:fade={{ duration: 250 }}>
<img src={logoImage} class="size-12 rounded-full dark:invert" alt="logo" />
<div class="text-sm text-green-400 opacity-70">{$i18n.t('common.ready')}</div>
</div>
{:else if phase === 'error'}
<div class="flex flex-col items-center gap-4 py-10" in:fade={{ duration: 250 }}>
<div class="text-[12px] text-red-400 opacity-80">{errorMsg}</div>
<button
class="w-fit inline-flex items-center gap-2 bg-black/[0.04] dark:bg-white/[0.06] px-6 py-2 text-[12px] opacity-60 hover:opacity-90 transition border-none text-[#1d1d1f] dark:text-[#fafafa]"
onclick={() => (phase = 'ready')}
>
{$i18n.t('common.retry')}
</button>
</div>
{/if}
</div>
@@ -1,130 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte'
import { fly, fade } from 'svelte/transition'
import { appState, connections, config } from '../../stores'
import i18n from '../../i18n'
import LocalInstall from './LocalInstall.svelte'
import logoImage from '../../assets/images/splash.png'
let view = $state('main') // main | install
let url = $state('')
let connecting = $state(false)
let error = $state('')
let mounted = $state(false)
let videoElement: HTMLVideoElement
onMount(() => {
setTimeout(() => { mounted = true }, 100)
if (videoElement) {
videoElement.play().catch(() => {})
}
})
const connect = async () => {
if (!url.trim()) return
let u = url.trim()
if (!u.startsWith('http')) u = 'https://' + u
error = ''
connecting = true
try {
const valid = await window.electronAPI.validateUrl(u)
if (!valid) { error = $i18n.t('setup.couldNotReachServer'); connecting = false; return }
await window.electronAPI.addConnection({
id: crypto.randomUUID(),
name: new URL(u).hostname,
type: 'remote',
url: u
})
connections.set(await window.electronAPI.getConnections())
config.set(await window.electronAPI.getConfig())
const conns = await window.electronAPI.getConnections()
await window.electronAPI.connectTo(conns[conns.length - 1].id)
appState.set('ready')
} catch {
error = $i18n.t('setup.connectionFailed')
} finally {
connecting = false
}
}
</script>
<div class="h-full w-full relative overflow-hidden bg-[#f5f5f7] dark:bg-[#0a0a0a] text-[#1d1d1f] dark:text-[#fafafa]">
<!-- Video background -->
<div class="absolute inset-0 overflow-hidden">
<video
bind:this={videoElement}
autoplay
muted
loop
playsinline
class="absolute top-1/2 left-1/2 h-auto min-h-full w-auto min-w-full -translate-x-1/2 -translate-y-1/2 object-cover opacity-30"
>
<source src="https://community.s3.openwebui.com/landing.mp4" type="video/mp4" />
</video>
</div>
<!-- Drag region -->
<div class="absolute top-0 left-0 right-0 h-8 drag-region z-10"></div>
<!-- Content -->
{#if mounted}
<div class="relative z-10 h-full flex flex-col justify-end px-8 pb-10">
{#if view === 'main'}
<div class="max-w-sm" in:fly={{ duration: 500, y: 10 }}>
<div class="mb-2 text-sm font-normal opacity-50">{$i18n.t('app.name')}</div>
<h1 class="text-3xl leading-tight font-light tracking-tight mb-6">
{$i18n.t('setup.newConnection')}
</h1>
<div class="flex flex-col gap-2.5">
<div class="flex gap-2">
<input
type="text"
bind:value={url}
placeholder={$i18n.t('setup.urlPlaceholder')}
class="flex-1 px-4 py-2.5 bg-black/[0.04] dark:bg-white/[0.06] text-[13px] text-[#1d1d1f] dark:text-[#fafafa] placeholder:opacity-20 outline-none focus:bg-white/[0.1] transition no-drag border-none"
onkeydown={(e) => e.key === 'Enter' && connect()}
/>
<button
class="inline-flex items-center gap-2 bg-white px-6 py-2.5 text-black text-[13px] transition hover:bg-gray-100 disabled:opacity-30 border-none shrink-0"
onclick={connect}
disabled={connecting || !url.trim()}
>
{connecting ? $i18n.t('common.connecting') : $i18n.t('common.connect')}
{#if !connecting}
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
{/if}
</button>
</div>
{#if error}
<p class="text-[11px] text-red-400 opacity-80">{error}</p>
{/if}
</div>
<div class="mt-6">
<button
class="text-sm opacity-40 hover:opacity-70 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa]"
onclick={() => (view = 'install')}
>
{$i18n.t('setup.orInstallLocally')}
</button>
</div>
</div>
{:else if view === 'install'}
<div class="max-w-sm">
<LocalInstall
onBack={() => (view = 'main')}
onComplete={() => appState.set('ready')}
/>
</div>
{/if}
</div>
{/if}
</div>
@@ -0,0 +1,9 @@
<script lang="ts">
const versions = window.electron.process.versions;
</script>
<ul class="versions">
<li class="electron-version">Electron v{versions.electron}</li>
<li class="chrome-version">Chromium v{versions.chrome}</li>
<li class="node-version">Node v{versions.node}</li>
</ul>
@@ -0,0 +1,181 @@
<script lang="ts">
import DOMPurify from "dompurify";
import { onMount, onDestroy, tick } from "svelte";
import * as FocusTrap from "focus-trap";
import { fade } from "svelte/transition";
import { flyAndScale } from "../../utils/transitions";
import { marked } from "marked";
export let title = "";
export let message = "";
export let cancelLabel = "Cancel";
export let confirmLabel = "Confirm";
export let onConfirm = () => {};
export let input = false;
export let inputPlaceholder = "";
export let inputValue = "";
export let show = false;
$: if (show) {
init();
}
let modalElement = null;
let mounted = false;
let focusTrap: FocusTrap.FocusTrap | null = null;
const init = () => {
inputValue = "";
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
console.log("Escape");
show = false;
}
if (event.key === "Enter") {
console.log("Enter");
confirmHandler();
}
};
const confirmHandler = async () => {
show = false;
await tick();
await onConfirm();
};
onMount(() => {
mounted = true;
});
$: if (mounted) {
if (show && modalElement) {
document.body.appendChild(modalElement);
focusTrap = FocusTrap.createFocusTrap(modalElement);
focusTrap.activate();
window.addEventListener("keydown", handleKeyDown);
document.body.style.overflow = "hidden";
} else if (modalElement) {
focusTrap.deactivate();
window.removeEventListener("keydown", handleKeyDown);
document.body.removeChild(modalElement);
document.body.style.overflow = "unset";
}
}
onDestroy(() => {
show = false;
if (focusTrap) {
focusTrap.deactivate();
}
if (modalElement) {
document.body.removeChild(modalElement);
}
});
</script>
{#if show}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
bind:this={modalElement}
class=" fixed top-0 right-0 left-0 bottom-0 bg-black/60 w-full h-screen max-h-[100dvh] flex justify-center z-99999999 overflow-hidden overscroll-contain"
in:fade={{ duration: 10 }}
on:mousedown={() => {
show = false;
}}
>
<div
class=" m-auto rounded-2xl max-w-full w-[32rem] mx-2 bg-gray-50 dark:bg-gray-950 max-h-[100dvh] shadow-3xl"
in:flyAndScale
on:mousedown={(e) => {
e.stopPropagation();
}}
>
<div class="px-[1.75rem] py-6 flex flex-col">
<div class=" text-lg font-semibold dark:text-gray-200 mb-2.5">
{#if title !== ""}
{title}
{:else}
{"Confirm your action"}
{/if}
</div>
<slot>
<div class=" text-sm text-gray-500 flex-1">
{#if message !== ""}
{@const html = DOMPurify.sanitize(
marked.parse(message)
)}
{@html html}
{:else}
{"This action cannot be undone. Do you wish to continue?"}
{/if}
{#if input}
<textarea
bind:value={inputValue}
placeholder={inputPlaceholder
? inputPlaceholder
: "Enter your message"}
class="w-full mt-2 rounded-lg px-4 py-2 text-sm dark:text-gray-300 dark:bg-gray-900 outline-hidden resize-none"
rows="3"
required
/>
{/if}
</div>
</slot>
<div class="mt-6 flex justify-between gap-1.5">
<button
class="bg-gray-100 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-white font-medium w-full py-2.5 rounded-lg transition"
on:click={() => {
show = false;
}}
type="button"
>
{cancelLabel}
</button>
<button
class="bg-gray-900 hover:bg-gray-850 text-gray-100 dark:bg-gray-100 dark:hover:bg-white dark:text-gray-800 font-medium w-full py-2.5 rounded-lg transition"
on:click={() => {
confirmHandler();
}}
type="button"
>
{confirmLabel}
</button>
</div>
</div>
</div>
</div>
{/if}
<style>
.modal-content {
animation: scaleUp 0.1s ease-out forwards;
}
@keyframes scaleUp {
from {
transform: scale(0.985);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
</style>
@@ -1,108 +0,0 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte'
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import '@xterm/xterm/css/xterm.css'
interface Props {
connect: (callback: (data: string) => void) => void
disconnect: () => void
readonly?: boolean
onWrite?: (data: string) => void
onResize?: (cols: number, rows: number) => void
}
let {
connect,
disconnect,
readonly = false,
onWrite,
onResize
}: Props = $props()
let containerEl: HTMLDivElement | undefined = $state()
let term: Terminal | null = null
let fitAddon: FitAddon | null = null
let resizeObserver: ResizeObserver | null = null
const initTerminal = () => {
if (!containerEl || term) return
term = new Terminal({
cursorBlink: false,
disableStdin: readonly,
fontSize: 11,
fontFamily: "'SF Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, monospace",
lineHeight: 1.5,
scrollback: 10000,
theme: {
background: '#0a0a0a',
foreground: '#d4d4d4',
cursor: readonly ? '#d4d4d4' : 'transparent',
selectionBackground: readonly ? '#264f78' : '#ffffff30'
},
convertEol: true
})
fitAddon = new FitAddon()
term.loadAddon(fitAddon)
term.open(containerEl)
requestAnimationFrame(() => fitAddon?.fit())
resizeObserver = new ResizeObserver(() => {
try {
fitAddon?.fit()
if (term && onResize) {
onResize(term.cols, term.rows)
}
} catch {}
})
resizeObserver.observe(containerEl)
if (!readonly && onWrite) {
term.onData((data: string) => {
onWrite!(data)
})
}
connect((data: string) => {
term?.write(data)
})
if (term && onResize) {
onResize(term.cols, term.rows)
}
}
const destroyTerminal = () => {
resizeObserver?.disconnect()
resizeObserver = null
disconnect()
term?.dispose()
term = null
fitAddon = null
}
export const getBufferText = (): string | null => {
if (!term) return null
const buf = term.buffer.active
const lines: string[] = []
for (let i = 0; i < buf.length; i++) {
lines.push(buf.getLine(i)?.translateToString(true) ?? '')
}
return lines.join('\n').trimEnd()
}
onMount(() => {
initTerminal()
})
onDestroy(() => {
destroyTerminal()
})
</script>
<div
class="absolute inset-0 px-3 py-2 bg-[#0a0a0a]"
bind:this={containerEl}
></div>
@@ -0,0 +1,41 @@
<script lang="ts">
import { onMount } from "svelte";
export let imageUrls = [
"./lib/assets/images/adam.jpg",
"./lib/assets/images/galaxy.jpg",
"./lib/assets/images/earth.jpg",
"./lib/assets/images/space.jpg",
];
export let duration = 5000;
let selectedImageIdx = 0;
onMount(() => {
setInterval(() => {
selectedImageIdx = (selectedImageIdx + 1) % (imageUrls.length - 1);
}, duration);
});
</script>
{#each imageUrls as imageUrl, idx (idx)}
<div
class="image w-full h-full absolute top-0 left-0 bg-cover bg-center transition-opacity duration-1000"
style="opacity: {selectedImageIdx === idx
? 1
: 0}; background-image: url('{imageUrl}')"
></div>
{/each}
<style>
.image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-size: cover;
background-position: center; /* Center the background images */
transition: opacity 1s ease-in-out; /* Smooth fade effect */
opacity: 0; /* Make images initially not visible */
}
</style>
@@ -0,0 +1,29 @@
<script lang="ts">
export let className: string = 'size-5';
</script>
<div class="flex justify-center text-center">
<svg
class={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
><style>
.spinner_ajPY {
transform-origin: center;
animation: spinner_AtaB 0.75s infinite linear;
}
@keyframes spinner_AtaB {
100% {
transform: rotate(360deg);
}
}
</style><path
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
opacity=".25"
/><path
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
class="spinner_ajPY"
/></svg
>
</div>
@@ -1,24 +1,18 @@
<script lang="ts">
let {
checked = false,
label = '',
onchange
}: { checked: boolean; label?: string; onchange?: (value: boolean) => void } = $props()
import { Switch } from "bits-ui";
let { state = $bindable(), id, onChange } = $props();
</script>
<button
class="w-9 h-5 rounded-full transition-colors {checked
? 'bg-black/30 dark:bg-white/30'
: 'bg-black/[0.10] dark:bg-white/[0.08]'} border-none relative"
aria-label={label}
onclick={() => {
checked = !checked
onchange?.(checked)
}}
<Switch.Root
bind:checked={state}
{id}
onCheckedChange={onChange}
class="flex h-5 min-h-5 w-9 shrink-0 cursor-pointer items-center rounded-full px-[3px] mx-[1px] transition outline outline-1 outline-gray-100 dark:outline-gray-800 {state
? ' bg-emerald-600'
: 'bg-gray-200 dark:bg-transparent'}"
>
<div
class="w-3.5 h-3.5 rounded-full bg-white shadow-sm absolute top-[2.5px] transition-all {checked
? 'left-[15px]'
: 'left-[3px]'}"
></div>
</button>
<Switch.Thumb
class="pointer-events-none block size-4 shrink-0 rounded-full bg-white transition-transform data-[state=checked]:translate-x-3.5 data-[state=unchecked]:translate-x-0 data-[state=unchecked]:shadow-mini "
/>
</Switch.Root>
@@ -0,0 +1,64 @@
<script lang="ts">
import DOMPurify from "dompurify";
import { onDestroy } from "svelte";
import tippy from "tippy.js";
export let elementId = "";
export let placement = "top";
export let content = `I'm a tooltip!`;
export let touch = true;
export let className = "flex";
export let theme = "";
export let offset = [0, 4];
export let allowHTML = true;
export let tippyOptions = {};
let tooltipElement;
let tooltipInstance;
$: if (tooltipElement && (content || elementId)) {
let tooltipContent = null;
if (elementId) {
tooltipContent = document.getElementById(`${elementId}`);
} else {
tooltipContent = DOMPurify.sanitize(content);
}
if (tooltipInstance) {
tooltipInstance.setContent(tooltipContent);
} else {
if (content) {
tooltipInstance = tippy(tooltipElement, {
content: tooltipContent,
placement: placement,
allowHTML: allowHTML,
touch: touch,
...(theme !== "" ? { theme } : { theme: "dark" }),
arrow: false,
offset: offset,
...tippyOptions,
});
}
}
} else if (tooltipInstance && content === "") {
if (tooltipInstance) {
tooltipInstance.destroy();
}
}
onDestroy(() => {
if (tooltipInstance) {
tooltipInstance.destroy();
}
});
</script>
<div bind:this={tooltipElement} class={className}>
<slot />
</div>
<slot name="tooltip"></slot>
@@ -0,0 +1,19 @@
<script lang="ts">
export let className = 'size-4';
export let strokeWidth = '1.5';
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width={strokeWidth}
stroke="currentColor"
class={className}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m12.75 15 3-3m0 0-3-3m3 3h-7.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
@@ -0,0 +1,15 @@
<script lang="ts">
export let className = 'w-4 h-4';
export let strokeWidth = '2';
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width={strokeWidth}
stroke="currentColor"
class={className}
>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>

Some files were not shown because too many files have changed in this diff Show More