Compare commits
144 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0931b46d66 | |||
| 601137c5de | |||
| a097a83c46 | |||
| f8c20275cd | |||
| fa64bc02b5 | |||
| 20f0aaf40c | |||
| c64e946b38 | |||
| 1902f791cb | |||
| c2f128aec0 | |||
| 64c399738e | |||
| 842c1481f6 | |||
| e7eef2da86 | |||
| aab2a687cc | |||
| 9e204e78b7 | |||
| ef53d6fb21 | |||
| 21e8a36e0d | |||
| fcb32f93ab | |||
| 48be8d0386 | |||
| 4cab91de4e | |||
| 564a89baa8 | |||
| 25b9e195c2 | |||
| 61db9dc10f | |||
| 27a3075c3a | |||
| 8c990befbe | |||
| da84e49970 | |||
| e0af7f3d32 | |||
| 4f653f5fcd | |||
| 3a76f985ab | |||
| 06808cb284 | |||
| ed26423f90 | |||
| 5e95e918c7 | |||
| 84c93aaeb6 | |||
| 37f0891840 | |||
| eb3c569078 | |||
| 3350da65ec | |||
| 953327b9ef | |||
| 1a56df0c6e | |||
| 44c40eabd6 | |||
| d475bde04a | |||
| 5cbe7553d3 | |||
| 7160392959 | |||
| a889d0e607 | |||
| ef66b1b21a | |||
| 6c669f1389 | |||
| fe398bc65d | |||
| f38c95befe | |||
| 4db0faff97 | |||
| 13dfb0f779 | |||
| 201b08826e | |||
| e12bc93d71 | |||
| be5661116f | |||
| 0680b56e1c | |||
| 20d7f145c7 | |||
| 0edc4d7532 | |||
| 36eacde7e9 | |||
| 03f6abae75 | |||
| 08616e701d | |||
| 34ce8c9d4f | |||
| c551bf0cb6 | |||
| 335f72ae4c | |||
| 563d87349c | |||
| b9ae57d008 | |||
| 8585558492 | |||
| 08356674e1 | |||
| b4ae19abf0 | |||
| 5aa13c813e | |||
| 14258c4b36 | |||
| a2f6de45f5 | |||
| 3b3349d3b5 | |||
| b099a4a6fa | |||
| 4920e90bef | |||
| 6852b4f83e | |||
| a29f1fe1f1 | |||
| 4076e12976 | |||
| e9f8c89cc9 | |||
| dd7a9c59ca | |||
| 44c81fc626 | |||
| 2dfb178ca0 | |||
| e1e2277fd8 | |||
| bef08424cc | |||
| f583dd1175 | |||
| 0c03262f59 | |||
| 3c9ed8c7b5 | |||
| 10c055c368 | |||
| 967cd01a86 | |||
| 650bdd4b6d | |||
| 95aba87530 | |||
| 8be489d9d6 | |||
| 5132ef790e | |||
| abd49e1084 | |||
| a8a77e31af | |||
| 7d0cf09640 | |||
| f63d2a1d55 | |||
| d71ff21c7d | |||
| 88d28ba0a6 | |||
| f6790b739d | |||
| e930ada5f2 | |||
| 89339fd373 | |||
| 9a87f15a7f | |||
| d870a27c1e | |||
| 6d2d176cd9 | |||
| 19e264f494 | |||
| b7399409fe | |||
| c44660ce7c | |||
| 61f55e6fd6 | |||
| 9678c513d1 | |||
| 04c3acf6f5 | |||
| ce24b23130 | |||
| d0b48e16c5 | |||
| b171cab68b | |||
| 5b6478f207 | |||
| a4f3bb252a | |||
| 0a35434afa | |||
| b7b1eecf55 | |||
| 04d60c4184 | |||
| 1186291a11 | |||
| b524aa369a | |||
| ecf1a143fa | |||
| 726ef9aeb9 | |||
| 8f247fd801 | |||
| 546c191609 | |||
| 98d10cb362 | |||
| d03e3c06c8 | |||
| e8e9349d18 | |||
| a9be68c695 | |||
| 8776b85ae0 | |||
| 6383d7040e | |||
| 35f5c6ee6d | |||
| c6f840d0e5 | |||
| 146f75f979 | |||
| 86cf4834cd | |||
| d1b6d15330 | |||
| 7c05fe4f74 | |||
| 0abeddde48 | |||
| f73e3adeeb | |||
| d26bd9718d | |||
| 3d148ee531 | |||
| 0e61600416 | |||
| f1eb615e9a | |||
| ee20d42184 | |||
| 20ec676f3a | |||
| e6bcb8e21f | |||
| 637a675fcd | |||
| 9f4cc2948c |
@@ -0,0 +1,9 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
@@ -1,74 +1,24 @@
|
||||
# Pull Request Checklist
|
||||
## Description
|
||||
|
||||
**Before submitting, make sure you've checked the following:**
|
||||
<!-- Describe your changes in detail. What problem does this solve? -->
|
||||
|
||||
- [ ] **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
|
||||
## Related Issues
|
||||
|
||||
# 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]
|
||||
<!-- Link any related issues: Fixes #123, Closes #456 -->
|
||||
|
||||
---
|
||||
|
||||
### Additional Information
|
||||
## Contributor License Agreement
|
||||
|
||||
- [Insert any additional context, notes, or explanations for the changes]
|
||||
- [Reference any related issues, commits, or other relevant 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.
|
||||
|
||||
### Screenshots or Videos
|
||||
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.
|
||||
-->
|
||||
|
||||
- [Attach any relevant screenshots or videos demonstrating the changes]
|
||||
- [ ] 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.
|
||||
|
||||
### 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.
|
||||
> [!NOTE]
|
||||
> Deleting the CLA section will lead to immediate closure of your PR and it will not be merged in.
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
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
|
||||
@@ -0,0 +1,424 @@
|
||||
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: ubuntu-24.04-arm
|
||||
arch: arm64
|
||||
- os: windows-latest
|
||||
arch: x64
|
||||
- os: windows-11-arm
|
||||
arch: arm64
|
||||
- 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 x64 only) ──
|
||||
- name: Cache Flatpak SDKs
|
||||
if: runner.os == 'Linux' && matrix.arch == 'x64'
|
||||
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: runner.os == 'Linux' && matrix.arch == 'x64'
|
||||
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 set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
security list-keychain -d user -s $KEYCHAIN_PATH
|
||||
|
||||
# ── Platform packaging ──
|
||||
- name: Package for Windows
|
||||
id: win_build
|
||||
if: runner.os == 'Windows'
|
||||
continue-on-error: true
|
||||
run: npx electron-builder --win --${{ matrix.arch }} --publish never
|
||||
|
||||
- name: Package for Windows (unsigned fallback)
|
||||
if: runner.os == 'Windows' && 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
|
||||
if: matrix.os == 'macos-latest' && steps.apple_cert.outcome == 'success'
|
||||
continue-on-error: true
|
||||
env:
|
||||
CSC_LINK: ${{ secrets.BUILD_CERTIFICATE_BASE64 }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.P12_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: npx electron-builder --mac --${{ matrix.arch }} -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.outcome == 'failure')
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
|
||||
run: |
|
||||
rm -rf dist/
|
||||
npx electron-builder --mac --${{ matrix.arch }} --publish never
|
||||
|
||||
- name: Package for Linux
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
if [ "${{ matrix.arch }}" == "arm64" ]; then
|
||||
# ARM64: deb + AppImage only (flatpak BaseApp not available for arm64)
|
||||
npx electron-builder --linux AppImage deb --${{ matrix.arch }} --publish never
|
||||
elif [ "${{ 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: runner.os == 'Windows'
|
||||
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: Install js-yaml for manifest merging
|
||||
run: npm install --no-save js-yaml
|
||||
|
||||
- name: Merge macOS latest-mac.yml (x64 + arm64)
|
||||
run: |
|
||||
# Each macOS arch build produces its own latest-mac.yml with only
|
||||
# that arch's entry. Merge them so electron-updater works for both.
|
||||
X64_YML="macos-latest-x64/latest-mac.yml"
|
||||
ARM_YML="macos-latest-arm64/latest-mac.yml"
|
||||
|
||||
if [ -f "$X64_YML" ] && [ -f "$ARM_YML" ]; then
|
||||
echo "Merging latest-mac.yml from both architectures"
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const yaml = require('js-yaml');
|
||||
const x64 = yaml.load(fs.readFileSync('$X64_YML', 'utf8'));
|
||||
const arm = yaml.load(fs.readFileSync('$ARM_YML', 'utf8'));
|
||||
const all = [...(x64.files || []), ...(arm.files || [])];
|
||||
const map = new Map();
|
||||
for (const f of all) map.set(f.url, f);
|
||||
x64.files = [...map.values()];
|
||||
const out = yaml.dump(x64, { lineWidth: -1 });
|
||||
fs.writeFileSync('$X64_YML', out);
|
||||
console.log(out);
|
||||
"
|
||||
rm -f "$ARM_YML"
|
||||
else
|
||||
echo "Skipping merge — need both $X64_YML and $ARM_YML"
|
||||
ls -la macos-latest-*/latest-mac.yml 2>/dev/null || true
|
||||
fi
|
||||
|
||||
- name: Merge Linux latest-linux.yml (x64 + arm64)
|
||||
run: |
|
||||
X64_YML="ubuntu-latest-x64/latest-linux.yml"
|
||||
# electron-builder names the arm64 Linux manifest "latest-linux-arm64.yml"
|
||||
ARM_YML="ubuntu-24.04-arm-arm64/latest-linux-arm64.yml"
|
||||
|
||||
if [ -f "$X64_YML" ] && [ -f "$ARM_YML" ]; then
|
||||
echo "Merging latest-linux.yml from both architectures"
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const yaml = require('js-yaml');
|
||||
const x64 = yaml.load(fs.readFileSync('$X64_YML', 'utf8'));
|
||||
const arm = yaml.load(fs.readFileSync('$ARM_YML', 'utf8'));
|
||||
const all = [...(x64.files || []), ...(arm.files || [])];
|
||||
const map = new Map();
|
||||
for (const f of all) map.set(f.url, f);
|
||||
x64.files = [...map.values()];
|
||||
const out = yaml.dump(x64, { lineWidth: -1 });
|
||||
fs.writeFileSync('$X64_YML', out);
|
||||
console.log(out);
|
||||
"
|
||||
rm -f "$ARM_YML"
|
||||
else
|
||||
echo "Skipping merge — need both $X64_YML and $ARM_YML"
|
||||
ls -la ubuntu-*-*/latest-linux*.yml 2>/dev/null || true
|
||||
fi
|
||||
|
||||
- name: Merge Windows latest.yml (x64 + arm64)
|
||||
run: |
|
||||
X64_YML="windows-latest-x64/latest.yml"
|
||||
ARM_YML="windows-11-arm-arm64/latest.yml"
|
||||
|
||||
if [ -f "$X64_YML" ] && [ -f "$ARM_YML" ]; then
|
||||
echo "Merging latest.yml from both architectures"
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const yaml = require('js-yaml');
|
||||
const x64 = yaml.load(fs.readFileSync('$X64_YML', 'utf8'));
|
||||
const arm = yaml.load(fs.readFileSync('$ARM_YML', 'utf8'));
|
||||
const all = [...(x64.files || []), ...(arm.files || [])];
|
||||
const map = new Map();
|
||||
for (const f of all) map.set(f.url, f);
|
||||
x64.files = [...map.values()];
|
||||
const out = yaml.dump(x64, { lineWidth: -1 });
|
||||
fs.writeFileSync('$X64_YML', out);
|
||||
console.log(out);
|
||||
"
|
||||
rm -f "$ARM_YML"
|
||||
else
|
||||
echo "Skipping merge — need both $X64_YML and $ARM_YML"
|
||||
ls -la windows-*-*/latest.yml 2>/dev/null || true
|
||||
fi
|
||||
|
||||
- name: Reconcile update manifest hashes
|
||||
run: |
|
||||
# Recompute SHA512 from actual artifact files to ensure manifests
|
||||
# match the real binaries (fixes signed/unsigned hash mismatches)
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const yaml = require('js-yaml');
|
||||
const path = require('path');
|
||||
|
||||
const manifests = [
|
||||
{ prefix: 'macos-latest-', file: 'latest-mac.yml' },
|
||||
{ prefix: 'ubuntu-', file: 'latest-linux.yml' },
|
||||
{ prefix: 'windows-', file: 'latest.yml' },
|
||||
];
|
||||
|
||||
const allDirs = fs.readdirSync('.').filter(d => {
|
||||
try { return fs.statSync(d).isDirectory(); } catch { return false; }
|
||||
});
|
||||
|
||||
for (const { prefix, file } of manifests) {
|
||||
const dirs = allDirs.filter(d => d.startsWith(prefix));
|
||||
let ymlPath = null;
|
||||
for (const dir of dirs) {
|
||||
const p = path.join(dir, file);
|
||||
if (fs.existsSync(p)) { ymlPath = p; break; }
|
||||
}
|
||||
if (!ymlPath) { console.log('No ' + file + ' found, skipping'); continue; }
|
||||
|
||||
const manifest = yaml.load(fs.readFileSync(ymlPath, 'utf8'));
|
||||
let fixed = 0;
|
||||
|
||||
for (const entry of manifest.files) {
|
||||
for (const dir of dirs) {
|
||||
const filePath = path.join(dir, entry.url);
|
||||
if (fs.existsSync(filePath)) {
|
||||
const buf = fs.readFileSync(filePath);
|
||||
const hash = crypto.createHash('sha512').update(buf).digest('base64');
|
||||
if (hash !== entry.sha512) {
|
||||
console.log('[' + file + '] Fix: ' + entry.url);
|
||||
entry.sha512 = hash;
|
||||
entry.size = buf.length;
|
||||
fixed++;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest.path) {
|
||||
for (const dir of dirs) {
|
||||
const filePath = path.join(dir, manifest.path);
|
||||
if (fs.existsSync(filePath)) {
|
||||
const buf = fs.readFileSync(filePath);
|
||||
manifest.sha512 = crypto.createHash('sha512').update(buf).digest('base64');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const out = yaml.dump(manifest, { lineWidth: -1 });
|
||||
fs.writeFileSync(ymlPath, out);
|
||||
console.log('[' + file + '] ' + (fixed ? 'Fixed ' + fixed + ' entries' : 'All hashes OK'));
|
||||
}
|
||||
"
|
||||
|
||||
- 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: |
|
||||
windows-*-*/*.exe
|
||||
windows-*-*/*.blockmap
|
||||
windows-*-*/latest*.yml
|
||||
macos-latest-*/*.dmg
|
||||
macos-latest-*/*.zip
|
||||
macos-latest-*/*.pkg
|
||||
macos-latest-*/*.blockmap
|
||||
macos-latest-*/latest*.yml
|
||||
ubuntu-*-*/*.deb
|
||||
ubuntu-*-*/*.rpm
|
||||
ubuntu-*-*/*.AppImage
|
||||
ubuntu-*-*/*.snap
|
||||
ubuntu-*-*/*.flatpak
|
||||
ubuntu-*-*/*.tar.gz
|
||||
ubuntu-*-*/latest*.yml
|
||||
@@ -1,102 +1,9 @@
|
||||
# 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
|
||||
|
||||
# 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
|
||||
.vscode
|
||||
node_modules
|
||||
dist
|
||||
out
|
||||
.DS_Store
|
||||
.eslintcache
|
||||
*.log*
|
||||
*.tsbuildinfo
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
out
|
||||
dist
|
||||
pnpm-lock.yaml
|
||||
LICENSE.md
|
||||
tsconfig.json
|
||||
tsconfig.*.json
|
||||
@@ -0,0 +1,10 @@
|
||||
singleQuote: true
|
||||
semi: false
|
||||
printWidth: 100
|
||||
trailingComma: none
|
||||
plugins:
|
||||
- prettier-plugin-svelte
|
||||
overrides:
|
||||
- files: '*.svelte'
|
||||
options:
|
||||
parser: svelte
|
||||
@@ -0,0 +1,211 @@
|
||||
# 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).
|
||||
|
||||
## [0.0.20] - 2026-05-07
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Blank Webview on Linux.** Replaced the `--in-process-gpu` Chromium flag with SwiftShader software rendering (`--use-gl=angle --use-angle=swiftshader`). The in-process GPU flag broke `<webview>` guest compositing entirely, leaving connection views blank on all Linux configurations. SwiftShader keeps the GPU process out-of-process (required for webview compositing) while avoiding driver-level crashes (#178).
|
||||
|
||||
## [0.0.19] - 2026-05-06
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Spotlight Pulls to First Desktop on macOS.** Spotlight no longer switches Spaces when triggered from a non-primary desktop. The window is now visible on all workspaces, and `app.focus({ steal: true })` — which activated the entire app and caused the Space switch — has been replaced with targeted window-level focus (#179).
|
||||
- **Gray Screen When Connecting to Server on Linux.** Replaced the `--disable-gpu` Chromium flag with `--in-process-gpu`, which keeps the display compositor alive so `<webview>` guest surfaces actually paint instead of showing a gray rectangle. The previous flag fixed GPU process crashes but broke webview rendering on Debian and Ubuntu (#178).
|
||||
- **Open Terminal Fails Silently Without Python.** Open Terminal now automatically installs Python when it's missing instead of throwing an opaque error. Progress status is surfaced in the UI during installation.
|
||||
- **Corrupt Auto-Update Manifests.** Fixed the release workflow to deduplicate artifact entries during manifest merging, preventing SHA512 checksum mismatches that caused updates to fail silently.
|
||||
|
||||
## [0.0.18] - 2026-05-05
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Downloaded Models Not Recognized by llama.cpp.** Models downloaded from Hugging Face are now stored directly under the `models/` directory instead of a nested `models/huggingface/` subdirectory, so llama-server's model scanner discovers them without manual symlinks. Existing models in the old location are automatically migrated on startup (#177).
|
||||
|
||||
## [0.0.17] - 2026-05-03
|
||||
|
||||
### Added
|
||||
|
||||
- **Webview Context Menu.** Right-clicking inside the webview now shows a native context menu with Cut, Copy, Paste, Undo/Redo, spell-check suggestions, and "Open Link in Browser" — enabling system autofill and password manager integration on login pages (#161).
|
||||
|
||||
### Changed
|
||||
|
||||
- **Windows OpenSSL Compatibility.** The bundled Python's directory is now prepended to `PATH` on Windows so its own OpenSSL DLLs are loaded before any conflicting system-wide installations (Git for Windows, Anaconda, Strawberry Perl, etc.), preventing the `OPENSSL_Uplink: no OPENSSL_Applink` crash on startup (#167).
|
||||
- **Links Open in Default Browser on Windows.** Added `allowpopups` to the webview so that `target="_blank"` link clicks correctly propagate to the main process handler and open in the default browser instead of being silently blocked (#165, #170).
|
||||
- **Linux System Requirements.** Documentation now specifies glibc 2.28+ as a minimum requirement for Linux installations.
|
||||
|
||||
## [0.0.16] - 2026-05-02
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Links Open in Default Browser.** Clicking links in chat responses now opens them in the user's default browser instead of navigating within the app or spawning a new Electron window (#165).
|
||||
|
||||
## [0.0.15] - 2026-04-28
|
||||
|
||||
### Added
|
||||
|
||||
- **ARM64 Support for Linux and Windows.** Native ARM64 builds are now produced for Linux (.deb, AppImage) and Windows (NSIS installer), enabling support for Raspberry Pi, NVIDIA DGX Spark, Snapdragon laptops, and other ARM64 devices (#140).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Grey/Blank Screen on Linux.** Disabled GPU compositing entirely on Linux to prevent shared memory allocation crashes that caused a grey or blank screen on systems with restricted `/dev/shm` or `/tmp` permissions.
|
||||
- **Spotlight Dismiss Behavior.** Pressing Escape or the toggle shortcut to dismiss Spotlight no longer erroneously brings the main application window to the foreground (#158).
|
||||
|
||||
## [0.0.14] - 2026-04-28
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Grey/Blank Webview on Linux.** Disabled GPU compositing on Linux to prevent silent compositor failures that produce a grey rectangle instead of rendered content on systems with problematic Intel/NVIDIA drivers or certain Wayland compositors (#119).
|
||||
- **Renderer Crash Recovery.** The main window now automatically reloads when the renderer process dies unexpectedly, preventing a permanent blank/grey screen.
|
||||
- **Webview Crash Diagnostics.** Added logging for guest webview renderer crashes to aid debugging connectivity and rendering issues.
|
||||
- **macOS Notarization.** Resolved Apple notarization failure caused by an expired Developer Program agreement, restoring signed and notarized macOS builds.
|
||||
|
||||
## [0.0.13] - 2026-04-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Copy Button on Linux (GNOME/Wayland/Flatpak).** Fixed the "Copy" button in the Open WebUI interface not actually writing to the system clipboard on Linux. The webview session was missing the `clipboard-sanitized-write` permission required by Electron for `navigator.clipboard.writeText()` to work.
|
||||
|
||||
## [0.0.12] - 2026-04-25
|
||||
|
||||
### Added
|
||||
|
||||
- **Toggleable Clipboard Auto-Paste for Spotlight.** Spotlight's automatic clipboard pasting is now optional and can be toggled in Settings, so the input bar starts empty when preferred.
|
||||
- **Persistent Window Size and Position.** The app now remembers your window dimensions, position, and maximized state across restarts, with safe fallback when a saved display is disconnected.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Linux .deb Crash.** Fixed app failing to launch on Linux with `Failed to load native module: pty.node` by enabling native module rebuilds and unpacking node-pty from the asar archive during packaging.
|
||||
- **Grey Screen on Connection Failure.** The webview now shows an error overlay with retry and open-in-browser options instead of a blank grey screen when a connection fails to load or crashes.
|
||||
- **Global Shortcuts on Wayland/Flatpak.** Global shortcuts now work on Wayland desktops via `xdg-desktop-portal`, with clear user-facing notifications when a shortcut cannot be registered.
|
||||
|
||||
## [0.0.11] - 2026-04-24
|
||||
|
||||
### Fixed
|
||||
|
||||
- **macOS Launch Crash.** Fixed app failing to launch with "different Team IDs" error by adding the missing `disable-library-validation` entitlement to the build signing configuration.
|
||||
- **Self-Signed SSL Connections.** The app now trusts all SSL certificates, allowing connections to Open WebUI instances behind self-signed or untrusted certificates without errors.
|
||||
|
||||
## [0.0.10] - 2026-04-24
|
||||
|
||||
### Added
|
||||
|
||||
- **Concurrent Model Downloads.** Multiple Hugging Face models can now be downloaded simultaneously, each with independent progress tracking and per-file cancel buttons.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Models Settings UI.** Cleaner layout with inline progress bars, hover-reveal download buttons, and breadcrumb-style repo navigation.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **GPU Process Crash Recovery.** The app now automatically detects GPU process crashes (common with certain NVIDIA/Intel drivers on Windows) and relaunches with the GPU sandbox disabled, instead of closing immediately. No manual shortcut edits required.
|
||||
|
||||
## [0.0.9] - 2026-04-20
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Open Terminal API Key Persistence.** The Open Terminal API key is now saved in config.json and reused across restarts instead of being regenerated on every startup, which was breaking existing integrations.
|
||||
|
||||
## [0.0.8] - 2026-04-11
|
||||
|
||||
### Added
|
||||
|
||||
- **Voice Input.** System-wide push-to-talk voice transcription. Press the shortcut from any app to record audio, which is automatically transcribed and sent to your active chat.
|
||||
- **Voice Input Settings.** Configurable global hotkey and enable/disable toggle in Settings, with a default of Shift+Cmd+Space (macOS) or Shift+Ctrl+Space (Windows/Linux).
|
||||
- **Audio Feedback.** Bundled start and stop chime sounds play when recording begins and ends.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Shortcut Recorder on macOS.** Shortcut inputs now use physical key codes instead of character values, fixing Alt key combinations producing unicode characters like √ instead of V.
|
||||
|
||||
## [0.0.7] - 2026-04-11
|
||||
|
||||
### Fixed
|
||||
|
||||
- **macOS Auto-Update.** Auto-update now works correctly on macOS. Previously, the updater tried to download a zip file with a versioned filename that did not exist in the release.
|
||||
|
||||
## [0.0.6] - 2026-04-10
|
||||
|
||||
### Added
|
||||
|
||||
- **Spotlight Screenshot Capture.** Drag anywhere on the Spotlight overlay to select a region of your screen. Screenshots appear as inline thumbnails and are sent alongside your message.
|
||||
- **Multiple Screenshots.** Attach several screenshots in a single Spotlight query. Each one can be individually removed before sending.
|
||||
- **Click-to-Dismiss Spotlight.** Clicking the background outside the input bar dismisses Spotlight, in addition to pressing Escape.
|
||||
- **Screen Recording Permission Prompt (macOS).** If screen capture permission hasn't been granted, a notification guides you to the correct System Settings page.
|
||||
- **Screenshot Hint.** A "Drag anywhere to capture a screenshot" hint appears when Spotlight opens.
|
||||
- **Offline Mode for llama.cpp.** Previously downloaded llama.cpp binaries are automatically detected on startup, so local models work without an internet connection.
|
||||
- **Auto-Connect on Startup.** The app pre-connects to your default connection when launched, so Spotlight queries work immediately.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Fullscreen Spotlight Overlay.** Spotlight now opens as a fullscreen transparent overlay on your active display rather than a small floating window, enabling screenshot capture and multi-display support.
|
||||
- **Faster Remote Connections.** Switching to a remote server is now instant with no loading delay.
|
||||
- **Smarter Loading Indicator.** The loading spinner only appears when the local server is actually starting, instead of showing on every connection switch.
|
||||
- **Clearer Sidebar Selection.** Active connections are more visually distinct with bolder text and stronger highlights. Inactive connections are subtler for better contrast.
|
||||
- **Safer llama.cpp Updates.** The app verifies internet connectivity before removing the current installation, preventing accidental data loss when updating offline.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Tray Menu Connections.** Clicking a connection from the system tray menu now correctly opens it in the app.
|
||||
- **Dark Mode Context Menus.** Sidebar right-click menus no longer appear incorrectly highlighted in dark mode.
|
||||
- **Local Server Always Accessible.** The local connection in the sidebar is no longer grayed out when the server isn't running. Clicking it will start the server.
|
||||
- **Open Terminal Install Errors.** If automatic installation of Open Terminal fails, you now see a clear error message instead of a silent failure.
|
||||
- **Network Timeout Handling.** Requests for llama.cpp releases now time out after 10 seconds instead of hanging indefinitely on slow networks.
|
||||
|
||||
## [0.0.5] - 2026-04-07
|
||||
|
||||
### Added
|
||||
|
||||
- **Two-Way Theme Sync.** Theme changes in Open WebUI are now mirrored to the desktop app and vice versa, so your light/dark preference stays consistent everywhere.
|
||||
- **Seamless Spotlight Queries.** Spotlight prompts now appear directly in your already-open chat without triggering a full page reload.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Auto-Default Connection.** Selecting a connection now automatically saves it as your default for Spotlight and app startup.
|
||||
- **Smooth Connection Switching.** Switching between already-open connections no longer causes unnecessary page reloads.
|
||||
- **Connection Switch Race Condition.** Clicking a remote connection while the local server is still starting no longer gets overridden when the local server finishes loading.
|
||||
|
||||
## [0.0.3] - 2026-04-06
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Spotlight Focus.** Spotlight now reliably appears after interacting with the main window. Previously could fail to show on macOS.
|
||||
- **Spotlight Search Passthrough.** Searches submitted from Spotlight now correctly load in already-open connections 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 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 and restored across app restarts.
|
||||
- **Spotlight Settings.** Shortcut recorder in Settings → General for the Spotlight shortcut.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **System Theme Sync.** The app now responds to 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 Viewer.** Live 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 engine 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 update checker with one-click download and install.
|
||||
- **Tray Support.** System tray icon with quick actions and optional background mode.
|
||||
- **Factory Reset.** One-click removal of all installed components, data, and connections.
|
||||
- **Disk Space Check.** Pre-install check requiring at least 5 GB of free storage.
|
||||
- **Internationalization.** English, Japanese, Chinese (Simplified & Traditional) translations.
|
||||
- **In-App Changelog.** Accessible from the About settings page.
|
||||
- **Cross-Platform.** macOS, Windows, and Linux support.
|
||||
@@ -1,7 +1,7 @@
|
||||
# Open WebUI Contributor License Agreement
|
||||
# Contributor License Agreement
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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 project’s future and helps guarantee that improvements can always be shared and distributed in the most effective way possible.
|
||||
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.
|
||||
|
||||
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.
|
||||
**_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._**
|
||||
@@ -1,50 +1,661 @@
|
||||
# Open WebUI Sustainable Use License 1.0
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (c) 2025 Timothy Jaeryang Baek (Open WebUI)
|
||||
All rights reserved.
|
||||
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.
|
||||
|
||||
1. Definitions
|
||||
Preamble
|
||||
|
||||
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 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.
|
||||
|
||||
2. Acceptance
|
||||
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.
|
||||
|
||||
By using, copying, modifying, or distributing the Software, you agree to all terms of this license.
|
||||
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.
|
||||
|
||||
3. Grant of Rights
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
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 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.
|
||||
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.
|
||||
|
||||
4. Branding and Attribution
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
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.
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
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.
|
||||
0. Definitions.
|
||||
|
||||
5. Contributions
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
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 repository’s documentation at the time of your contribution.
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
6. Termination
|
||||
"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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
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 "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.
|
||||
|
||||
7. No Warranty
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
8. Patent Grant
|
||||
1. Source Code.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
9. Miscellaneous
|
||||
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.
|
||||
|
||||
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.
|
||||
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/>.
|
||||
|
||||
@@ -1,53 +1,80 @@
|
||||
# Open WebUI Desktop 🌐
|
||||
# Open WebUI Desktop
|
||||
|
||||

|
||||
[](https://github.com/open-webui/desktop/releases)
|
||||
[](https://github.com/open-webui/desktop/releases)
|
||||
[](https://discord.gg/open-webui)
|
||||
[](LICENSE)
|
||||
|
||||
**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.
|
||||

|
||||
|
||||
This project is still in an **experimental phase** and under active development. 🛠️ Expect frequent updates and potential changes as we refine the application.
|
||||
Your AI, right on your desktop. [Open WebUI](https://github.com/open-webui/open-webui) as a native app. Run models locally or connect to any server. No Docker, no terminal, no setup. Download, launch, chat.
|
||||
|
||||
---
|
||||
> [!WARNING]
|
||||
> **Early Alpha.** Things move fast and stuff might break. [Report bugs](https://github.com/open-webui/desktop/issues) or [come hang out on Discord](https://discord.gg/open-webui).
|
||||
|
||||
## 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.
|
||||
## Download
|
||||
|
||||
---
|
||||
| Platform | Installer |
|
||||
|----------|-----------|
|
||||
| macOS (Apple Silicon) | [**Download .dmg**](https://github.com/open-webui/desktop/releases/latest/download/open-webui-arm64.dmg) |
|
||||
| macOS (Intel) | [**Download .dmg**](https://github.com/open-webui/desktop/releases/latest/download/open-webui-x64.dmg) |
|
||||
| Windows x64 | [**Download .exe**](https://github.com/open-webui/desktop/releases/latest/download/open-webui-x64-setup.exe) |
|
||||
| Windows ARM64 | [**Download .exe**](https://github.com/open-webui/desktop/releases/latest/download/open-webui-arm64-setup.exe) |
|
||||
| Linux x64 (AppImage) | [**Download .AppImage**](https://github.com/open-webui/desktop/releases/latest/download/open-webui_x64.AppImage) |
|
||||
| Linux x64 (Debian/Ubuntu) | [**Download .deb**](https://github.com/open-webui/desktop/releases/latest/download/open-webui_amd64.deb) |
|
||||
| Linux x64 (Snap) | [**Download .snap**](https://github.com/open-webui/desktop/releases/latest/download/open-webui_amd64.snap) |
|
||||
| Linux x64 (Flatpak) | [**Download .flatpak**](https://github.com/open-webui/desktop/releases/latest/download/open-webui.flatpak) |
|
||||
| Linux ARM64 (AppImage) | [**Download .AppImage**](https://github.com/open-webui/desktop/releases/latest/download/open-webui_arm64.AppImage) |
|
||||
| Linux ARM64 (Debian/Ubuntu) | [**Download .deb**](https://github.com/open-webui/desktop/releases/latest/download/open-webui_arm64.deb) |
|
||||
|
||||
## Project Setup
|
||||
Internet required on first launch. After that, everything works offline. [All releases →](https://github.com/open-webui/desktop/releases)
|
||||
|
||||
### Install
|
||||
## How It Works
|
||||
|
||||
🖥️ **Run locally.** The app runs Open WebUI on your machine. You can optionally enable the built-in llama.cpp engine to download and run models offline. Nothing leaves your computer.
|
||||
|
||||
☁️ **Connect remotely.** Point the app at any Open WebUI server. Switch between multiple connections from the sidebar.
|
||||
|
||||
Use both at the same time.
|
||||
|
||||
## Highlights
|
||||
|
||||
- ⚡ **Spotlight.** Hit `Shift+Cmd+I` (macOS) or `Shift+Ctrl+I` (Windows/Linux) to summon a floating chat bar over whatever you're doing. Drag to screenshot anything on screen.
|
||||
- 🎙️ **Voice input.** System-wide push-to-talk. Press the shortcut from any app to record, and your speech is transcribed and sent to your chat automatically.
|
||||
- 🧠 **Local inference.** Optionally run models entirely on your hardware via the built-in llama.cpp engine. Your data never leaves your machine.
|
||||
- 🎯 **One-click setup.** Launch and connect to a server in seconds. Local models can be enabled from the settings.
|
||||
- 🔌 **Multiple connections.** Juggle servers and switch between them instantly.
|
||||
- 🔄 **Auto-updates.** New releases land in the background.
|
||||
- 📡 **Offline-ready.** No internet needed after initial setup.
|
||||
- 💻 **Cross-platform.** macOS, Windows, and Linux.
|
||||
|
||||
## System Requirements
|
||||
|
||||
| | Local Models | Remote Only |
|
||||
|--|-------------|-------------|
|
||||
| **Disk** | 5 GB+ | ~500 MB |
|
||||
| **RAM** | 16 GB+ | 4 GB |
|
||||
| **OS** | macOS 12+, Windows 10+, modern Linux (glibc 2.28+) | Same |
|
||||
|
||||
> [!NOTE]
|
||||
> Local models need serious RAM (7B ≈ 8 GB, 13B ≈ 16 GB). Lighter machine? Connect to a remote server instead.
|
||||
|
||||
## Privacy
|
||||
|
||||
No telemetry. No tracking. No phone-home. Your conversations stay on your machine. Period.
|
||||
|
||||
## Community
|
||||
|
||||
- 💬 [Discord](https://discord.gg/open-webui) - Come hang out
|
||||
- 🐛 [Issues](https://github.com/open-webui/desktop/issues) - Report bugs or request features
|
||||
- 🌐 [Open WebUI](https://github.com/open-webui/open-webui) - The main project
|
||||
- 📖 [Docs](https://docs.openwebui.com) - Full documentation
|
||||
|
||||
## Contributing
|
||||
|
||||
```bash
|
||||
$ npm install
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
$ npm run dev
|
||||
```
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
# For windows
|
||||
$ npm run build:win
|
||||
|
||||
# For macOS
|
||||
$ npm run build:mac
|
||||
|
||||
# For Linux
|
||||
$ npm run build:linux
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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 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.
|
||||
See [CHANGELOG.md](CHANGELOG.md) for release history. Licensed under [AGPL-3.0](LICENSE).
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<component type="desktop-application">
|
||||
<id>com.openwebui.open-webui</id>
|
||||
|
||||
<metadata_license>CC0-1.0</metadata_license>
|
||||
<project_license>AGPL-3.0-or-later</project_license>
|
||||
|
||||
<name>Open WebUI</name>
|
||||
<summary>The freedom AI stack</summary>
|
||||
|
||||
<developer id="com.timbaek">
|
||||
<name>Timothy J. Baek</name>
|
||||
</developer>
|
||||
|
||||
<description>
|
||||
<p>
|
||||
Open WebUI is an extensible, feature-rich, and user-friendly self-hosted AI platform designed to operate entirely offline.
|
||||
</p>
|
||||
</description>
|
||||
|
||||
<launchable type="desktop-id">com.openwebui.open-webui.desktop</launchable>
|
||||
|
||||
<content_rating type="oars-1.1">
|
||||
<content_attribute id="social-chat">intense</content_attribute>
|
||||
</content_rating>
|
||||
|
||||
<url type="bugtracker">https://github.com/open-webui/desktop/issues</url>
|
||||
<url type="homepage">https://openwebui.com</url>
|
||||
<url type="donation">https://github.com/sponsors/tjbck</url>
|
||||
<url type="vcs-browser">https://github.com/open-webui/desktop</url>
|
||||
|
||||
<screenshots>
|
||||
<screenshot type="default">
|
||||
<image>https://raw.githubusercontent.com/open-webui/desktop/61f55e6fd6814b959b16a4704b03262e02186f48/demo.png</image>
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
|
||||
<releases>
|
||||
<release version="0.0.8" date="2026-04-11">
|
||||
<url type="details">https://github.com/open-webui/desktop/releases/tag/v0.0.8</url>
|
||||
</release>
|
||||
<release version="0.0.6" date="2026-04-10">
|
||||
<url type="details">https://github.com/open-webui/desktop/releases/tag/v0.0.6</url>
|
||||
</release>
|
||||
</releases>
|
||||
</component>
|
||||
@@ -8,5 +8,33 @@
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.debugger</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.server</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-only</key>
|
||||
<true/>
|
||||
<key>com.apple.security.inherit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.automation.apple-events</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.bluetooth</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.camera</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.print</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.microphone</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.usb</key>
|
||||
<true/>
|
||||
<key>com.apple.security.personal-information.location</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
|
Before Width: | Height: | Size: 946 KiB After Width: | Height: | Size: 708 KiB |
@@ -1,3 +1,4 @@
|
||||
provider: generic
|
||||
url: https://example.com/auto-updates
|
||||
provider: github
|
||||
owner: open-webui
|
||||
repo: desktop
|
||||
updaterCacheDirName: desktop-updater
|
||||
|
||||
@@ -1,54 +1,88 @@
|
||||
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,CHANGELOG.md,README.md}"
|
||||
- "!{.env,.env.*,.npmrc,pnpm-lock.yaml}"
|
||||
- "!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}"
|
||||
- '!**/.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
|
||||
asarUnpack:
|
||||
- resources/**
|
||||
- resources/**
|
||||
- node_modules/node-pty/**
|
||||
win:
|
||||
executableName: open-webui
|
||||
executableName: open-webui
|
||||
nsis:
|
||||
artifactName: ${name}-${version}-setup.${ext}
|
||||
shortcutName: ${productName}
|
||||
uninstallDisplayName: ${productName}
|
||||
createDesktopShortcut: always
|
||||
artifactName: ${name}-${arch}-setup.${ext}
|
||||
shortcutName: ${productName}
|
||||
uninstallDisplayName: ${productName}
|
||||
createDesktopShortcut: always
|
||||
mac:
|
||||
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
|
||||
target:
|
||||
- target: dmg
|
||||
- target: zip
|
||||
arch:
|
||||
- x64
|
||||
- arm64
|
||||
artifactName: ${name}-${arch}-mac.${ext}
|
||||
entitlements: build/entitlements.mac.plist
|
||||
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.
|
||||
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}-${arch}.${ext}
|
||||
title: ${productName}
|
||||
contents:
|
||||
- x: 225
|
||||
y: 250
|
||||
type: file
|
||||
- x: 400
|
||||
y: 240
|
||||
type: link
|
||||
path: /Applications
|
||||
linux:
|
||||
target:
|
||||
- AppImage
|
||||
- snap
|
||||
- deb
|
||||
maintainer: openwebui.com
|
||||
category: Utility
|
||||
target:
|
||||
- AppImage
|
||||
- snap
|
||||
- deb
|
||||
- flatpak
|
||||
maintainer: openwebui.com
|
||||
category: Utility
|
||||
deb:
|
||||
artifactName: ${name}_${arch}.${ext}
|
||||
snap:
|
||||
artifactName: ${name}_${arch}.${ext}
|
||||
appImage:
|
||||
artifactName: ${name}-${version}.${ext}
|
||||
npmRebuild: false
|
||||
artifactName: ${name}_${arch}.${ext}
|
||||
flatpak:
|
||||
base: org.electronjs.Electron2.BaseApp
|
||||
baseVersion: '23.08'
|
||||
runtime: org.freedesktop.Platform
|
||||
runtimeVersion: '23.08'
|
||||
sdk: org.freedesktop.Sdk
|
||||
artifactName: ${name}.flatpak
|
||||
finishArgs:
|
||||
- --share=ipc
|
||||
- --socket=x11
|
||||
- --socket=wayland
|
||||
- --socket=pulseaudio
|
||||
- --share=network
|
||||
- --device=dri
|
||||
- --filesystem=home
|
||||
- --talk-name=org.freedesktop.Notifications
|
||||
- --talk-name=org.freedesktop.portal.Desktop
|
||||
npmRebuild: true
|
||||
publish:
|
||||
provider: generic
|
||||
url: https://example.com/auto-updates
|
||||
provider: github
|
||||
owner: open-webui
|
||||
repo: desktop
|
||||
|
||||
@@ -1,15 +1,36 @@
|
||||
import { defineConfig, externalizeDepsPlugin } from "electron-vite";
|
||||
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
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'
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
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'),
|
||||
'voice-input-preload': resolve(__dirname, 'src/preload/voice-input-preload.ts')
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
renderer: {
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve(__dirname, 'src/renderer/index.html'),
|
||||
spotlight: resolve(__dirname, 'src/renderer/spotlight.html'),
|
||||
'voice-input': resolve(__dirname, 'src/renderer/voice-input.html')
|
||||
}
|
||||
}
|
||||
},
|
||||
preload: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
},
|
||||
renderer: {
|
||||
plugins: [svelte(), tailwindcss()],
|
||||
},
|
||||
});
|
||||
plugins: [tailwindcss(), svelte()]
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 tseslint.config(
|
||||
export default defineConfig(
|
||||
{ ignores: ['**/node_modules', '**/dist', '**/out'] },
|
||||
tseslint.configs.recommended,
|
||||
indent: "off",
|
||||
eslintPluginSvelte.configs['flat/recommended'],
|
||||
{
|
||||
files: ['**/*.svelte'],
|
||||
|
||||
@@ -1,57 +1,59 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
"name": "open-webui",
|
||||
"version": "0.0.20",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 258 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 782 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 2.8 MiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 432 KiB |
|
Before Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 5.1 KiB |
@@ -1,13 +0,0 @@
|
||||
# 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
|
||||
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 5.3 KiB |
@@ -0,0 +1,73 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
// @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/<repo-slug>/<filename>
|
||||
*/
|
||||
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import log from 'electron-log'
|
||||
|
||||
import { getInstallDir, 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(getInstallDir(), 'models')
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
|
||||
// Migrate models from legacy models/huggingface/<slug>/ to models/<slug>/
|
||||
const legacyDir = path.join(dir, 'huggingface')
|
||||
if (fs.existsSync(legacyDir)) {
|
||||
try {
|
||||
const entries = fs.readdirSync(legacyDir, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const src = path.join(legacyDir, entry.name)
|
||||
const dest = path.join(dir, entry.name)
|
||||
if (!fs.existsSync(dest)) {
|
||||
fs.renameSync(src, dest)
|
||||
log.info(`[huggingface] Migrated ${entry.name} from legacy cache`)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Remove legacy dir if empty (manifest.json may remain)
|
||||
const remaining = fs.readdirSync(legacyDir)
|
||||
if (remaining.length === 0) {
|
||||
fs.rmdirSync(legacyDir)
|
||||
log.info('[huggingface] Removed empty legacy huggingface/ directory')
|
||||
}
|
||||
} catch (e) {
|
||||
log.warn('[huggingface] Failed to migrate legacy cache:', e)
|
||||
}
|
||||
}
|
||||
|
||||
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 ─────────────────────────────────────────
|
||||
|
||||
const activeDownloads = new Map<string, AbortController>()
|
||||
|
||||
const downloadKey = (repo: string, filename: string): string => `${repo}/${filename}`
|
||||
|
||||
/**
|
||||
* Cancel a specific download in progress.
|
||||
* If no repo/filename given, cancels ALL active downloads.
|
||||
*/
|
||||
export const cancelDownload = (repo?: string, filename?: string): void => {
|
||||
if (repo && filename) {
|
||||
const key = downloadKey(repo, filename)
|
||||
const ctrl = activeDownloads.get(key)
|
||||
if (ctrl) {
|
||||
ctrl.abort()
|
||||
activeDownloads.delete(key)
|
||||
}
|
||||
} else {
|
||||
// Cancel all
|
||||
for (const ctrl of activeDownloads.values()) {
|
||||
ctrl.abort()
|
||||
}
|
||||
activeDownloads.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(getInstallDir(), '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}`
|
||||
}
|
||||
|
||||
const key = downloadKey(repo, filename)
|
||||
// Cancel any existing download for the same file
|
||||
activeDownloads.get(key)?.abort()
|
||||
|
||||
const abortController = new AbortController()
|
||||
activeDownloads.set(key, abortController)
|
||||
const { signal } = abortController
|
||||
|
||||
// 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 {}
|
||||
activeDownloads.delete(downloadKey(repo, filename))
|
||||
throw err
|
||||
} finally {
|
||||
writeStream.end()
|
||||
await new Promise((resolve) => writeStream.on('finish', resolve))
|
||||
}
|
||||
|
||||
// Rename tmp to final
|
||||
fs.renameSync(tmpPath, destPath)
|
||||
activeDownloads.delete(downloadKey(repo, filename))
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,613 @@
|
||||
// @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,
|
||||
setConfig,
|
||||
getInstallDir,
|
||||
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 = () => {
|
||||
// Lazily discover a cached binary on cold boot so the UI never falsely
|
||||
// reports "not installed" when the files are actually on disk.
|
||||
if (!binaryPath) {
|
||||
const cacheBase = path.join(getInstallDir(), 'llama.cpp')
|
||||
try {
|
||||
if (fs.existsSync(cacheBase)) {
|
||||
const dirs = fs.readdirSync(cacheBase, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory())
|
||||
for (const d of dirs) {
|
||||
const found = findBinary(path.join(cacheBase, d.name))
|
||||
if (found) {
|
||||
binaryPath = found
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore — best-effort discovery
|
||||
}
|
||||
}
|
||||
|
||||
// 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(getInstallDir(), '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(getInstallDir(), 'llama.cpp')
|
||||
if (!fs.existsSync(cacheBase)) {
|
||||
fs.mkdirSync(cacheBase, { recursive: true })
|
||||
}
|
||||
|
||||
// ── Check for existing cached binary before any network request ──
|
||||
// This allows llama.cpp to start offline when previously installed.
|
||||
if (version !== 'latest') {
|
||||
// Pinned version — check its specific directory
|
||||
const pinnedDir = path.join(cacheBase, version)
|
||||
const pinnedBinary = fs.existsSync(pinnedDir) ? findBinary(pinnedDir) : null
|
||||
if (pinnedBinary) {
|
||||
log.info(`Using cached llama-server binary (pinned ${version}): ${pinnedBinary}`)
|
||||
binaryPath = pinnedBinary
|
||||
onStatus?.('Ready')
|
||||
return pinnedBinary
|
||||
}
|
||||
} else {
|
||||
// 'latest' — scan all cached version directories for a usable binary
|
||||
try {
|
||||
const cachedVersions = fs.readdirSync(cacheBase, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory())
|
||||
.map((d) => d.name)
|
||||
|
||||
for (const cachedTag of cachedVersions) {
|
||||
const cachedBinary = findBinary(path.join(cacheBase, cachedTag))
|
||||
if (cachedBinary) {
|
||||
log.info(`Found cached llama-server binary (${cachedTag}): ${cachedBinary}`)
|
||||
// Still try to fetch release info to see if there's a newer version,
|
||||
// but if the network is unavailable, use the cached binary.
|
||||
binaryPath = cachedBinary
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Cache directory scan failed — proceed to network fetch
|
||||
}
|
||||
}
|
||||
|
||||
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' },
|
||||
signal: AbortSignal.timeout(10000)
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub API returned ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
releaseData = await response.json()
|
||||
} catch (error) {
|
||||
// Network unavailable — fall back to cached binary if we found one
|
||||
if (binaryPath) {
|
||||
log.info('Network unavailable, using cached llama-server binary:', binaryPath)
|
||||
onStatus?.('Ready (offline)')
|
||||
return binaryPath
|
||||
}
|
||||
throw new Error(
|
||||
`Failed to fetch release info (no internet?) and no cached llama.cpp binary found. ` +
|
||||
`Please connect to the internet for the initial llama.cpp installation. ` +
|
||||
`Original error: ${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. Verify network is available BEFORE destructive operations —
|
||||
// don't delete the old binary if we can't download a replacement.
|
||||
onStatus?.('Checking for updates…')
|
||||
let releaseTag: string
|
||||
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(10000)
|
||||
}
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub API returned ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
const data = await response.json()
|
||||
releaseTag = data.tag_name
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Cannot update llama.cpp: unable to reach GitHub. ` +
|
||||
`Please check your internet connection. (${error?.message ?? error})`
|
||||
)
|
||||
}
|
||||
|
||||
// 2. Stop if running
|
||||
await stopLlamaCpp()
|
||||
|
||||
// 3. Clear old cache directory (safe — we verified network above)
|
||||
const currentInfo = getLlamaCppInfo()
|
||||
if (currentInfo.version) {
|
||||
const cacheDir = path.join(getInstallDir(), '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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Temporarily enforce 'latest' in config so it fetches the newest
|
||||
const config = await getConfig()
|
||||
await setConfig({ llamaCpp: { ...config.llamaCpp, version: 'latest' } })
|
||||
|
||||
// 5. 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(getInstallDir(), 'llama.cpp')
|
||||
if (fs.existsSync(cacheBase)) {
|
||||
fs.rmSync(cacheBase, { recursive: true, force: true })
|
||||
log.info('Removed llama.cpp directory:', cacheBase)
|
||||
}
|
||||
|
||||
binaryPath = null
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
// @ts-nocheck
|
||||
|
||||
import crypto from 'crypto'
|
||||
import log from 'electron-log'
|
||||
import * as pty from 'node-pty'
|
||||
import {
|
||||
getPythonPath,
|
||||
getConfig,
|
||||
setConfig,
|
||||
installPackage,
|
||||
isPackageInstalled,
|
||||
isPythonInstalled,
|
||||
installPython,
|
||||
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,
|
||||
onStatus?: (status: string) => void
|
||||
): Promise<{ url: string; apiKey: string; pid: number }> => {
|
||||
if (!lock.acquire()) {
|
||||
return { url, apiKey, pid }
|
||||
}
|
||||
|
||||
await stopOpenTerminal()
|
||||
|
||||
if (!isPythonInstalled()) {
|
||||
log.info('Python not installed — installing automatically for Open Terminal…')
|
||||
onStatus?.('Installing Python…')
|
||||
try {
|
||||
const ok = await installPython(undefined, onStatus)
|
||||
if (!ok) throw new Error('Python installation returned false')
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Python is required for Open Terminal but installation failed: ${err?.message ?? err}`
|
||||
)
|
||||
}
|
||||
if (!isPythonInstalled()) {
|
||||
throw new Error(
|
||||
'Python was installed but could not be verified. Please restart the app and try again.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isPackageInstalled('open-terminal')) {
|
||||
log.info('open-terminal not installed, attempting install...')
|
||||
onStatus?.('Installing Open Terminal package…')
|
||||
try {
|
||||
await installPackage('open-terminal')
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Open Terminal is not installed and auto-install failed. ` +
|
||||
`Please connect to the internet and try again. (${err?.message ?? err})`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const pythonPath = getPythonPath()
|
||||
const host = '127.0.0.1'
|
||||
const config = await getConfig()
|
||||
const configEnvVars = config.envVars ?? {}
|
||||
|
||||
// Use persisted API key or generate and save a new one
|
||||
let generatedKey = config.openTerminal?.apiKey
|
||||
if (!generatedKey) {
|
||||
generatedKey = crypto.randomBytes(24).toString('base64url')
|
||||
await setConfig({
|
||||
openTerminal: { ...config.openTerminal, apiKey: generatedKey }
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// @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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
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))
|
||||
})
|
||||
|
||||
// ─── Theme Sync: Open WebUI → Desktop ───────────────────
|
||||
// Open WebUI calls window.applyTheme() after every theme change.
|
||||
// We inject this hook so the desktop shell can mirror the theme.
|
||||
contextBridge.exposeInMainWorld('applyTheme', () => {
|
||||
const theme = localStorage.getItem('theme') ?? 'system'
|
||||
ipcRenderer.sendToHost('webview:event', { type: 'theme:update', data: { theme } })
|
||||
})
|
||||
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
@@ -1,205 +1,203 @@
|
||||
import { ipcRenderer, contextBridge } from "electron";
|
||||
import { electronAPI } from "@electron-toolkit/preload";
|
||||
import { ipcRenderer, contextBridge } from 'electron'
|
||||
import { electronAPI } from '@electron-toolkit/preload'
|
||||
|
||||
const isLocalSource = () => {
|
||||
// Check if the execution environment is local
|
||||
const origin = window.location.origin;
|
||||
// ─── PTY MessagePort ────────────────────────────────────
|
||||
// MessagePorts stay 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
|
||||
if (activePtyPort) activePtyPort.close()
|
||||
activePtyPort = port
|
||||
port.onmessage = (ev: MessageEvent) => {
|
||||
if (ev.data?.type === 'output' && ptyOutputCallback) ptyOutputCallback(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
|
||||
);
|
||||
});
|
||||
});
|
||||
// ─── 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()
|
||||
})
|
||||
|
||||
// Custom APIs for renderer
|
||||
const api = {
|
||||
onLog: (callback: (message: string) => void) => {
|
||||
if (!isLocalSource()) {
|
||||
throw new Error(
|
||||
"Access restricted: This operation is only allowed in a local environment."
|
||||
);
|
||||
}
|
||||
onData: (callback: (data: any) => void) => {
|
||||
const handler = (_: any, data: any): void => callback(data)
|
||||
ipcRenderer.on('main:data', handler)
|
||||
return () => ipcRenderer.removeListener('main:data', handler)
|
||||
},
|
||||
|
||||
ipcRenderer.on("main:log", (_, message: string) => callback(message));
|
||||
},
|
||||
// App
|
||||
getAppInfo: () => ipcRenderer.invoke('app:info'),
|
||||
getVersion: () => ipcRenderer.invoke('get:version'),
|
||||
resetApp: () => ipcRenderer.invoke('app:reset'),
|
||||
getDefaultDataPath: () => ipcRenderer.invoke('app:defaultDataPath'),
|
||||
getInstallDir: () => ipcRenderer.invoke('app:installDir'),
|
||||
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 }),
|
||||
|
||||
send: async ({ type, data }: { type: string; data?: any }) => {
|
||||
return await ipcRenderer.invoke("renderer:data", { type, data });
|
||||
},
|
||||
// Config
|
||||
getConfig: () => ipcRenderer.invoke('get:config'),
|
||||
setConfig: (config: Record<string, any>) => ipcRenderer.invoke('set:config', config),
|
||||
|
||||
openInBrowser: async (url: string) => {
|
||||
if (!isLocalSource()) {
|
||||
throw new Error(
|
||||
"Access restricted: This operation is only allowed in a local environment."
|
||||
);
|
||||
}
|
||||
// Python/uv
|
||||
installPython: () => ipcRenderer.invoke('install:python'),
|
||||
getPythonStatus: () => ipcRenderer.invoke('status:python'),
|
||||
|
||||
await ipcRenderer.invoke("open:browser", { url });
|
||||
},
|
||||
// Package
|
||||
installPackage: () => ipcRenderer.invoke('install:package'),
|
||||
getPackageStatus: () => ipcRenderer.invoke('status:package'),
|
||||
|
||||
getVersion: async () => {
|
||||
if (!isLocalSource()) {
|
||||
throw new Error(
|
||||
"Access restricted: This operation is only allowed in a local environment."
|
||||
);
|
||||
}
|
||||
// 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'),
|
||||
|
||||
return await ipcRenderer.invoke("get:version");
|
||||
},
|
||||
|
||||
getConfig: async () => {
|
||||
if (!isLocalSource()) {
|
||||
throw new Error(
|
||||
"Access restricted: This operation is only allowed in a local environment."
|
||||
);
|
||||
}
|
||||
|
||||
return await ipcRenderer.invoke("get:config");
|
||||
},
|
||||
|
||||
setConfig: async (config: Record<string, any>) => {
|
||||
if (!isLocalSource()) {
|
||||
throw new Error(
|
||||
"Access restricted: This operation is only allowed in a local environment."
|
||||
);
|
||||
}
|
||||
|
||||
return await ipcRenderer.invoke("set:config", config);
|
||||
},
|
||||
|
||||
installPython: async () => {
|
||||
if (!isLocalSource()) {
|
||||
throw new Error(
|
||||
"Access restricted: This operation is only allowed in a local environment."
|
||||
);
|
||||
}
|
||||
|
||||
return await ipcRenderer.invoke("install:python");
|
||||
},
|
||||
|
||||
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");
|
||||
},
|
||||
|
||||
resetApp: async () => {
|
||||
if (!isLocalSource()) {
|
||||
throw new Error(
|
||||
"Access restricted: This operation is only allowed in a local environment."
|
||||
);
|
||||
}
|
||||
|
||||
return await ipcRenderer.invoke("app:reset");
|
||||
},
|
||||
|
||||
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);
|
||||
// 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
|
||||
}
|
||||
} else {
|
||||
// @ts-ignore (define in dts)
|
||||
window.electron = electronAPI;
|
||||
// @ts-ignore (define in dts)
|
||||
window.electronAPI = api;
|
||||
},
|
||||
|
||||
// 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
|
||||
}
|
||||
},
|
||||
|
||||
// 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'),
|
||||
|
||||
// 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: (repo?: string, filename?: string) =>
|
||||
ipcRenderer.invoke('huggingface:models:cancel', repo, filename),
|
||||
searchHfModels: (query: string, token?: string) =>
|
||||
ipcRenderer.invoke('huggingface:search', query, token),
|
||||
getHfRepoFiles: (repo: string, token?: string) =>
|
||||
ipcRenderer.invoke('huggingface:repo:files', repo, token),
|
||||
|
||||
// Package
|
||||
getPackageVersion: (packageName: string) => ipcRenderer.invoke('package:version', packageName),
|
||||
uninstallPackage: (packageName: string) => ipcRenderer.invoke('package:uninstall', packageName),
|
||||
|
||||
// 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'),
|
||||
|
||||
// Updater
|
||||
checkForUpdates: () => ipcRenderer.invoke('updater:check'),
|
||||
downloadUpdate: () => ipcRenderer.invoke('updater:download'),
|
||||
installUpdate: () => ipcRenderer.invoke('updater:install'),
|
||||
|
||||
// Changelog
|
||||
getChangelog: () => ipcRenderer.invoke('app:changelog'),
|
||||
|
||||
// Auth token relay from webview
|
||||
setAuthToken: (token: string) => ipcRenderer.invoke('app:setAuthToken', token)
|
||||
}
|
||||
|
||||
if (process.contextIsolated) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { ipcRenderer, contextBridge } from 'electron'
|
||||
|
||||
const api = {
|
||||
submitQuery: (query: string, images?: string[]): void => {
|
||||
ipcRenderer.invoke('spotlight:submit', query, images)
|
||||
},
|
||||
closeSpotlight: (): void => {
|
||||
ipcRenderer.invoke('spotlight:close')
|
||||
},
|
||||
captureRegion: (rect: {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}): Promise<string | null> => {
|
||||
return ipcRenderer.invoke('spotlight:captureRegion', rect)
|
||||
},
|
||||
savePosition: (offset: { x: number; y: number }): void => {
|
||||
ipcRenderer.invoke('spotlight:savePosition', offset)
|
||||
},
|
||||
onInit: (
|
||||
callback: (data: {
|
||||
barOffset: { x: number; y: number } | null
|
||||
screenSize: { width: number; height: number }
|
||||
query: string
|
||||
}) => void
|
||||
): void => {
|
||||
ipcRenderer.on('spotlight:init', (_event, data) => {
|
||||
callback(data)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (process.contextIsolated) {
|
||||
try {
|
||||
contextBridge.exposeInMainWorld('spotlightAPI', api)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
} else {
|
||||
// @ts-ignore
|
||||
window.spotlightAPI = api
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { ipcRenderer, contextBridge } from 'electron'
|
||||
|
||||
const api = {
|
||||
// Main process tells us to start/stop recording
|
||||
onRecordingState: (
|
||||
callback: (data: { recording: boolean }) => void
|
||||
): void => {
|
||||
ipcRenderer.on('voiceInput:state', (_event, data) => {
|
||||
callback(data)
|
||||
})
|
||||
},
|
||||
|
||||
// Request microphone permission (macOS system-level)
|
||||
checkMicPermission: (): Promise<string> => {
|
||||
return ipcRenderer.invoke('voiceInput:micPermission')
|
||||
},
|
||||
|
||||
// Send recorded audio to main process for transcription
|
||||
transcribe: (audioBuffer: ArrayBuffer, token?: string): Promise<any> => {
|
||||
return ipcRenderer.invoke('voiceInput:transcribe', audioBuffer, token)
|
||||
},
|
||||
|
||||
// Notify main process that transcription completed
|
||||
done: (text: string): void => {
|
||||
ipcRenderer.invoke('voiceInput:done', text)
|
||||
},
|
||||
|
||||
// Close/hide the voice input window
|
||||
close: (): void => {
|
||||
ipcRenderer.invoke('voiceInput:close')
|
||||
},
|
||||
|
||||
// Report an error
|
||||
error: (message: string): void => {
|
||||
ipcRenderer.invoke('voiceInput:error', message)
|
||||
}
|
||||
}
|
||||
|
||||
if (process.contextIsolated) {
|
||||
try {
|
||||
contextBridge.exposeInMainWorld('voiceInputAPI', api)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
} else {
|
||||
// @ts-ignore
|
||||
window.voiceInputAPI = api
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="script-src 'self'; style-src 'self' 'unsafe-inline';"
|
||||
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"
|
||||
/>
|
||||
</head>
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<!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>
|
||||
@@ -1,70 +1,69 @@
|
||||
<script lang="ts">
|
||||
import { toast, Toaster } from "svelte-sonner";
|
||||
import { onMount } from "svelte";
|
||||
import { onMount, onDestroy } from 'svelte'
|
||||
import { fade } from 'svelte/transition'
|
||||
import { appInfo, config, connections, serverInfo, appState } from './lib/stores'
|
||||
|
||||
import Controls from "./lib/components/Controls.svelte";
|
||||
import Installation from "./lib/components/Installation.svelte";
|
||||
import Loading from "./lib/components/Loading.svelte";
|
||||
import Main from './lib/components/Main.svelte'
|
||||
|
||||
import { info, config } from "./lib/stores";
|
||||
let themeMediaQuery: MediaQueryList
|
||||
let themeChangeHandler: ((e: MediaQueryListEvent) => void) | null = null
|
||||
|
||||
let installed = $state(false);
|
||||
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)
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
config.set(await window?.electronAPI?.getConfig());
|
||||
onMount(async () => {
|
||||
const api = window?.electronAPI
|
||||
if (!api) return
|
||||
|
||||
const pythonStatus = await window?.electronAPI?.getPythonStatus();
|
||||
if (pythonStatus) {
|
||||
const packageStatus = await window?.electronAPI?.getPackageStatus();
|
||||
if (packageStatus) {
|
||||
installed = true;
|
||||
} else {
|
||||
installed = false;
|
||||
}
|
||||
}
|
||||
appInfo.set(await api.getAppInfo())
|
||||
config.set(await api.getConfig())
|
||||
connections.set(await api.getConnections())
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
// Apply saved theme
|
||||
const savedTheme = (await api.getConfig())?.theme ?? 'system'
|
||||
applyResolvedTheme(savedTheme)
|
||||
|
||||
if (event.data?.type === "electron:reload") {
|
||||
window.location.reload();
|
||||
}
|
||||
// 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:server") {
|
||||
info.set(await window.electronAPI.getServerInfo());
|
||||
}
|
||||
});
|
||||
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 }))
|
||||
}
|
||||
})
|
||||
|
||||
info.set(await window.electronAPI.getServerInfo());
|
||||
setInterval(async () => {
|
||||
info.set(await window.electronAPI.getServerInfo());
|
||||
}, 1000);
|
||||
});
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<main class="w-screen h-screen bg-gray-900">
|
||||
{#if installed === null}
|
||||
<Loading />
|
||||
{:else if installed === false}
|
||||
<Installation bind:installed />
|
||||
{:else}
|
||||
<Controls bind:installed />
|
||||
{/if}
|
||||
<main class="w-full h-full bg-[#f5f5f7] dark:bg-[#0a0a0a]">
|
||||
<Main />
|
||||
</main>
|
||||
|
||||
<Toaster
|
||||
theme={window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light"}
|
||||
richColors
|
||||
position="top-center"
|
||||
/>
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/*
|
||||
The default border color has changed to `currentColor` in Tailwind CSS v4,
|
||||
so we've added these compatibility styles to make sure everything still
|
||||
looks the same as it did with Tailwind CSS v3.
|
||||
@variant dark (&:where(.dark, .dark *));
|
||||
|
||||
If we ever want to remove these styles, we need to add an explicit border
|
||||
color utility to any element that depends on these defaults.
|
||||
*/
|
||||
@layer base {
|
||||
*,
|
||||
::after,
|
||||
@@ -19,73 +13,17 @@
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html,
|
||||
pre {
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Inter", "Vazirmatn",
|
||||
ui-sans-serif, system-ui, "Segoe UI", Roboto, Ubuntu, Cantarell,
|
||||
"Noto Sans", sans-serif, "Helvetica Neue", Arial,
|
||||
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
|
||||
"Noto Color Emoji";
|
||||
}
|
||||
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
button {
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: theme(--color-gray-400);
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
@apply appearance-none size-3.5 align-middle bg-white border border-gray-300 rounded transition cursor-pointer focus:ring-2 focus:ring-blue-500 focus:outline-none dark:bg-gray-800 dark:border-gray-600 self-center;
|
||||
/* Center the custom mark */
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
}
|
||||
input[type="checkbox"]:checked {
|
||||
@apply bg-blue-600 border-blue-600;
|
||||
}
|
||||
input[type="checkbox"]:after {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
/* Hide by default */
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
/* SVG checkmark as background image */
|
||||
background: url('data:image/svg+xml;utf8,<svg viewBox="0 0 16 16" fill="none" stroke="white" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><path d="M4 8l3 3l5-5"/></svg>')
|
||||
center/80% no-repeat;
|
||||
}
|
||||
input[type="checkbox"]:checked:after {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@custom-variant hover (&:hover);
|
||||
|
||||
@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;
|
||||
}
|
||||
@@ -95,35 +33,10 @@
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.no-drag-region {
|
||||
.no-drag {
|
||||
-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;
|
||||
@@ -141,60 +54,48 @@ html {
|
||||
--color-gray-950: #0d0d0d;
|
||||
}
|
||||
|
||||
.tippy-box[data-theme~="dark"] {
|
||||
@apply rounded-lg bg-gray-950 text-xs border border-gray-900 shadow-xl;
|
||||
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~="transparent"] {
|
||||
@apply bg-transparent p-0 m-0;
|
||||
html.dark {
|
||||
background: #0a0a0a;
|
||||
color: #fafafa;
|
||||
}
|
||||
|
||||
.scrollbar-hidden:active::-webkit-scrollbar-thumb,
|
||||
.scrollbar-hidden:focus::-webkit-scrollbar-thumb,
|
||||
.scrollbar-hidden:hover::-webkit-scrollbar-thumb {
|
||||
visibility: visible;
|
||||
}
|
||||
.scrollbar-hidden::-webkit-scrollbar-thumb {
|
||||
visibility: hidden;
|
||||
body {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scrollbar-hidden::-webkit-scrollbar-corner {
|
||||
display: none;
|
||||
#app {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.scrollbar-none::-webkit-scrollbar {
|
||||
display: none; /* for Chrome, Safari and Opera */
|
||||
}
|
||||
|
||||
.scrollbar-none::-webkit-scrollbar-corner {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.scrollbar-none {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
::selection {
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
.dark ::selection {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar { width: 4px; }
|
||||
::-webkit-scrollbar-thumb {
|
||||
--tw-border-opacity: 1;
|
||||
background-color: rgba(215, 215, 215, 0.8);
|
||||
border-color: rgba(255, 255, 255, var(--tw-border-opacity));
|
||||
border-radius: 9999px;
|
||||
border-width: 1px;
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
border-radius: 99px;
|
||||
}
|
||||
|
||||
/* Dark theme scrollbar styles */
|
||||
.dark ::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(67, 67, 67, 0.8); /* Darker color for dark theme */
|
||||
border-color: rgba(0, 0, 0, var(--tw-border-opacity));
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
|
||||
::-webkit-scrollbar {
|
||||
height: 0.6rem;
|
||||
width: 0.4rem;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background-color: transparent;
|
||||
border-radius: 9999px;
|
||||
.xterm-screen {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
: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;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 5.7 KiB |
@@ -0,0 +1,171 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,526 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import logoImage from '../lib/assets/images/splash.png'
|
||||
|
||||
let inputEl = $state<HTMLInputElement | null>(null)
|
||||
let query = $state('')
|
||||
let images = $state<string[]>([])
|
||||
let errorMsg = $state('')
|
||||
let showHint = $state(true)
|
||||
|
||||
// Bar position within the fullscreen window
|
||||
let barX = $state(0)
|
||||
let barY = $state(160)
|
||||
let screenW = $state(1920)
|
||||
let screenH = $state(1080)
|
||||
|
||||
const BAR_W = 748
|
||||
|
||||
const api = window.spotlightAPI
|
||||
|
||||
// ─── Error Toast ───
|
||||
let errorTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const showError = (msg: string, duration = 4000) => {
|
||||
errorMsg = msg
|
||||
if (errorTimer) clearTimeout(errorTimer)
|
||||
errorTimer = setTimeout(() => { errorMsg = '' }, duration)
|
||||
}
|
||||
|
||||
// ─── Submit ───
|
||||
const submit = () => {
|
||||
const q = query.trim()
|
||||
if (!q && images.length === 0) return
|
||||
if (!api) return
|
||||
// Svelte 5 $state creates Proxy objects that Electron IPC can't serialize.
|
||||
// Spread into a plain array of plain strings for structured clone.
|
||||
const plainImages = images.length > 0 ? [...images].map((s) => s.slice(0)) : undefined
|
||||
api.submitQuery(q, plainImages)
|
||||
query = ''
|
||||
images = []
|
||||
}
|
||||
|
||||
// ─── Bar Dragging ───
|
||||
let barDragging = $state(false)
|
||||
let barDragStart = { mx: 0, my: 0, bx: 0, by: 0 }
|
||||
|
||||
const onBarMouseDown = (e: MouseEvent) => {
|
||||
// Only drag from the bar background, not from input or buttons
|
||||
const target = e.target as HTMLElement
|
||||
if (target.tagName === 'INPUT' || target.closest('button') || target.closest('.preview')) return
|
||||
e.preventDefault()
|
||||
barDragging = true
|
||||
barDragStart = { mx: e.clientX, my: e.clientY, bx: barX, by: barY }
|
||||
}
|
||||
|
||||
// ─── Region Selection ───
|
||||
let selecting = $state(false)
|
||||
let selStart = { x: 0, y: 0 }
|
||||
let selRect = $state({ x: 0, y: 0, w: 0, h: 0 })
|
||||
let didDrag = false
|
||||
const DRAG_THRESHOLD = 8
|
||||
|
||||
const onWrapperMouseDown = (e: MouseEvent) => {
|
||||
// Ignore if it's on the bar or preview
|
||||
if ((e.target as HTMLElement).closest('.bar') || (e.target as HTMLElement).closest('.preview')) return
|
||||
e.preventDefault()
|
||||
selStart = { x: e.clientX, y: e.clientY }
|
||||
selecting = true
|
||||
didDrag = false
|
||||
selRect = { x: e.clientX, y: e.clientY, w: 0, h: 0 }
|
||||
}
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
if (barDragging) {
|
||||
const dx = e.clientX - barDragStart.mx
|
||||
const dy = e.clientY - barDragStart.my
|
||||
barX = Math.max(0, Math.min(screenW - BAR_W, barDragStart.bx + dx))
|
||||
barY = Math.max(0, Math.min(screenH - 60, barDragStart.by + dy))
|
||||
return
|
||||
}
|
||||
if (selecting) {
|
||||
const dx = e.clientX - selStart.x
|
||||
const dy = e.clientY - selStart.y
|
||||
if (!didDrag && Math.abs(dx) + Math.abs(dy) > DRAG_THRESHOLD) {
|
||||
didDrag = true
|
||||
}
|
||||
selRect = {
|
||||
x: Math.min(e.clientX, selStart.x),
|
||||
y: Math.min(e.clientY, selStart.y),
|
||||
w: Math.abs(dx),
|
||||
h: Math.abs(dy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onMouseUp = async (e: MouseEvent) => {
|
||||
if (barDragging) {
|
||||
barDragging = false
|
||||
api?.savePosition({ x: barX, y: barY })
|
||||
return
|
||||
}
|
||||
if (selecting) {
|
||||
selecting = false
|
||||
showHint = false
|
||||
if (didDrag && selRect.w > 10 && selRect.h > 10) {
|
||||
// Capture the selected region
|
||||
const result = await api?.captureRegion({
|
||||
x: selRect.x,
|
||||
y: selRect.y,
|
||||
width: selRect.w,
|
||||
height: selRect.h
|
||||
})
|
||||
if (result === 'no-permission') {
|
||||
showError('Screen Recording permission required. Opening System Settings…')
|
||||
} else if (result && typeof result === 'string' && result.startsWith('data:')) {
|
||||
images = [...images, result]
|
||||
} else if (!result) {
|
||||
showError('Screenshot capture failed.')
|
||||
}
|
||||
selRect = { x: 0, y: 0, w: 0, h: 0 }
|
||||
} else {
|
||||
// Click on background (not a drag) — dismiss
|
||||
selRect = { x: 0, y: 0, w: 0, h: 0 }
|
||||
api?.closeSpotlight()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const removeImage = (index: number) => {
|
||||
images = images.filter((_, i) => i !== index)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
inputEl?.focus()
|
||||
window.addEventListener('focus', () => {
|
||||
inputEl?.focus()
|
||||
})
|
||||
|
||||
api?.onInit?.((data) => {
|
||||
if (data.screenSize) {
|
||||
screenW = data.screenSize.width
|
||||
screenH = data.screenSize.height
|
||||
}
|
||||
if (data.barOffset) {
|
||||
barX = data.barOffset.x
|
||||
barY = data.barOffset.y
|
||||
} else {
|
||||
// Default: center horizontally, near top
|
||||
barX = Math.round((screenW - BAR_W) / 2)
|
||||
barY = 160
|
||||
}
|
||||
if (data.query) {
|
||||
query = data.query
|
||||
requestAnimationFrame(() => inputEl?.select())
|
||||
}
|
||||
requestAnimationFrame(() => inputEl?.focus())
|
||||
|
||||
// Show screenshot hint
|
||||
showHint = true
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
onkeydown={(e) => e.key === 'Escape' && api?.closeSpotlight()}
|
||||
onmousemove={onMouseMove}
|
||||
onmouseup={onMouseUp}
|
||||
/>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="wrapper" onmousedown={onWrapperMouseDown}>
|
||||
<!-- Screen capture overlay -->
|
||||
{#if selecting && didDrag}
|
||||
<!-- Selection with shadow-based dimming -->
|
||||
<div
|
||||
class="selection"
|
||||
style="left:{selRect.x}px;top:{selRect.y}px;width:{selRect.w}px;height:{selRect.h}px"
|
||||
>
|
||||
<div class="handle tl"></div>
|
||||
<div class="handle tr"></div>
|
||||
<div class="handle bl"></div>
|
||||
<div class="handle br"></div>
|
||||
</div>
|
||||
<!-- Dimensions label -->
|
||||
<div
|
||||
class="dimensions"
|
||||
style="left:{selRect.x + selRect.w / 2}px;top:{selRect.y + selRect.h + 12}px"
|
||||
>
|
||||
{Math.round(selRect.w)} × {Math.round(selRect.h)}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Floating bar -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="bar"
|
||||
style="left:{barX}px;top:{barY}px"
|
||||
onmousedown={onBarMouseDown}
|
||||
>
|
||||
{#if images.length > 0}
|
||||
<div class="attachments">
|
||||
{#each images as img, i}
|
||||
<div class="preview-item">
|
||||
<img src={img} alt="Screenshot {i + 1}" />
|
||||
<button class="preview-remove" onclick={() => removeImage(i)} aria-label="Remove">×</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="input-row">
|
||||
<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 || images.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>
|
||||
|
||||
<!-- Screenshot hint -->
|
||||
{#if showHint && images.length === 0 && !selecting}
|
||||
<div class="hint">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 8V6a2 2 0 012-2h2" /><path d="M4 16v2a2 2 0 002 2h2" /><path d="M16 4h2a2 2 0 012 2v2" /><path d="M16 20h2a2 2 0 002-2v-2" />
|
||||
</svg>
|
||||
Drag anywhere to capture a screenshot
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Error toast -->
|
||||
{#if errorMsg}
|
||||
<div class="error-toast">{errorMsg}</div>
|
||||
{/if}
|
||||
</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 {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.selection {
|
||||
position: absolute;
|
||||
border: 2px solid rgba(255, 255, 255, 0.9);
|
||||
border-radius: 2px;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
/* Massive spread shadow dims everything outside the selection */
|
||||
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.handle {
|
||||
position: absolute;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: white;
|
||||
border-radius: 1px;
|
||||
box-shadow: 0 0 2px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.handle.tl { top: -3px; left: -3px; }
|
||||
.handle.tr { top: -3px; right: -3px; }
|
||||
.handle.bl { bottom: -3px; left: -3px; }
|
||||
.handle.br { bottom: -3px; right: -3px; }
|
||||
|
||||
.dimensions {
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Archivo', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
backdrop-filter: blur(4px);
|
||||
pointer-events: none;
|
||||
z-index: 11;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bar {
|
||||
position: absolute;
|
||||
width: 748px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 14px;
|
||||
font-family: 'Archivo', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||||
cursor: grab;
|
||||
z-index: 100;
|
||||
overflow: hidden;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
.input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 52px;
|
||||
padding: 0 9px 0 15px;
|
||||
}
|
||||
|
||||
.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;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Inline Attachments ─── */
|
||||
.attachments {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 10px 12px 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.preview-item {
|
||||
position: relative;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.preview-item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.preview-remove {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.preview-item:hover .preview-remove {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ─── Error Toast ─── */
|
||||
.error-toast {
|
||||
position: fixed;
|
||||
bottom: 80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 10px 20px;
|
||||
border-radius: 10px;
|
||||
font-family: 'Archivo', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
background: rgba(220, 38, 38, 0.9);
|
||||
backdrop-filter: blur(8px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
|
||||
z-index: 200;
|
||||
pointer-events: none;
|
||||
animation: toast-in 0.25s ease-out;
|
||||
}
|
||||
|
||||
@keyframes toast-in {
|
||||
from { opacity: 0; transform: translateX(-50%) translateY(10px); }
|
||||
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
}
|
||||
|
||||
/* ─── Screenshot Hint ─── */
|
||||
.hint {
|
||||
position: fixed;
|
||||
bottom: 48px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
border-radius: 10px;
|
||||
font-family: 'Archivo', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 450;
|
||||
z-index: 200;
|
||||
pointer-events: none;
|
||||
animation: hint-in 0.25s ease-out;
|
||||
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 0.5px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
@keyframes hint-in {
|
||||
from { opacity: 0; transform: translateX(-50%) translateY(6px); }
|
||||
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
}
|
||||
</style>
|
||||
@@ -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,337 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte'
|
||||
|
||||
const api = window.voiceInputAPI
|
||||
|
||||
let recording = $state(false)
|
||||
let transcribing = $state(false)
|
||||
let duration = $state(0)
|
||||
let errorMsg = $state('')
|
||||
|
||||
// Waveform
|
||||
let levels: number[] = $state(Array(5).fill(0.15))
|
||||
let animFrame: number | null = null
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
let errorTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// Audio
|
||||
let mediaRecorder: MediaRecorder | null = null
|
||||
let audioChunks: Blob[] = []
|
||||
let mediaStream: MediaStream | null = null
|
||||
let analyser: AnalyserNode | null = null
|
||||
let audioCtx: AudioContext | null = null
|
||||
let dataArray: Uint8Array | null = null
|
||||
|
||||
// Dragging
|
||||
let dragging = false
|
||||
let dragStart = { mx: 0, my: 0, wx: 0, wy: 0 }
|
||||
|
||||
const formatDuration = (s: number): string => {
|
||||
const m = Math.floor(s / 60)
|
||||
return `${m}:${(s % 60).toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const animateLevel = () => {
|
||||
if (analyser && dataArray) {
|
||||
analyser.getByteFrequencyData(dataArray)
|
||||
// Sample 5 frequency bands
|
||||
const bands = 5
|
||||
const step = Math.floor(dataArray.length / bands)
|
||||
levels = Array.from({ length: bands }, (_, i) => {
|
||||
const val = dataArray![i * step] / 255
|
||||
return Math.max(0.15, val)
|
||||
})
|
||||
} else {
|
||||
levels = levels.map(() => 0.15 + Math.random() * 0.6)
|
||||
}
|
||||
animFrame = requestAnimationFrame(animateLevel)
|
||||
}
|
||||
|
||||
const showError = (msg: string) => {
|
||||
errorMsg = msg
|
||||
if (errorTimer) clearTimeout(errorTimer)
|
||||
errorTimer = setTimeout(() => {
|
||||
errorMsg = ''
|
||||
api?.close()
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
const startRecording = async () => {
|
||||
// Reset all state from any previous session
|
||||
cleanup()
|
||||
errorMsg = ''
|
||||
transcribing = false
|
||||
recording = true
|
||||
duration = 0
|
||||
audioChunks = []
|
||||
animateLevel() // show placeholder bars immediately
|
||||
|
||||
// Wait for the start chime (played from main process) to finish
|
||||
// before activating mic — macOS ducks audio when mic activates
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
|
||||
try {
|
||||
// Request system-level mic permission (macOS) before activating the mic
|
||||
const permStatus = await api?.checkMicPermission()
|
||||
if (permStatus === 'denied') {
|
||||
const msg = 'Microphone access denied. Enable it in System Settings → Privacy & Security → Microphone, then restart the app.'
|
||||
showError(msg)
|
||||
api?.error(msg)
|
||||
return
|
||||
}
|
||||
|
||||
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
audioChunks = []
|
||||
|
||||
// Set up analyser for real audio levels
|
||||
audioCtx = new AudioContext()
|
||||
analyser = audioCtx.createAnalyser()
|
||||
analyser.fftSize = 64
|
||||
dataArray = new Uint8Array(analyser.frequencyBinCount)
|
||||
const source = audioCtx.createMediaStreamSource(mediaStream)
|
||||
source.connect(analyser)
|
||||
|
||||
mediaRecorder = new MediaRecorder(mediaStream, {
|
||||
mimeType: MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
|
||||
? 'audio/webm;codecs=opus'
|
||||
: 'audio/webm'
|
||||
})
|
||||
|
||||
mediaRecorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) audioChunks.push(e.data)
|
||||
}
|
||||
|
||||
mediaRecorder.start(250)
|
||||
timer = setInterval(() => { duration++ }, 1000)
|
||||
} catch (err: any) {
|
||||
const msg = err?.name === 'NotAllowedError'
|
||||
? 'Microphone access denied. Check system permissions.'
|
||||
: err?.name === 'NotFoundError'
|
||||
? 'No microphone found. Connect a microphone and try again.'
|
||||
: err?.message || 'Mic access failed'
|
||||
showError(msg)
|
||||
api?.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
recording = false
|
||||
transcribing = false
|
||||
if (timer) { clearInterval(timer); timer = null }
|
||||
if (animFrame) { cancelAnimationFrame(animFrame); animFrame = null }
|
||||
levels = Array(5).fill(0.15)
|
||||
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
||||
mediaRecorder.stop()
|
||||
}
|
||||
if (mediaStream) {
|
||||
mediaStream.getTracks().forEach((t) => t.stop())
|
||||
mediaStream = null
|
||||
}
|
||||
if (audioCtx) {
|
||||
audioCtx.close()
|
||||
audioCtx = null
|
||||
analyser = null
|
||||
}
|
||||
mediaRecorder = null
|
||||
}
|
||||
|
||||
const cancelRecording = () => {
|
||||
cleanup()
|
||||
api?.close()
|
||||
}
|
||||
|
||||
const stopRecording = async () => {
|
||||
if (!mediaRecorder || mediaRecorder.state === 'inactive') {
|
||||
cancelRecording()
|
||||
return
|
||||
}
|
||||
|
||||
// Too short — treat as cancel (less than 0.8 seconds)
|
||||
if (duration < 1) {
|
||||
cancelRecording()
|
||||
return
|
||||
}
|
||||
|
||||
recording = false
|
||||
if (timer) { clearInterval(timer); timer = null }
|
||||
if (animFrame) { cancelAnimationFrame(animFrame); animFrame = null }
|
||||
levels = Array(5).fill(0.15)
|
||||
|
||||
const audioBlob = await new Promise<Blob>((resolve) => {
|
||||
mediaRecorder!.onstop = () => {
|
||||
resolve(new Blob(audioChunks, { type: mediaRecorder!.mimeType }))
|
||||
}
|
||||
mediaRecorder!.stop()
|
||||
})
|
||||
|
||||
if (mediaStream) {
|
||||
mediaStream.getTracks().forEach((t) => t.stop())
|
||||
mediaStream = null
|
||||
}
|
||||
if (audioCtx) {
|
||||
audioCtx.close()
|
||||
audioCtx = null
|
||||
analyser = null
|
||||
}
|
||||
|
||||
if (audioBlob.size < 4096) {
|
||||
api?.close()
|
||||
return
|
||||
}
|
||||
|
||||
transcribing = true
|
||||
try {
|
||||
const buffer = await audioBlob.arrayBuffer()
|
||||
const result = await api?.transcribe(buffer)
|
||||
const text = result?.text?.trim()
|
||||
if (text) {
|
||||
api?.done(text)
|
||||
} else {
|
||||
api?.close()
|
||||
}
|
||||
} catch (err: any) {
|
||||
const msg = err?.message || 'Transcription failed'
|
||||
showError(msg)
|
||||
api?.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
dragging = true
|
||||
dragStart = { mx: e.screenX, my: e.screenY, wx: window.screenX, wy: window.screenY }
|
||||
}
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
if (!dragging) return
|
||||
window.moveTo(
|
||||
dragStart.wx + (e.screenX - dragStart.mx),
|
||||
dragStart.wy + (e.screenY - dragStart.my)
|
||||
)
|
||||
}
|
||||
|
||||
const onMouseUp = () => { dragging = false }
|
||||
|
||||
onMount(() => {
|
||||
api?.onRecordingState((data) => {
|
||||
if (data.recording && !recording) startRecording()
|
||||
else if (!data.recording && recording) stopRecording()
|
||||
})
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
cleanup()
|
||||
if (errorTimer) clearTimeout(errorTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
onkeydown={(e) => { if (e.key === 'Escape') cancelRecording() }}
|
||||
onmousemove={onMouseMove}
|
||||
onmouseup={onMouseUp}
|
||||
/>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="pill" onmousedown={onMouseDown}>
|
||||
{#if recording}
|
||||
<div class="bars">
|
||||
{#each levels as level}
|
||||
<div class="bar" style="height: {6 + level * 22}px"></div>
|
||||
{/each}
|
||||
</div>
|
||||
<span class="time">{formatDuration(duration)}</span>
|
||||
{:else if transcribing}
|
||||
<div class="loader"></div>
|
||||
{:else if errorMsg}
|
||||
<span class="err">{errorMsg}</span>
|
||||
{/if}
|
||||
</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;
|
||||
}
|
||||
|
||||
.pill {
|
||||
position: absolute;
|
||||
top: 50%; left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 20px;
|
||||
height: 44px;
|
||||
border-radius: 22px;
|
||||
cursor: grab;
|
||||
font-family: 'Archivo', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||||
animation: appear 0.15s ease-out;
|
||||
|
||||
background: rgba(30, 30, 30, 0.78);
|
||||
backdrop-filter: blur(40px) saturate(1.8);
|
||||
-webkit-backdrop-filter: blur(40px) saturate(1.8);
|
||||
border: 0.5px solid rgba(255, 255, 255, 0.12);
|
||||
box-shadow:
|
||||
0 2px 12px rgba(0, 0, 0, 0.35),
|
||||
inset 0 0.5px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.pill:active { cursor: grabbing; }
|
||||
|
||||
@keyframes appear {
|
||||
from { opacity: 0; transform: translate(-50%, -50%) scale(0.92); }
|
||||
to { opacity: 1; transform: translate(-50%, -50%) scale(1); }
|
||||
}
|
||||
|
||||
.bars {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.bar {
|
||||
width: 4px;
|
||||
border-radius: 99px;
|
||||
background: #fff;
|
||||
opacity: 0.9;
|
||||
transition: height 60ms ease-out;
|
||||
min-height: 6px;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.loader {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.15);
|
||||
border-top-color: rgba(255, 255, 255, 0.8);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.err {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #ff6b6b;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,25 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 1022 KiB |
|
Before Width: | Height: | Size: 782 KiB |
|
Before Width: | Height: | Size: 2.8 MiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 432 KiB |
|
Before Width: | Height: | Size: 1.2 MiB |
@@ -1,118 +0,0 @@
|
||||
<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 { installed = $bindable(false) } = $props();
|
||||
|
||||
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 bind:installed info={$info} />
|
||||
{:else if selectedTab === "about"}
|
||||
<About />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<Launching />
|
||||
{/if}
|
||||
@@ -1,115 +0,0 @@
|
||||
<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}
|
||||
@@ -1,276 +0,0 @@
|
||||
<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, installed = $bindable(false) } = $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:show={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 {
|
||||
installed = null;
|
||||
config = null;
|
||||
await window.electronAPI.resetApp();
|
||||
toast.success("App has been reset successfully.");
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
} 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}
|
||||
@@ -1,54 +0,0 @@
|
||||
<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>
|
||||
@@ -1,236 +0,0 @@
|
||||
<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 nasaImage from "../assets/images/nasa.jpg";
|
||||
import neomImage from "../assets/images/neom.jpg";
|
||||
|
||||
let { installed = $bindable() } = $props();
|
||||
|
||||
let images = [galaxyImage, greenImage, adamImage, 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, We’ll notify you
|
||||
when it’s 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>
|
||||
|
||||
<div
|
||||
class="text-xs mt-3 text-gray-500 cursor-pointer"
|
||||
in:fly={{
|
||||
delay: 500,
|
||||
duration: 750,
|
||||
y: 10,
|
||||
}}
|
||||
>
|
||||
By continuing, you agree to our
|
||||
<button
|
||||
class="underline"
|
||||
onclick={() => {
|
||||
window.electronAPI.openInBrowser(
|
||||
"https://github.com/open-webui/desktop/blob/main/LICENSE"
|
||||
);
|
||||
}}>license agreement</button
|
||||
>.
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,98 +0,0 @@
|
||||
<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 nasaImage from "../assets/images/nasa.jpg";
|
||||
import neomImage from "../assets/images/neom.jpg";
|
||||
import { fly } from "svelte/transition";
|
||||
|
||||
let images = [galaxyImage, greenImage, adamImage, 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>
|
||||
@@ -1,17 +0,0 @@
|
||||
<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>
|
||||
@@ -0,0 +1,142 @@
|
||||
<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}
|
||||
@@ -0,0 +1,627 @@
|
||||
<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 localConn = $derived(localInstalled
|
||||
? { id: 'local', name: 'Open WebUI', type: 'local' as const, url: `http://127.0.0.1:${$config?.localServer?.port ?? 8080}` }
|
||||
: null
|
||||
)
|
||||
const remoteConnections = $derived($connections ?? [])
|
||||
|
||||
// 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('')
|
||||
let openTerminalSetupStatus = $state('')
|
||||
|
||||
const startInstall = async (options?: { installOpenTerminal?: boolean; installLlamaCpp?: boolean; installDir?: string }) => {
|
||||
installPhase = 'working'
|
||||
installError = ''
|
||||
installStatus = ''
|
||||
toastVisible = false
|
||||
try {
|
||||
// Save custom install directory before anything else
|
||||
if (options?.installDir) {
|
||||
const currentDir = await window.electronAPI.getInstallDir()
|
||||
if (options.installDir !== currentDir) {
|
||||
await window.electronAPI.setConfig({ installDir: options.installDir })
|
||||
}
|
||||
}
|
||||
|
||||
// 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.setDefaultConnection('local')
|
||||
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 = ''
|
||||
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
|
||||
})
|
||||
config.set(await window.electronAPI.getConfig())
|
||||
url = ''
|
||||
error = ''
|
||||
showAddConnectionModal = false
|
||||
view = 'welcome'
|
||||
} catch {
|
||||
error = $i18n.t('setup.connectionFailed')
|
||||
} finally {
|
||||
connecting = false
|
||||
}
|
||||
}
|
||||
|
||||
const connect = (id: string) => {
|
||||
showingLogs = false
|
||||
// Toggle: clicking the active connection unselects it
|
||||
if (activeConnectionId === id && view === 'connected') {
|
||||
connectingId = ''
|
||||
activeConnectionId = ''
|
||||
connectedUrl = ''
|
||||
view = 'welcome'
|
||||
return
|
||||
}
|
||||
// Persist as default so spotlight/startup always use the last-selected connection
|
||||
window.electronAPI.setDefaultConnection(id)
|
||||
// Already-open connection — just switch to it
|
||||
if (openConnections.has(id)) {
|
||||
connectingId = ''
|
||||
activeConnectionId = id
|
||||
connectedUrl = openConnections.get(id)!
|
||||
view = 'connected'
|
||||
return
|
||||
}
|
||||
|
||||
activeConnectionId = id
|
||||
|
||||
if (id === 'local') {
|
||||
// Local needs server start — use IPC (no renderer-side conn needed)
|
||||
connectingId = id
|
||||
view = 'welcome'
|
||||
window.electronAPI.connectTo(id).then((result: any) => {
|
||||
if (!result?.url) {
|
||||
if (connectingId === id) connectingId = ''
|
||||
return
|
||||
}
|
||||
if (!openConnections.has(result.connectionId)) {
|
||||
openConnections.set(result.connectionId, result.url)
|
||||
openConnections = new Map(openConnections)
|
||||
}
|
||||
if (connectingId === id) {
|
||||
connectedUrl = result.url
|
||||
activeConnectionId = result.connectionId
|
||||
connectingId = ''
|
||||
if (installPhase !== 'working') {
|
||||
view = 'connected'
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
const conn = ($connections ?? []).find((c) => c.id === id)
|
||||
if (!conn) return
|
||||
// Remote — open immediately, no IPC needed
|
||||
connectingId = ''
|
||||
openConnections.set(id, conn.url)
|
||||
openConnections = new Map(openConnections)
|
||||
connectedUrl = conn.url
|
||||
view = 'connected'
|
||||
}
|
||||
}
|
||||
|
||||
const disconnect = () => {
|
||||
activeConnectionId = ''
|
||||
connectedUrl = ''
|
||||
view = 'welcome'
|
||||
}
|
||||
|
||||
const remove = async (id: string) => {
|
||||
await window.electronAPI.removeConnection(id)
|
||||
config.set(await window.electronAPI.getConfig())
|
||||
if (activeConnectionId === id) {
|
||||
disconnect()
|
||||
}
|
||||
openConnections.delete(id)
|
||||
openConnections = new Map(openConnections)
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (activeConnectionId === 'local') {
|
||||
activeConnectionName = localConn?.name ?? 'Open WebUI'
|
||||
isLocalConnection = true
|
||||
} else {
|
||||
const conn = ($connections ?? []).find((c) => c.id === activeConnectionId)
|
||||
activeConnectionName = conn?.name ?? ''
|
||||
isLocalConnection = false
|
||||
}
|
||||
})
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// ── Webview event delivery ─────────────────────────────
|
||||
// Single path: all events from the main process flow through here.
|
||||
// Query events target a specific webview; everything else broadcasts.
|
||||
const sendToWebview = (event: any, connId?: string) => {
|
||||
const container = document.querySelector('.content-webview-container')
|
||||
if (!container) return
|
||||
|
||||
const webviews = connId
|
||||
? [container.querySelector(`webview[partition="persist:connection-${connId}"]`) as any].filter(Boolean)
|
||||
: Array.from(container.querySelectorAll('webview'))
|
||||
|
||||
for (const wv of webviews) {
|
||||
try {
|
||||
// Attempt to send — throws if webview hasn't fired dom-ready yet
|
||||
wv.send('desktop:event', event)
|
||||
} catch {
|
||||
// Webview not ready — queue delivery until dom-ready
|
||||
const onReady = () => {
|
||||
wv.removeEventListener('dom-ready', onReady)
|
||||
try { wv.send('desktop:event', event) } catch (_) {}
|
||||
}
|
||||
wv.addEventListener('dom-ready', onReady)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for events from main process
|
||||
onMount(() => {
|
||||
window.electronAPI.onData((data: any) => {
|
||||
// ── Connection opened (startup, tray click) ───────
|
||||
if (data.type === 'connection:open' && data.data?.url) {
|
||||
const connId = data.data.connectionId ?? ''
|
||||
const incomingUrl = data.data.url
|
||||
|
||||
if (!openConnections.has(connId)) {
|
||||
openConnections.set(connId, incomingUrl)
|
||||
openConnections = new Map(openConnections)
|
||||
}
|
||||
|
||||
if (view !== 'connected') {
|
||||
connectedUrl = openConnections.get(connId) ?? incomingUrl
|
||||
activeConnectionId = connId
|
||||
if (installPhase !== 'working') view = 'connected'
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ── Spotlight / desktop query ─────────────────────
|
||||
if (data.type === 'query' && (data.data?.query || data.data?.files?.length)) {
|
||||
const connId = data.data.connectionId ?? ''
|
||||
const query = data.data.query
|
||||
const files = data.data.files
|
||||
const baseUrl = data.data.url ?? ''
|
||||
|
||||
if (!openConnections.has(connId)) {
|
||||
openConnections.set(connId, baseUrl)
|
||||
openConnections = new Map(openConnections)
|
||||
connectedUrl = baseUrl
|
||||
} else {
|
||||
connectedUrl = openConnections.get(connId)!
|
||||
}
|
||||
activeConnectionId = connId
|
||||
if (installPhase !== 'working') view = 'connected'
|
||||
|
||||
// Targeted delivery — wait a frame for the webview DOM to exist
|
||||
requestAnimationFrame(() => {
|
||||
sendToWebview({ type: 'query', data: { query, files } }, connId)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// ── Call shortcut ─────────────────────────────────
|
||||
if (data.type === 'call' && data.data?.connectionId) {
|
||||
const connId = data.data.connectionId ?? ''
|
||||
const baseUrl = data.data.url ?? ''
|
||||
|
||||
if (!openConnections.has(connId)) {
|
||||
openConnections.set(connId, baseUrl)
|
||||
openConnections = new Map(openConnections)
|
||||
connectedUrl = baseUrl
|
||||
} else {
|
||||
connectedUrl = openConnections.get(connId)!
|
||||
}
|
||||
activeConnectionId = connId
|
||||
if (installPhase !== 'working') view = 'connected'
|
||||
|
||||
// Targeted delivery — wait a frame for the webview DOM to exist
|
||||
requestAnimationFrame(() => {
|
||||
sendToWebview({ type: 'call' }, connId)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// ── Desktop-only state (not forwarded to webviews) ─
|
||||
if (data.type === 'status:open-terminal') { openTerminalStatus = data.data; return }
|
||||
if (data.type === 'status:open-terminal-setup') { openTerminalSetupStatus = data.data ?? ''; return }
|
||||
if (data.type === 'open-terminal:ready') { openTerminalInfo = data.data; openTerminalStatus = 'started'; openTerminalSetupStatus = ''; return }
|
||||
if (data.type === 'status:llamacpp') { llamaCppStatus = data.data; return }
|
||||
if (data.type === 'status:llamacpp-setup') { llamaCppSetupStatus = data.data ?? ''; return }
|
||||
if (data.type === 'llamacpp:ready') { llamaCppInfo = data.data; llamaCppStatus = 'started'; llamaCppSetupStatus = ''; return }
|
||||
if (data.type === 'status:install') { installStatus = data.data ?? ''; return }
|
||||
if (data.type === 'packages:changed') {
|
||||
localInstalled = !!data.data?.['open-webui']
|
||||
return
|
||||
}
|
||||
if (data.type === 'connections:changed') {
|
||||
connections.set(data.data ?? [])
|
||||
return
|
||||
}
|
||||
|
||||
// ── Everything else → broadcast to all webviews ───
|
||||
sendToWebview(data)
|
||||
})
|
||||
|
||||
// Auto-connect to the default connection on startup so the webview
|
||||
// is pre-loaded and ready for spotlight queries.
|
||||
window.electronAPI.getConfig().then((cfg: any) => {
|
||||
if (cfg?.defaultConnectionId && !activeConnectionId) {
|
||||
connect(cfg.defaultConnectionId)
|
||||
}
|
||||
})
|
||||
|
||||
// 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
|
||||
openTerminalSetupStatus = ''
|
||||
} else {
|
||||
openTerminalStatus = 'starting'
|
||||
openTerminalSetupStatus = ''
|
||||
const result = await window.electronAPI.startOpenTerminal()
|
||||
if (result) {
|
||||
openTerminalInfo = result
|
||||
openTerminalStatus = 'started'
|
||||
} else {
|
||||
openTerminalStatus = 'failed'
|
||||
}
|
||||
openTerminalSetupStatus = ''
|
||||
}
|
||||
}
|
||||
|
||||
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 })
|
||||
}}
|
||||
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…' : openTerminalSetupStatus || (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>
|
||||
@@ -0,0 +1,114 @@
|
||||
<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>
|
||||
@@ -0,0 +1,523 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { fade, fly } from 'svelte/transition'
|
||||
import { 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; installDir?: string }) => 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())
|
||||
|
||||
// Track webview load errors per connection
|
||||
let webviewErrors: Map<string, { code: number; description: string; url: string }> = $state(new Map())
|
||||
|
||||
// Content preload path for webview bridge
|
||||
let contentPreloadPath: string = $state('')
|
||||
|
||||
// Server is starting up (local)
|
||||
const serverStarting = $derived(
|
||||
localInstalled && (
|
||||
$serverInfo?.status === 'starting' ||
|
||||
($serverInfo?.status === 'running' && !$serverInfo?.reachable)
|
||||
)
|
||||
)
|
||||
|
||||
const activeWebviewError = $derived(
|
||||
view === 'connected' && activeConnectionId
|
||||
? webviewErrors.get(activeConnectionId) ?? null
|
||||
: null
|
||||
)
|
||||
|
||||
const isLoading = $derived(
|
||||
connectingId !== '' ||
|
||||
(serverStarting && activeConnectionId === 'local') ||
|
||||
(view === 'connected' && !activeWebviewError && webviewLoading.get(activeConnectionId) === true)
|
||||
)
|
||||
|
||||
const retryActiveWebview = () => {
|
||||
const wv = document.querySelector(
|
||||
`webview[partition="persist:connection-${activeConnectionId}"]`
|
||||
) as any
|
||||
if (wv?.reload) {
|
||||
webviewErrors.delete(activeConnectionId)
|
||||
webviewErrors = new Map(webviewErrors)
|
||||
wv.reload()
|
||||
}
|
||||
}
|
||||
|
||||
const openActiveInBrowser = () => {
|
||||
const connUrl = openConnections.get(activeConnectionId)
|
||||
if (connUrl) {
|
||||
window.electronAPI.openInBrowser(connUrl)
|
||||
}
|
||||
}
|
||||
|
||||
// Attach load event listeners and IPC forwarding to webviews
|
||||
onMount(async () => {
|
||||
// Fetch the content preload path once
|
||||
contentPreloadPath = await window.electronAPI.getContentPreloadPath()
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
// Track load failures so we can show an error overlay
|
||||
wv.addEventListener('did-fail-load', (event: any) => {
|
||||
// Ignore sub-resource failures and aborted navigations (-3)
|
||||
if (event.errorCode === -3 || event.isMainFrame === false) return
|
||||
webviewErrors.set(connId, {
|
||||
code: event.errorCode,
|
||||
description: event.errorDescription || 'Unknown error',
|
||||
url: event.validatedURL || ''
|
||||
})
|
||||
webviewErrors = new Map(webviewErrors)
|
||||
})
|
||||
|
||||
// Clear error when a navigation succeeds (retry, redirect, etc.)
|
||||
wv.addEventListener('did-navigate', () => {
|
||||
if (webviewErrors.has(connId)) {
|
||||
webviewErrors.delete(connId)
|
||||
webviewErrors = new Map(webviewErrors)
|
||||
}
|
||||
})
|
||||
|
||||
// Renderer process crash
|
||||
wv.addEventListener('crashed', () => {
|
||||
webviewErrors.set(connId, {
|
||||
code: -1,
|
||||
description: 'crashed',
|
||||
url: ''
|
||||
})
|
||||
webviewErrors = new Map(webviewErrors)
|
||||
})
|
||||
|
||||
// Log guest page console messages for debugging blank-page issues (#124)
|
||||
wv.addEventListener('console-message', (event: any) => {
|
||||
if (event.level >= 2) { // warnings and errors only
|
||||
console.warn(`[webview:${connId}]`, event.message)
|
||||
}
|
||||
})
|
||||
|
||||
// If this webview was created before the preload path resolved
|
||||
// (race between auto-connect and async IPC), the preload didn't
|
||||
// attach. Force a reload now so it picks up the correct preload.
|
||||
if (contentPreloadPath && wv.getAttribute('preload') !== contentPreloadPath) {
|
||||
wv.setAttribute('preload', contentPreloadPath)
|
||||
wv.reload()
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
// Handle auth token relay from webview
|
||||
if (requestData.type === 'token:update' && requestData.token) {
|
||||
window.electronAPI.setAuthToken?.(requestData.token)
|
||||
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)
|
||||
} else if (event.channel === 'webview:event') {
|
||||
const payload = event.args?.[0]
|
||||
if (!payload?.type) return
|
||||
|
||||
if (payload.type === 'theme:update') {
|
||||
const webuiTheme = payload.data?.theme ?? 'system'
|
||||
|
||||
// Map Open WebUI theme names to desktop-compatible values
|
||||
let desktopTheme: string
|
||||
if (webuiTheme === 'system') {
|
||||
desktopTheme = 'system'
|
||||
} else if (webuiTheme.includes('dark')) {
|
||||
desktopTheme = 'dark'
|
||||
} else {
|
||||
desktopTheme = 'light'
|
||||
}
|
||||
|
||||
// Resolve and apply CSS class
|
||||
let resolved = desktopTheme
|
||||
if (desktopTheme === 'system') {
|
||||
resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
document.documentElement.classList.remove('light', 'dark')
|
||||
document.documentElement.classList.add(resolved)
|
||||
|
||||
// Persist to desktop config
|
||||
await window.electronAPI.setConfig({ theme: desktopTheme })
|
||||
config.set(await window.electronAPI.getConfig())
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
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'}"
|
||||
partition="persist:connection-{connId}"
|
||||
preload={contentPreloadPath}
|
||||
allowpopups
|
||||
></webview>
|
||||
{/each}
|
||||
|
||||
<!-- Error overlay when webview fails to load -->
|
||||
{#if activeWebviewError}
|
||||
<div class="absolute inset-0 z-20 flex items-center justify-center bg-[#eee] dark:bg-[#111]" transition:fade={{ duration: 200 }}>
|
||||
<div class="text-center max-w-sm px-6">
|
||||
<div class="mx-auto mb-4 w-10 h-10 rounded-full bg-black/[0.04] dark:bg-white/[0.06] flex items-center justify-center">
|
||||
{#if activeWebviewError.code === -1}
|
||||
<svg class="w-5 h-5 opacity-30" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="w-5 h-5 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}
|
||||
</div>
|
||||
<div class="text-[14px] font-medium mb-1 opacity-80">
|
||||
{activeWebviewError.code === -1 ? $i18n.t('setup.pageCrashed') : $i18n.t('setup.couldNotLoadPage')}
|
||||
</div>
|
||||
<div class="text-[12px] opacity-30 mb-1">{activeWebviewError.description}</div>
|
||||
{#if activeWebviewError.url}
|
||||
<div class="text-[11px] opacity-20 mb-6 break-all font-mono">{activeWebviewError.url}</div>
|
||||
{:else}
|
||||
<div class="mb-6"></div>
|
||||
{/if}
|
||||
<div class="flex gap-2 justify-center">
|
||||
<button
|
||||
class="px-4 py-2 rounded-xl text-[13px] font-medium bg-black dark:bg-white text-white dark:text-black border-none cursor-pointer transition hover:bg-gray-800 dark:hover:bg-gray-100 active:scale-[0.98]"
|
||||
onclick={retryActiveWebview}
|
||||
>
|
||||
{$i18n.t('common.retry')}
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-2 rounded-xl text-[13px] bg-black/[0.04] dark:bg-white/[0.06] text-[#1d1d1f] dark:text-[#fafafa] border-none cursor-pointer opacity-60 hover:opacity-90 transition active:scale-[0.98]"
|
||||
onclick={openActiveInBrowser}
|
||||
>
|
||||
{$i18n.t('setup.openInBrowser')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 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 || 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 () => {
|
||||
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>
|
||||
@@ -0,0 +1,151 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { fade, scale } from 'svelte/transition'
|
||||
import i18n from '../../../i18n'
|
||||
import Switch from '../../common/Switch.svelte'
|
||||
|
||||
interface Props {
|
||||
onContinue: (options: { installOpenTerminal: boolean; installLlamaCpp: boolean; installDir: string }) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
let { onContinue, onCancel }: Props = $props()
|
||||
|
||||
let installOpenTerminal = $state(true)
|
||||
let installLlamaCpp = $state(true)
|
||||
let installDir = $state('')
|
||||
let defaultInstallDir = $state('')
|
||||
let advancedOpen = $state(false)
|
||||
|
||||
onMount(async () => {
|
||||
defaultInstallDir = await window.electronAPI.getInstallDir()
|
||||
installDir = defaultInstallDir
|
||||
})
|
||||
|
||||
const changeInstallDir = async () => {
|
||||
const folder = await window.electronAPI.selectFolder()
|
||||
if (folder) {
|
||||
installDir = folder
|
||||
}
|
||||
}
|
||||
</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>
|
||||
|
||||
<!-- Advanced (collapsed) -->
|
||||
<div class="px-6 pb-4">
|
||||
<button
|
||||
class="flex items-center gap-1.5 bg-transparent border-none p-0 cursor-pointer"
|
||||
onclick={() => { advancedOpen = !advancedOpen }}
|
||||
>
|
||||
<svg
|
||||
class="w-2.5 h-2.5 text-gray-400 dark:text-gray-500 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-[11px] text-gray-400 dark:text-gray-500">{$i18n.t('common.advanced')}</span>
|
||||
</button>
|
||||
|
||||
{#if advancedOpen}
|
||||
<div class="mt-3">
|
||||
<div class="text-[11px] text-gray-400 dark:text-gray-500 mb-1.5">{$i18n.t('setup.install.installLocation')}</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="flex-1 min-w-0 px-3 py-2 bg-gray-50 dark:bg-gray-900 text-[11px] text-gray-500 dark:text-gray-400 font-mono truncate rounded-xl"
|
||||
title={installDir}
|
||||
>
|
||||
{installDir || '…'}
|
||||
</div>
|
||||
<button
|
||||
class="shrink-0 text-[11px] text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 px-3 py-2 bg-gray-50 dark:bg-gray-900 rounded-xl transition border-none cursor-pointer"
|
||||
onclick={changeInstallDir}
|
||||
>
|
||||
{$i18n.t('setup.install.changeLocation')}
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-[10px] text-gray-300 dark:text-gray-600 mt-1">{$i18n.t('setup.install.installLocationDesc')}</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="px-5 pb-5 pt-1 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, installDir })}
|
||||
>
|
||||
{$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>
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
<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>
|
||||
@@ -0,0 +1,495 @@
|
||||
<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)}
|
||||
<div
|
||||
class="w-full px-2.5 py-1.5 rounded-xl group flex items-center gap-2 transition-colors cursor-pointer {activeConnectionId === localConn.id
|
||||
? 'bg-black/[0.08] dark:bg-white/[0.08]'
|
||||
: 'hover:bg-black/[0.04] dark:hover:bg-white/[0.06]'}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => onConnect(localConn.id)}
|
||||
onkeydown={(e) => e.key === 'Enter' && 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
|
||||
? 'font-medium opacity-100'
|
||||
: 'opacity-70'} 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:hover: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:hover: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.08] dark:bg-white/[0.08]'
|
||||
: 'hover:bg-black/[0.04] dark:hover:bg-white/[0.06]'}"
|
||||
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
|
||||
? 'font-medium opacity-100'
|
||||
: 'opacity-70'} 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:hover: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:hover: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>
|
||||
@@ -0,0 +1,181 @@
|
||||
<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>
|
||||
@@ -0,0 +1,146 @@
|
||||
<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>
|
||||
@@ -0,0 +1,540 @@
|
||||
<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>
|
||||
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
import { connections, config } from '../../../stores'
|
||||
import i18n from '../../../i18n'
|
||||
|
||||
const remove = async (id: string) => {
|
||||
await window.electronAPI.removeConnection(id)
|
||||
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 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"
|
||||
/>
|
||||
{/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>
|
||||
@@ -0,0 +1,827 @@
|
||||
<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)
|
||||
let installDirPath = $state('')
|
||||
let defaultInstallDir = $state('')
|
||||
|
||||
// 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 install dir
|
||||
defaultInstallDir = await window.electronAPI.getInstallDir()
|
||||
installDirPath = cfg?.installDir || defaultInstallDir
|
||||
|
||||
// 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())
|
||||
|
||||
// Push theme to all active Open WebUI webviews
|
||||
const container = document.querySelector('.content-webview-container')
|
||||
if (container) {
|
||||
container.querySelectorAll('webview').forEach((wv: any) => {
|
||||
try {
|
||||
wv.send('desktop:event', { type: 'theme:update', data: { theme: newTheme } })
|
||||
} catch (_) {
|
||||
// webview may not be ready yet
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// Voice input shortcut recorder
|
||||
let voiceInputShortcutValue = $state('')
|
||||
let voiceInputRecording = $state(false)
|
||||
let voiceInputShortcutInputEl = $state<HTMLButtonElement | null>(null)
|
||||
let voiceInputEnabled = $state(true)
|
||||
|
||||
// Call shortcut recorder
|
||||
let callShortcutValue = $state('')
|
||||
let callRecording = $state(false)
|
||||
let callShortcutInputEl = $state<HTMLButtonElement | null>(null)
|
||||
let callEnabled = $state(true)
|
||||
|
||||
// Spotlight clipboard paste
|
||||
let spotlightClipboardPaste = $state(true)
|
||||
|
||||
// Keep shortcut value in sync with config store
|
||||
$effect(() => {
|
||||
if ($config?.globalShortcut !== undefined) {
|
||||
shortcutValue = $config.globalShortcut ?? ''
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if ($config?.spotlightShortcut !== undefined) {
|
||||
spotlightShortcutValue = $config.spotlightShortcut ?? ''
|
||||
}
|
||||
if ($config?.spotlightClipboardPaste !== undefined) {
|
||||
spotlightClipboardPaste = $config.spotlightClipboardPaste ?? true
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if ($config?.voiceInputShortcut !== undefined) {
|
||||
voiceInputShortcutValue = $config.voiceInputShortcut ?? ''
|
||||
}
|
||||
if ($config?.voiceInputEnabled !== undefined) {
|
||||
voiceInputEnabled = $config.voiceInputEnabled ?? true
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if ($config?.callShortcut !== undefined) {
|
||||
callShortcutValue = $config.callShortcut ?? ''
|
||||
}
|
||||
if ($config?.callEnabled !== undefined) {
|
||||
callEnabled = $config.callEnabled ?? true
|
||||
}
|
||||
})
|
||||
|
||||
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
|
||||
|
||||
// Use e.code to get the physical key (avoids macOS Alt producing unicode like √ for V)
|
||||
const codeMap: Record<string, string> = {
|
||||
Space: 'Space',
|
||||
ArrowUp: 'Up',
|
||||
ArrowDown: 'Down',
|
||||
ArrowLeft: 'Left',
|
||||
ArrowRight: 'Right',
|
||||
Enter: 'Return',
|
||||
Backquote: '`',
|
||||
Minus: '-',
|
||||
Equal: '=',
|
||||
BracketLeft: '[',
|
||||
BracketRight: ']',
|
||||
Backslash: '\\',
|
||||
Semicolon: ';',
|
||||
Quote: "'",
|
||||
Comma: ',',
|
||||
Period: '.',
|
||||
Slash: '/'
|
||||
}
|
||||
|
||||
let key: string
|
||||
if (codeMap[e.code]) {
|
||||
key = codeMap[e.code]
|
||||
} else if (e.code.startsWith('Key')) {
|
||||
key = e.code.slice(3) // KeyA → A
|
||||
} else if (e.code.startsWith('Digit')) {
|
||||
key = e.code.slice(5) // Digit1 → 1
|
||||
} else if (e.code.startsWith('F') && /^F\d+$/.test(e.code)) {
|
||||
key = e.code // F1, F2, etc.
|
||||
} else {
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
const handleVoiceInputShortcutKeydown = async (e: KeyboardEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
voiceInputRecording = false
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'Backspace' || e.key === 'Delete') {
|
||||
voiceInputShortcutValue = ''
|
||||
voiceInputRecording = false
|
||||
await window.electronAPI.setConfig({ voiceInputShortcut: '' })
|
||||
config.set(await window.electronAPI.getConfig())
|
||||
return
|
||||
}
|
||||
|
||||
const accel = keyToElectron(e)
|
||||
if (accel) {
|
||||
voiceInputShortcutValue = accel
|
||||
voiceInputRecording = false
|
||||
await window.electronAPI.setConfig({ voiceInputShortcut: accel })
|
||||
config.set(await window.electronAPI.getConfig())
|
||||
}
|
||||
}
|
||||
|
||||
const handleCallShortcutKeydown = async (e: KeyboardEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
callRecording = false
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'Backspace' || e.key === 'Delete') {
|
||||
callShortcutValue = ''
|
||||
callRecording = false
|
||||
await window.electronAPI.setConfig({ callShortcut: '' })
|
||||
config.set(await window.electronAPI.getConfig())
|
||||
return
|
||||
}
|
||||
|
||||
const accel = keyToElectron(e)
|
||||
if (accel) {
|
||||
callShortcutValue = accel
|
||||
callRecording = false
|
||||
await window.electronAPI.setConfig({ callShortcut: 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>
|
||||
|
||||
<div class="py-4 flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-[13px] opacity-70">Clipboard Auto-Paste</div>
|
||||
<div class="text-[11px] opacity-25 mt-0.5">Automatically paste clipboard contents into Spotlight</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={spotlightClipboardPaste}
|
||||
label="Toggle clipboard auto-paste"
|
||||
onchange={async (value) => {
|
||||
spotlightClipboardPaste = value
|
||||
await window.electronAPI.setConfig({ spotlightClipboardPaste: value })
|
||||
config.set(await window.electronAPI.getConfig())
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="py-4 flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-[13px] opacity-70">Voice Input</div>
|
||||
<div class="text-[11px] opacity-25 mt-0.5">Enable global push-to-talk voice transcription</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={voiceInputEnabled}
|
||||
label="Toggle voice input"
|
||||
onchange={async (value) => {
|
||||
voiceInputEnabled = value
|
||||
await window.electronAPI.setConfig({ voiceInputEnabled: value })
|
||||
config.set(await window.electronAPI.getConfig())
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if voiceInputEnabled}
|
||||
<div class="py-4 flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-[13px] opacity-70">Voice Input Shortcut</div>
|
||||
<div class="text-[11px] opacity-25 mt-0.5">
|
||||
{#if voiceInputRecording}
|
||||
Press a key combination…
|
||||
{:else}
|
||||
Toggle microphone recording from anywhere
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<button
|
||||
bind:this={voiceInputShortcutInputEl}
|
||||
class="text-[12px] px-3 py-1.5 border-none outline-none rounded-xl transition min-w-[80px] text-center
|
||||
{voiceInputRecording
|
||||
? '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={() => {
|
||||
voiceInputRecording = true
|
||||
voiceInputShortcutInputEl?.focus()
|
||||
}}
|
||||
onkeydown={(e) => {
|
||||
if (voiceInputRecording) handleVoiceInputShortcutKeydown(e)
|
||||
}}
|
||||
onblur={() => {
|
||||
voiceInputRecording = false
|
||||
}}
|
||||
>
|
||||
{#if voiceInputRecording}
|
||||
<span class="text-[11px]">Press keys…</span>
|
||||
{:else if voiceInputShortcutValue}
|
||||
{displayShortcut(voiceInputShortcutValue)}
|
||||
{:else}
|
||||
<span class="opacity-40">Disabled</span>
|
||||
{/if}
|
||||
</button>
|
||||
{#if voiceInputShortcutValue && !voiceInputRecording}
|
||||
<button
|
||||
class="opacity-20 hover:opacity-50 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-0.5 shrink-0"
|
||||
onclick={async () => {
|
||||
voiceInputShortcutValue = ''
|
||||
await window.electronAPI.setConfig({ voiceInputShortcut: '' })
|
||||
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>
|
||||
{/if}
|
||||
|
||||
<div class="py-4 flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-[13px] opacity-70">Call</div>
|
||||
<div class="text-[11px] opacity-25 mt-0.5">Enable global shortcut to start a voice/video call</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={callEnabled}
|
||||
label="Toggle call shortcut"
|
||||
onchange={async (value) => {
|
||||
callEnabled = value
|
||||
await window.electronAPI.setConfig({ callEnabled: value })
|
||||
config.set(await window.electronAPI.getConfig())
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if callEnabled}
|
||||
<div class="py-4 flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-[13px] opacity-70">Call Shortcut</div>
|
||||
<div class="text-[11px] opacity-25 mt-0.5">
|
||||
{#if callRecording}
|
||||
Press a key combination…
|
||||
{:else}
|
||||
Start a call from anywhere
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<button
|
||||
bind:this={callShortcutInputEl}
|
||||
class="text-[12px] px-3 py-1.5 border-none outline-none rounded-xl transition min-w-[80px] text-center
|
||||
{callRecording
|
||||
? '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={() => {
|
||||
callRecording = true
|
||||
callShortcutInputEl?.focus()
|
||||
}}
|
||||
onkeydown={(e) => {
|
||||
if (callRecording) handleCallShortcutKeydown(e)
|
||||
}}
|
||||
onblur={() => {
|
||||
callRecording = false
|
||||
}}
|
||||
>
|
||||
{#if callRecording}
|
||||
<span class="text-[11px]">Press keys…</span>
|
||||
{:else if callShortcutValue}
|
||||
{displayShortcut(callShortcutValue)}
|
||||
{:else}
|
||||
<span class="opacity-40">Disabled</span>
|
||||
{/if}
|
||||
</button>
|
||||
{#if callShortcutValue && !callRecording}
|
||||
<button
|
||||
class="opacity-20 hover:opacity-50 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-0.5 shrink-0"
|
||||
onclick={async () => {
|
||||
callShortcutValue = ''
|
||||
await window.electronAPI.setConfig({ callShortcut: '' })
|
||||
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>
|
||||
{/if}
|
||||
|
||||
<!-- 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">
|
||||
<!-- Install location -->
|
||||
<div class="py-4 flex items-center justify-between gap-4">
|
||||
<div class="shrink-0">
|
||||
<div class="text-[13px] opacity-70">{$i18n.t('settings.general.installLocation')}</div>
|
||||
<div class="text-[11px] opacity-25 mt-0.5">{$i18n.t('settings.general.installLocationDesc')}</div>
|
||||
<div class="text-[10px] opacity-15 mt-0.5">{$i18n.t('settings.general.installLocationNote')}</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={defaultInstallDir || 'Default'}
|
||||
value={installDirPath === defaultInstallDir ? '' : installDirPath}
|
||||
onchange={async (e) => {
|
||||
const val = (e.target as HTMLInputElement).value.trim()
|
||||
installDirPath = val || defaultInstallDir
|
||||
await window.electronAPI.setConfig({ installDir: 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) {
|
||||
installDirPath = folder
|
||||
await window.electronAPI.setConfig({ installDir: folder })
|
||||
config.set(await window.electronAPI.getConfig())
|
||||
}
|
||||
}}
|
||||
>
|
||||
{$i18n.t('common.browse')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
@@ -0,0 +1,532 @@
|
||||
<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}
|
||||
onchange={(value) => updateConfig('enabled', value)}
|
||||
/>
|
||||
</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}
|
||||
@@ -0,0 +1,403 @@
|
||||
<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 downloads in the "Downloaded" section
|
||||
let activeDownloads = $state<Map<string, { repo: string; filename: string; percent: number }>>(new Map())
|
||||
|
||||
const dlKey = (repo: string, filename: string): string => `${repo}/${filename}`
|
||||
|
||||
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
|
||||
const key = dlKey(d.repo, d.filename)
|
||||
if (d?.status === 'downloading') {
|
||||
const updated = new Map(activeDownloads)
|
||||
updated.set(key, { repo: d.repo, filename: d.filename, percent: d.percent ?? 0 })
|
||||
activeDownloads = updated
|
||||
}
|
||||
if (d?.status === 'done') {
|
||||
const updated = new Map(activeDownloads)
|
||||
updated.delete(key)
|
||||
activeDownloads = updated
|
||||
window.electronAPI.listHfModels().then((m: HfModel[]) => { models = m })
|
||||
}
|
||||
if (d?.status === 'failed') {
|
||||
const updated = new Map(activeDownloads)
|
||||
updated.delete(key)
|
||||
activeDownloads = updated
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
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) => {
|
||||
const key = dlKey(repo, filename)
|
||||
const updated = new Map(activeDownloads)
|
||||
updated.set(key, { repo, filename, percent: 0 })
|
||||
activeDownloads = updated
|
||||
try {
|
||||
await window.electronAPI.downloadHfModel(repo, filename, undefined, size)
|
||||
} catch (e) {
|
||||
console.error('Failed to download model:', e)
|
||||
const cleaned = new Map(activeDownloads)
|
||||
cleaned.delete(key)
|
||||
activeDownloads = cleaned
|
||||
}
|
||||
}
|
||||
|
||||
const cancelDownload = async (repo: string, filename: string) => {
|
||||
try {
|
||||
await window.electronAPI.cancelHfDownload(repo, filename)
|
||||
} catch (e) {
|
||||
console.error('Failed to cancel download:', e)
|
||||
}
|
||||
const updated = new Map(activeDownloads)
|
||||
updated.delete(dlKey(repo, filename))
|
||||
activeDownloads = updated
|
||||
}
|
||||
|
||||
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 activeDownloads.has(dlKey(repo, filename))
|
||||
}
|
||||
|
||||
const getDownloadPercent = (repo: string, filename: string): number => {
|
||||
return activeDownloads.get(dlKey(repo, filename))?.percent ?? 0
|
||||
}
|
||||
|
||||
const hasActiveDownloads = $derived(activeDownloads.size > 0)
|
||||
|
||||
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 downloads -->
|
||||
<div class="py-4">
|
||||
<div class="text-[12px] opacity-50 mb-2">{$i18n.t('settings.models.downloadedModels')}</div>
|
||||
|
||||
{#if models.length > 0 || hasActiveDownloads}
|
||||
<div class="flex flex-col">
|
||||
|
||||
<!-- Active downloads -->
|
||||
{#each [...activeDownloads.values()] as dl (dlKey(dl.repo, dl.filename))}
|
||||
<div class="flex items-center gap-3 py-2 group">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[12px] opacity-60 truncate font-mono">{dl.filename}</span>
|
||||
<span class="text-[10px] opacity-30 font-mono shrink-0">{dl.percent.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div class="mt-1.5 w-full h-[3px] bg-black/[0.06] dark:bg-white/[0.06] rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-emerald-400/70 rounded-full transition-[width] duration-300"
|
||||
style="width: {dl.percent}%"
|
||||
></div>
|
||||
</div>
|
||||
<div class="text-[10px] opacity-20 mt-1 truncate">{dl.repo}</div>
|
||||
</div>
|
||||
<button
|
||||
class="opacity-0 group-hover:opacity-40 hover:!opacity-70 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-1 shrink-0"
|
||||
onclick={() => cancelDownload(dl.repo, dl.filename)}
|
||||
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>
|
||||
{/each}
|
||||
|
||||
<!-- Completed downloads -->
|
||||
{#each models as model}
|
||||
<div class="flex items-center gap-3 py-2 group">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-[12px] opacity-60 truncate font-mono">{model.filename}</div>
|
||||
<div class="text-[10px] opacity-20 truncate mt-0.5">{model.repo} · {formatSize(model.size)}</div>
|
||||
</div>
|
||||
<button
|
||||
class="opacity-0 group-hover:opacity-30 hover:!opacity-60 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-1 shrink-0 {deleting === `${model.repo}/${model.filename}` ? '!opacity-30 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-20 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-70 hover:opacity-100 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-0 text-[12px] flex items-center gap-1 font-mono truncate"
|
||||
onclick={backToSearch}
|
||||
>
|
||||
<svg class="w-3 h-3 shrink-0 opacity-50" 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>
|
||||
<span class="truncate">{selectedRepo}</span>
|
||||
</button>
|
||||
{:else}
|
||||
{$i18n.t('settings.models.downloadFromHF')}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if selectedRepo}
|
||||
<!-- Repo file browser -->
|
||||
{#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">
|
||||
{#each repoFiles as file}
|
||||
{@const downloaded = isDownloaded(selectedRepo, file.filename)}
|
||||
{@const dlActive = isDownloading(selectedRepo, file.filename)}
|
||||
<div class="flex items-center gap-3 py-2 group">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-[12px] opacity-50 truncate font-mono">{file.filename}</div>
|
||||
<div class="text-[10px] opacity-20 mt-0.5">{formatSize(file.size)}</div>
|
||||
{#if dlActive}
|
||||
<div class="mt-1.5 w-full h-[3px] bg-black/[0.06] dark:bg-white/[0.06] rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-emerald-400/70 rounded-full transition-[width] duration-300"
|
||||
style="width: {getDownloadPercent(selectedRepo, file.filename)}%"
|
||||
></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if downloaded}
|
||||
<span class="text-[10px] opacity-25 shrink-0">{$i18n.t('settings.models.downloaded')}</span>
|
||||
{:else if dlActive}
|
||||
<div class="flex items-center gap-1.5 shrink-0">
|
||||
<span class="text-[10px] opacity-40 font-mono">{getDownloadPercent(selectedRepo, file.filename).toFixed(0)}%</span>
|
||||
<button
|
||||
class="opacity-30 hover:opacity-70 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-0.5"
|
||||
onclick={() => cancelDownload(selectedRepo, file.filename)}
|
||||
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>
|
||||
{:else}
|
||||
<button
|
||||
class="opacity-0 group-hover:opacity-40 hover:!opacity-70 transition bg-transparent border-none text-[#1d1d1f] dark:text-[#fafafa] p-1 shrink-0"
|
||||
onclick={() => startDownload(selectedRepo, file.filename, file.size)}
|
||||
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 max-h-[300px] overflow-y-auto">
|
||||
{#each searchResults as repo}
|
||||
<button
|
||||
class="flex items-center justify-between gap-2 py-2 hover:bg-black/[0.03] dark:hover:bg-white/[0.04] rounded-lg transition border-none text-left w-full text-[#1d1d1f] dark:text-[#fafafa] bg-transparent px-1"
|
||||
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-15 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}
|
||||
@@ -0,0 +1,363 @@
|
||||
<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}
|
||||
@@ -0,0 +1,370 @@
|
||||
<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}
|
||||
@@ -0,0 +1,163 @@
|
||||
<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
|
||||
})
|
||||
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)
|
||||
config.set(await window.electronAPI.getConfig())
|
||||
if (($connections ?? []).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}
|
||||
@@ -0,0 +1,124 @@
|
||||
<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}
|
||||
@@ -0,0 +1,138 @@
|
||||
<script lang="ts">
|
||||
import { fade, fly } from 'svelte/transition'
|
||||
import { onMount } from 'svelte'
|
||||
import { 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('')
|
||||
let installDir = $state('')
|
||||
let defaultInstallDir = $state('')
|
||||
|
||||
onMount(async () => {
|
||||
defaultInstallDir = await window.electronAPI.getInstallDir()
|
||||
installDir = defaultInstallDir
|
||||
if (autoStart) install()
|
||||
})
|
||||
|
||||
const install = async () => {
|
||||
phase = 'working'
|
||||
try {
|
||||
// Save custom install directory before installing
|
||||
if (installDir && installDir !== defaultInstallDir) {
|
||||
await window.electronAPI.setConfig({ installDir })
|
||||
}
|
||||
|
||||
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.setDefaultConnection('local')
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
||||
const changeInstallDir = async () => {
|
||||
const folder = await window.electronAPI.selectFolder()
|
||||
if (folder) {
|
||||
installDir = folder
|
||||
}
|
||||
}
|
||||
</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-6 leading-relaxed">
|
||||
{$i18n.t('setup.install.description')}
|
||||
</p>
|
||||
|
||||
<!-- Install location -->
|
||||
<div class="mb-6">
|
||||
<div class="text-[11px] opacity-40 mb-1.5">{$i18n.t('setup.install.installLocation')}</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="flex-1 min-w-0 px-3 py-2 bg-black/[0.04] dark:bg-white/[0.06] text-[12px] text-[#1d1d1f] dark:text-[#fafafa] opacity-50 font-mono truncate rounded-lg"
|
||||
title={installDir}
|
||||
>
|
||||
{installDir || '…'}
|
||||
</div>
|
||||
<button
|
||||
class="shrink-0 text-[11px] opacity-40 hover:opacity-70 px-2.5 py-2 bg-black/[0.04] dark:bg-white/[0.06] transition border-none text-[#1d1d1f] dark:text-[#fafafa] rounded-lg"
|
||||
onclick={changeInstallDir}
|
||||
>
|
||||
{$i18n.t('setup.install.changeLocation')}
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-[10px] opacity-20 mt-1">{$i18n.t('setup.install.installLocationDesc')}</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
@@ -0,0 +1,129 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { fly, fade } from 'svelte/transition'
|
||||
import { appState, 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 }
|
||||
const connId = crypto.randomUUID()
|
||||
await window.electronAPI.addConnection({
|
||||
id: connId,
|
||||
name: new URL(u).hostname,
|
||||
type: 'remote',
|
||||
url: u
|
||||
})
|
||||
config.set(await window.electronAPI.getConfig())
|
||||
await window.electronAPI.connectTo(connId)
|
||||
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>
|
||||
@@ -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>
|
||||
@@ -1,183 +0,0 @@
|
||||
<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";
|
||||
|
||||
let {
|
||||
title,
|
||||
message,
|
||||
cancelLabel = "Cancel",
|
||||
confirmLabel = "Confirm",
|
||||
onConfirm = () => {},
|
||||
input,
|
||||
inputPlaceholder,
|
||||
inputValue,
|
||||
show = $bindable(false),
|
||||
} = $props();
|
||||
|
||||
$effect(() => {
|
||||
if (show) {
|
||||
init();
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
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";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
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>
|
||||
@@ -0,0 +1,108 @@
|
||||
<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>
|
||||