├── .gitignore ├── .idea └── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── assets ├── ico.icns ├── ico.ico └── ico.png ├── config_example.json ├── index.html ├── main.js ├── modal-preload.js ├── modal.html ├── modal.js ├── package-lock.json ├── package.json ├── preload.js ├── renderer.js └── styles ├── index.css └── modal.css /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | node_modules 3 | /dist/ 4 | /config.json 5 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | # Datasource local storage ignored files 7 | /dataSources/ 8 | /dataSources.local.xml 9 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | . 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution Guidelines 2 | 3 | Thank you for your interest in contributing to **OpenWebUISimpleDesktop**! 4 | We welcome contributions of all kinds, including bug reports, feature requests, documentation improvements, and code changes. 5 | 6 | --- 7 | 8 | ## How to Contribute 9 | 10 | ### Reporting Issues 11 | 12 | - Please use GitHub Issues to report bugs or suggest features. 13 | - Provide clear, descriptive titles and detailed descriptions. 14 | - Include steps to reproduce, expected vs. actual behavior, and any relevant logs or screenshots. 15 | 16 | ### Submitting Pull Requests 17 | 18 | 1. **Fork** the repository and clone it locally. 19 | 2. **Create a new branch** for your feature or bug fix: git checkout -b feature/my-new-feature 20 | 3. **Make your changes** following the project’s code style. 21 | 4. **Test your changes** thoroughly. 22 | 5. **Commit your changes** with clear, concise commit messages. 23 | 6. **Push your branch** to your fork. 24 | 7. **Open a Pull Request** against the `main` branch of the original repository. 25 | 8. Fill in the PR template with a description of your changes and any relevant details. 26 | 27 | --- 28 | 29 | ## Code Style and Quality 30 | 31 | - Write clear, maintainable, and well-documented code. 32 | - Follow existing coding conventions and patterns used in the project. 33 | - Ensure your code passes any existing linting and formatting checks. 34 | 35 | --- 36 | 37 | ## Testing 38 | 39 | - Please test your changes locally before submitting. 40 | - If adding new features, include tests if applicable. 41 | 42 | --- 43 | 44 | ## Communication 45 | 46 | - Be respectful and courteous in all interactions. 47 | - Respond promptly to review comments and feedback. 48 | - If you need help or guidance, don’t hesitate to ask. 49 | 50 | --- 51 | 52 | ## License 53 | 54 | By contributing, you agree that your contributions will be licensed under the project’s MIT License. 55 | 56 | --- 57 | 58 | Thank you for helping make OpenWebUISimpleDesktop better! 59 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 n1kozor 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Screenshot 2025-04-18 193738](https://github.com/user-attachments/assets/b362b4b3-5c77-4123-8995-0323e588103e) 2 | 3 | 4 | # OpenWebUISimpleDesktop 5 | 6 | **OpenWebUISimpleDesktop** is a minimal, modern Electron desktop wrapper for [OpenWebUI](https://github.com/open-webui/open-webui). 7 | It provides a native desktop experience with native window controls and seamless integration, packaged as a simple Electron app with a built-in setup wizard. 8 | 9 | --- 10 | 11 | ## Features 12 | 13 | - Clean, lightweight Electron shell for OpenWebUI 14 | - Built-in first-launch setup wizard to configure app name and OpenWebUI server URL 15 | - Settings saved to `config.json` beside the executable for easy user customization 16 | - Native window controls (minimize, maximize, close) 17 | - Customizable window size, colors, and UI offsets via config 18 | - Displays a connection error message with retry button if the server is unreachable 19 | - Cross-platform support: Windows and macOS 20 | - Easy development with Node.js and Electron 21 | - Crtl + Shift + Space Short. Open a Chat Modal 22 | 23 | 24 | ![Screenshot 2025-04-19 182011](https://github.com/user-attachments/assets/93315595-d5d7-4eee-80af-68f5cde0f515) 25 | 26 | --- 27 | 28 | ## Prerequisites 29 | 30 | - [Node.js](https://nodejs.org/) (v18 or newer recommended) 31 | - [npm](https://www.npmjs.com/) (comes with Node.js) 32 | - A running instance of [OpenWebUI](https://github.com/open-webui/open-webui) accessible via HTTP (e.g. `http://localhost:8080`) 33 | 34 | --- 35 | 36 | ## Installation 37 | 38 | 1. Clone or download this repository. 39 | 2. Navigate to the project folder in a terminal. 40 | 3. Install dependencies by running: 41 | `npm install` 42 | 43 | --- 44 | 45 | ## Configuration 46 | 47 | All settings are stored in `config.json` under the user data directory: 48 | - Windows: `%AppData%/config.json` 49 | - macOS: `~/Library/Application Support/config.json` 50 | - Linux: `~/.config/config.json` 51 | 52 | ### Example `config.json` content: 53 | 54 | ```json 55 | { 56 | "iconDarwin": "assets/ico.icns", 57 | "iconWin": "assets/ico.ico", 58 | "bgColor": "#fff", 59 | "borderRadius": 0, 60 | "profileNavOffset": 15, 61 | "sidebarOffset": 15, 62 | "apiToken": "your_openwebui_api_key", 63 | "windowButtons": "right", 64 | "window": { 65 | "width": 1200, 66 | "height": 800, 67 | "minWidth": 500, 68 | "minHeight": 400 69 | }, 70 | "appName": "YourAppName", 71 | "webuiUrl": "http://localhost:8000", 72 | "preloaderTheme": "dark", //or light 73 | "opacity": 1 74 | } 75 | ``` 76 | 77 | `webuiUrl`: The URL where your OpenWebUI server is running. 78 | `windowButtons`: Position of window control buttons, `"right"` or `"left"`. 79 | 80 | Adjust icon paths if you customize icons. 81 | 82 | --- 83 | 84 | ## Fine-Tuning UI Offsets in `config.json` 85 | 86 | Adjust these values to fix UI margins/offsets in OpenWebUI's webview: 87 | 88 | - `profileNavOffset`: 15 89 | - `sidebarOffset`: 15 90 | 91 | Typical values range from 10 to 20 depending on your theme and UI. 92 | 93 | --- 94 | 95 | # First Launch Setup Wizard 96 | ## Enter the Application Name 97 | ![Screenshot 2025-04-19 015513](https://github.com/user-attachments/assets/d699195c-5f50-4ad2-9971-381d41b51aab) 98 | 99 | ## Enter the OpenWebUI Server URL (defaults to `http://localhost:8080`) 100 | ![Screenshot 2025-04-19 015525](https://github.com/user-attachments/assets/7ea2ea5f-e192-49e1-8768-a123ac05eaa6) 101 | 102 | ## Click Start to save and launch the app 103 | ![Screenshot 2025-04-19 015534](https://github.com/user-attachments/assets/42852644-d4a2-495c-960c-83e542fe7acc) 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | On first launch, a simple, clean wizard appears asking you to: 112 | - Enter the Application Name 113 | - Enter the OpenWebUI Server URL (defaults to `http://localhost:8080`) 114 | - Click Start to save and launch the app 115 | 116 | --- 117 | 118 | ## Connection Error Handling 119 | 120 | 121 | If the app cannot connect to the OpenWebUI server, it displays a clear Connection error message with: 122 | - Instructions to check your `config.json` settings 123 | - A Refresh button to retry connecting without restarting the app 124 | 125 | 126 | ![Screenshot 2025-04-19 015544](https://github.com/user-attachments/assets/b99c5045-dca0-4299-a794-e2f417ec6b26) 127 | 128 | 129 | --- 130 | 131 | ## Usage 132 | 133 | ### Development Mode 134 | 135 | Run the app in development mode with: 136 | 137 | `npx electron main.js` 138 | 139 | This will start the app loading local files and allowing you to develop and test. 140 | 141 | --- 142 | 143 | ### Build Distributable 144 | 145 | To build a Windows or macOS installer/package using electron-builder, run: 146 | 147 | `npm run dist` 148 | 149 | - Output installers are created inside the dist folder. 150 | - Packaging settings are in package.json under the "build" key. 151 | 152 | --- 153 | 154 | ## Troubleshooting 155 | 156 | - Blank window or connection refused: 157 | Make sure your OpenWebUI server URL in config.json is correct and your OpenWebUI server is running and reachable. 158 | 159 | - Config file not found after wizard: 160 | The config.json file is saved next to the executable. Ensure the app has write permissions to its folder. 161 | 162 | - Icons missing: 163 | Verify icon files exist at the paths set in config.json or update paths to your custom icons. 164 | 165 | --- 166 | 167 | ## Credits 168 | 169 | - OpenWebUI 170 | - Electron 171 | 172 | --- 173 | 174 | Enjoy using OpenWebUISimpleDesktop! 175 | -------------------------------------------------------------------------------- /assets/ico.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n1kozor/OpenWebUISimpleDesktop/eaf802f43e10ca73938e86254bd8ffee7fbe9d26/assets/ico.icns -------------------------------------------------------------------------------- /assets/ico.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n1kozor/OpenWebUISimpleDesktop/eaf802f43e10ca73938e86254bd8ffee7fbe9d26/assets/ico.ico -------------------------------------------------------------------------------- /assets/ico.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n1kozor/OpenWebUISimpleDesktop/eaf802f43e10ca73938e86254bd8ffee7fbe9d26/assets/ico.png -------------------------------------------------------------------------------- /config_example.json: -------------------------------------------------------------------------------- 1 | { 2 | "iconDarwin": "assets/ico.icns", 3 | "iconWin": "assets/ico.ico", 4 | "bgColor": "#fff", 5 | "borderRadius": 0, 6 | "profileNavOffset": 15, 7 | "sidebarOffset": 15, 8 | "apiToken": "your_openwebui_api_key", 9 | "windowButtons": "right", 10 | "window": { 11 | "width": 1200, 12 | "height": 800, 13 | "minWidth": 500, 14 | "minHeight": 400 15 | }, 16 | "appName": "TestApp", 17 | "webuiUrl": "http://localhost:8080", 18 | "opacity": 0.9 19 | } -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | OpenwebuiSimpleDesktop 6 | 7 | 8 | 9 | 10 |
11 | OpenWebUISimpleDesktop 12 |
13 | 14 | 15 | 74 | 75 |
76 | 77 |
78 |
79 |
80 | 81 | 82 | 83 |
84 | 95 | 96 | 97 | -------------------------------------------------------------------------------- /main.js: -------------------------------------------------------------------------------- 1 | // main.js 2 | const { app, BrowserWindow, ipcMain, globalShortcut, Tray, Menu } = require('electron'); 3 | const path = require('path'); 4 | const fs = require('fs'); 5 | const crypto = require('crypto'); 6 | const os = require('os'); 7 | const http = require('http'); 8 | 9 | let mainWindow = null; 10 | let modalWindow = null; 11 | let tray = null; 12 | let isQuiting = false; 13 | 14 | app.commandLine.appendSwitch('disable-gpu'); 15 | app.commandLine.appendSwitch('ignore-certificate-errors'); 16 | 17 | const configDir = app.getPath('appData'); 18 | const configPath = path.join(configDir, 'config.json'); 19 | 20 | function readConfig() { 21 | try { 22 | if (!fs.existsSync(configPath)) return null; 23 | return JSON.parse(fs.readFileSync(configPath, 'utf8')); 24 | } catch { 25 | return null; 26 | } 27 | } 28 | 29 | function getIconPath(cfg) { 30 | return process.platform === 'darwin' 31 | ? path.join(__dirname, cfg?.iconDarwin ?? 'assets/ico.icns') 32 | : path.join(__dirname, cfg?.iconWin ?? 'assets/ico.ico'); 33 | } 34 | 35 | function createMainWindow() { 36 | const config = readConfig(); 37 | mainWindow = new BrowserWindow({ 38 | width: config?.window?.width || 1200, 39 | height: config?.window?.height || 800, 40 | minWidth: config?.window?.minWidth || 500, 41 | minHeight: config?.window?.minHeight || 400, 42 | frame: false, 43 | backgroundColor: config?.bgColor || '#fff', 44 | roundedCorners: true, 45 | hasShadow: true, 46 | resizable: true, 47 | icon: getIconPath(config || {}), 48 | title: config?.appName || 'OpenwebuiSimpleDesktop', 49 | webPreferences: { 50 | contextIsolation: true, 51 | preload: path.join(__dirname, 'preload.js'), 52 | nodeIntegration: false, 53 | webviewTag: true, 54 | partition: 'persist:openwebui' 55 | } 56 | }); 57 | 58 | const opacity = config?.opacity; 59 | if (typeof opacity === 'number' && opacity >= 0 && opacity <= 1) { 60 | mainWindow.setOpacity(opacity); 61 | } 62 | 63 | mainWindow.loadFile('index.html'); 64 | 65 | mainWindow.on('close', event => { 66 | if (!isQuiting) { 67 | event.preventDefault(); 68 | mainWindow.hide(); 69 | } 70 | }); 71 | 72 | ipcMain.on('win-close', () => mainWindow.close()); 73 | ipcMain.on('win-min', () => mainWindow.minimize()); 74 | ipcMain.on('win-max', () => { 75 | if (mainWindow.isMaximized()) mainWindow.unmaximize(); 76 | else mainWindow.maximize(); 77 | }); 78 | } 79 | 80 | function createModalWindow() { 81 | if (modalWindow) { 82 | modalWindow.focus(); 83 | return; 84 | } 85 | modalWindow = new BrowserWindow({ 86 | width: 400, 87 | height: 330, 88 | resizable: false, 89 | alwaysOnTop: true, 90 | transparent: true, 91 | backgroundColor: '#00000000', 92 | frame: false, 93 | show: false, 94 | webPreferences: { 95 | contextIsolation: true, 96 | preload: path.join(__dirname, 'modal-preload.js'), 97 | nodeIntegration: false 98 | } 99 | }); 100 | modalWindow.loadFile('modal.html'); 101 | modalWindow.once('ready-to-show', () => modalWindow.show()); 102 | modalWindow.on('closed', () => { modalWindow = null; }); 103 | } 104 | 105 | // only allow a single instance 106 | const gotTheLock = app.requestSingleInstanceLock(); 107 | if (!gotTheLock) { 108 | app.quit(); 109 | } else { 110 | app.on('second-instance', () => { 111 | // someone tried to run a second instance, we should focus our window 112 | if (mainWindow) { 113 | if (mainWindow.isMinimized()) mainWindow.restore(); 114 | if (!mainWindow.isVisible()) mainWindow.show(); 115 | mainWindow.focus(); 116 | } 117 | }); 118 | 119 | app.whenReady().then(() => { 120 | createMainWindow(); 121 | 122 | // tray 123 | const iconPath = getIconPath({}); 124 | tray = new Tray(iconPath); 125 | const trayMenu = Menu.buildFromTemplate([ 126 | { 127 | label: 'Show App', 128 | click: () => { 129 | if (mainWindow) { 130 | mainWindow.show(); 131 | mainWindow.focus(); 132 | } 133 | } 134 | }, 135 | { 136 | label: 'Quit', 137 | click: () => { 138 | isQuiting = true; 139 | app.quit(); 140 | } 141 | } 142 | ]); 143 | tray.setToolTip('OpenwebuiSimpleDesktop'); 144 | tray.setContextMenu(trayMenu); 145 | 146 | // global shortcut for modal 147 | globalShortcut.register('Control+Shift+Space', () => { 148 | createModalWindow(); 149 | }); 150 | }); 151 | 152 | app.on('will-quit', () => { 153 | globalShortcut.unregisterAll(); 154 | }); 155 | 156 | app.on('window-all-closed', e => { 157 | if (process.platform !== 'darwin') { 158 | e.preventDefault(); 159 | } 160 | }); 161 | 162 | app.on('activate', () => { 163 | if (BrowserWindow.getAllWindows().length === 0) { 164 | createMainWindow(); 165 | } 166 | }); 167 | 168 | // ======= IPC and API logic below ======= 169 | 170 | async function apiFetch(cfg, endpoint, options = {}) { 171 | if (!cfg?.webuiUrl) throw new Error('No webuiUrl in config'); 172 | let baseUrl = cfg.webuiUrl.replace(/^http:\/\/localhost/, 'http://127.0.0.1'); 173 | const url = new URL(endpoint, baseUrl).href; 174 | const headers = { 175 | 'Content-Type': 'application/json', 176 | ...(options.headers || {}) 177 | }; 178 | if (cfg.apiToken) { 179 | headers['Authorization'] = `Bearer ${cfg.apiToken}`; 180 | } 181 | const res = await fetch(url, { ...options, headers }); 182 | if (!res.ok) throw new Error(await res.text()); 183 | return await res.json(); 184 | } 185 | 186 | ipcMain.handle('get-config', () => { 187 | try { return readConfig(); } 188 | catch { return null; } 189 | }); 190 | 191 | ipcMain.on('save-config', (event, configData) => { 192 | try { 193 | if (!fs.existsSync(configDir)) { 194 | fs.mkdirSync(configDir, { recursive: true }); 195 | } 196 | fs.writeFileSync(configPath, JSON.stringify(configData, null, 2), 'utf8'); 197 | event.sender.send('config-saved'); 198 | } catch (err) { 199 | event.sender.send('config-save-error', err.message); 200 | } 201 | }); 202 | 203 | ipcMain.handle('modal-get-models', async () => { 204 | const cfg = readConfig(); 205 | const data = await apiFetch(cfg, '/api/models'); 206 | return data.data || []; 207 | }); 208 | 209 | ipcMain.handle('modal-start-chat', async (event, modelName, initialMessageRaw) => { 210 | const cfg = readConfig(); 211 | const now = Math.floor(Date.now() / 1000); 212 | const initialMessage = initialMessageRaw?.trim() || "Hi!"; 213 | const userId = crypto.randomUUID(); 214 | const assistantId = crypto.randomUUID(); 215 | 216 | // create new chat 217 | const newChatResp = await apiFetch(cfg, '/api/v1/chats/new', { 218 | method: 'POST', 219 | body: JSON.stringify({ 220 | chat: { 221 | title: 'Chat from Modal', 222 | models: [modelName], 223 | params: {}, 224 | history: { messages: {}, currentId: "" }, 225 | messages: [], 226 | tags: [], 227 | timestamp: now 228 | } 229 | }) 230 | }); 231 | const chatId = newChatResp?.id; 232 | if (!chatId) throw new Error("❌ Could not create new chat"); 233 | 234 | // get assistant response 235 | const completionResp = await apiFetch(cfg, '/api/chat/completions', { 236 | method: 'POST', 237 | body: JSON.stringify({ 238 | model: modelName, 239 | messages: [{ 240 | role: 'user', 241 | content: initialMessage, 242 | timestamp: now 243 | }] 244 | }) 245 | }); 246 | const assistantMessage = completionResp?.choices?.[0]?.message?.content; 247 | if (!assistantMessage) throw new Error("❌ Missing assistant response"); 248 | 249 | // update chat with both messages 250 | await apiFetch(cfg, `/api/v1/chats/${chatId}`, { 251 | method: 'POST', 252 | body: JSON.stringify({ 253 | chat: { 254 | history: { 255 | messages: { 256 | [userId]: { 257 | id: userId, 258 | parentId: null, 259 | childrenIds: [assistantId], 260 | role: 'user', 261 | content: initialMessage, 262 | timestamp: now, 263 | models: [modelName] 264 | }, 265 | [assistantId]: { 266 | id: assistantId, 267 | parentId: userId, 268 | childrenIds: [], 269 | role: 'assistant', 270 | content: assistantMessage, 271 | model: modelName, 272 | modelName: modelName, 273 | modelIdx: 0, 274 | userContext: null, 275 | timestamp: now + 1, 276 | done: true 277 | } 278 | }, 279 | currentId: assistantId 280 | }, 281 | messages: [ 282 | { 283 | id: userId, 284 | parentId: null, 285 | childrenIds: [assistantId], 286 | role: 'user', 287 | content: initialMessage, 288 | timestamp: now, 289 | models: [modelName] 290 | }, 291 | { 292 | id: assistantId, 293 | parentId: userId, 294 | childrenIds: [], 295 | role: 'assistant', 296 | content: assistantMessage, 297 | model: modelName, 298 | modelName: modelName, 299 | modelIdx: 0, 300 | userContext: null, 301 | timestamp: now + 1, 302 | done: true 303 | } 304 | ] 305 | } 306 | }) 307 | }); 308 | 309 | return chatId; 310 | }); 311 | 312 | ipcMain.on('modal-open-chat', (event, chatId) => { 313 | if (!chatId) return; 314 | if (!mainWindow) { 315 | createMainWindow(); 316 | mainWindow.once('ready-to-show', () => { 317 | openChatAndFocus(mainWindow, chatId); 318 | }); 319 | } else { 320 | if (!mainWindow.isVisible()) mainWindow.show(); 321 | if (mainWindow.isMinimized()) mainWindow.restore(); 322 | openChatAndFocus(mainWindow, chatId); 323 | } 324 | if (modalWindow) modalWindow.close(); 325 | }); 326 | 327 | ipcMain.on('modal-close', () => { 328 | if (modalWindow) modalWindow.close(); 329 | }); 330 | 331 | function openChatAndFocus(win, chatId) { 332 | const cfg = readConfig(); 333 | const url = `${cfg.webuiUrl.replace(/\/$/, '')}/c/${chatId}`; 334 | win.webContents.send('open-chat', url); 335 | win.focus(); 336 | } 337 | 338 | function getLocalSubnetIPs() { 339 | const interfaces = os.networkInterfaces(); 340 | let ips = []; 341 | for (const ifaceList of Object.values(interfaces)) { 342 | for (const iface of ifaceList) { 343 | if (iface.family === 'IPv4' && !iface.internal) { 344 | const sub = iface.address.split('.').slice(0,3).join('.'); 345 | for (let i = 1; i <= 254; i++) { 346 | ips.push(`${sub}.${i}`); 347 | } 348 | } 349 | } 350 | } 351 | return [...new Set(ips)]; 352 | } 353 | 354 | function compareVersion(a, b) { 355 | const aa = a.split('.').map(Number); 356 | const bb = b.split('.').map(Number); 357 | for (let i = 0; i < Math.max(aa.length, bb.length); i++) { 358 | const x = aa[i] || 0, y = bb[i] || 0; 359 | if (x > y) return 1; 360 | if (x < y) return -1; 361 | } 362 | return 0; 363 | } 364 | 365 | function checkOpenWebUI(ip, port) { 366 | return new Promise(resolve => { 367 | const req = http.get({ host: ip, port, path: '/api/version', timeout: 1000 }, res => { 368 | let data = ''; 369 | res.on('data', chunk => data += chunk); 370 | res.on('end', () => { 371 | try { 372 | const json = JSON.parse(data); 373 | if (json.version && compareVersion(json.version, "0.6.5") >= 0) { 374 | resolve({ ip, port, version: json.version }); 375 | return; 376 | } 377 | } catch {} 378 | resolve(null); 379 | }); 380 | }); 381 | req.on('error', () => resolve(null)); 382 | req.on('timeout', () => { req.abort(); resolve(null); }); 383 | }); 384 | } 385 | 386 | async function scanNetworkForOpenWebUI() { 387 | const PORTS = [8000, 8080, 5000, 8001]; 388 | const ips = getLocalSubnetIPs(); 389 | const CONCURRENCY = 20; 390 | let results = []; 391 | const tasks = []; 392 | for (const ip of ips) { 393 | for (const port of PORTS) { 394 | tasks.push({ ip, port }); 395 | } 396 | } 397 | for (let i = 0; i < tasks.length; i += CONCURRENCY) { 398 | const slice = tasks.slice(i, i + CONCURRENCY); 399 | const found = await Promise.all(slice.map(t => checkOpenWebUI(t.ip, t.port))); 400 | results.push(...found.filter(Boolean)); 401 | } 402 | const unique = []; 403 | const seen = new Set(); 404 | for (const r of results) { 405 | const key = `${r.ip}:${r.port}`; 406 | if (!seen.has(key)) { 407 | seen.add(key); 408 | unique.push(r); 409 | } 410 | } 411 | return unique; 412 | } 413 | 414 | ipcMain.handle('scan-openwebui', async () => { 415 | return await scanNetworkForOpenWebUI(); 416 | }); 417 | } -------------------------------------------------------------------------------- /modal-preload.js: -------------------------------------------------------------------------------- 1 | const { contextBridge, ipcRenderer } = require('electron'); 2 | 3 | contextBridge.exposeInMainWorld('modalAPI', { 4 | getModels: () => ipcRenderer.invoke('modal-get-models'), 5 | startChat: (modelName, initialMessage) => ipcRenderer.invoke('modal-start-chat', modelName, initialMessage), 6 | openChat: (chatId) => ipcRenderer.send('modal-open-chat', chatId), 7 | close: () => ipcRenderer.send('modal-close') 8 | }); 9 | -------------------------------------------------------------------------------- /modal.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Select Model 6 | 7 | 8 | 9 | 10 | 11 |
12 |
13 |

Start a New Chat

14 |
15 |
16 | 17 |
18 | 19 | 20 |
    21 |
    22 |
    23 |
    24 | 25 |
    26 | 27 | 28 |
    29 | 30 | 31 | 32 |
    33 | 34 | 35 |
    36 |
    37 |
    38 |
    39 | 40 | 41 | -------------------------------------------------------------------------------- /modal.js: -------------------------------------------------------------------------------- 1 | const modelInput = document.getElementById('model-search'); 2 | const dropdown = document.getElementById('model-dropdown'); 3 | const loader = document.getElementById('model-loader'); 4 | const btnCancel = document.getElementById('btn-cancel'); 5 | const btnStartChat = document.getElementById('btn-start-chat'); 6 | const errorDiv = document.getElementById('modal-error'); 7 | const inputInitialMessage = document.getElementById('initial-message'); 8 | const dropdownArrow = document.getElementById('dropdown-arrow'); 9 | 10 | let allModels = []; 11 | let filteredModels = []; 12 | let selectedModel = null; 13 | 14 | // --- Fetch models on open 15 | function showLoader(show) { 16 | loader.style.display = show ? 'block' : 'none'; 17 | } 18 | function renderDropdown() { 19 | dropdown.innerHTML = ''; 20 | if (!filteredModels.length) { 21 | const li = document.createElement('li'); 22 | li.textContent = 'No models found'; 23 | li.style.color = '#888'; 24 | li.style.cursor = 'default'; 25 | dropdown.appendChild(li); 26 | } else { 27 | filteredModels.forEach(m => { 28 | const li = document.createElement('li'); 29 | li.textContent = m.name; 30 | if (selectedModel && m.name === selectedModel.name) li.classList.add('selected'); 31 | li.tabIndex = 0; 32 | li.addEventListener('click', () => { 33 | selectedModel = m; 34 | modelInput.value = m.name; 35 | dropdown.classList.remove('active'); 36 | renderDropdown(); 37 | checkForm(); 38 | }); 39 | li.addEventListener('keydown', e => { 40 | if ((e.key === 'Enter' || e.key === ' ') && !btnStartChat.disabled) { 41 | btnStartChat.click(); 42 | } 43 | }); 44 | dropdown.appendChild(li); 45 | }); 46 | } 47 | } 48 | 49 | function filterModels() { 50 | const val = modelInput.value.trim().toLowerCase(); 51 | filteredModels = allModels.filter(m => m.name.toLowerCase().includes(val)); 52 | renderDropdown(); 53 | dropdown.classList.add('active'); 54 | // Reset selection if input not exact 55 | if (!filteredModels.some(m => m.name === modelInput.value.trim())) { 56 | selectedModel = null; 57 | checkForm(); 58 | } 59 | } 60 | 61 | // --- Init (load models) 62 | showLoader(true); 63 | window.modalAPI.getModels() 64 | .then(models => { 65 | showLoader(false); 66 | allModels = models; 67 | filteredModels = [...models]; 68 | renderDropdown(); 69 | }) 70 | .catch(err => { 71 | showLoader(false); 72 | errorDiv.textContent = 'Error loading models: ' + (err.message || err); 73 | }); 74 | 75 | // --- Dropdown events 76 | modelInput.addEventListener('focus', () => { 77 | filterModels(); 78 | dropdown.classList.add('active'); 79 | }); 80 | modelInput.addEventListener('input', filterModels); 81 | modelInput.addEventListener('blur', () => setTimeout(() => dropdown.classList.remove('active'), 130)); 82 | dropdownArrow.addEventListener('mousedown', (e) => { 83 | e.preventDefault(); 84 | modelInput.focus(); 85 | filterModels(); 86 | }); 87 | 88 | // --- Enable start button only if all ok 89 | function checkForm() { 90 | const msg = inputInitialMessage.value.trim(); 91 | btnStartChat.disabled = !(selectedModel && msg); 92 | } 93 | inputInitialMessage.addEventListener('input', checkForm); 94 | 95 | // --- Cancel 96 | btnCancel.addEventListener('click', () => { 97 | window.modalAPI.close?.(); 98 | window.close(); 99 | }); 100 | 101 | // --- Start Chat 102 | btnStartChat.addEventListener('click', async () => { 103 | if (!selectedModel) return; 104 | btnStartChat.disabled = true; 105 | errorDiv.textContent = ''; 106 | const customText = inputInitialMessage.value.trim() || "Hi!"; 107 | try { 108 | const chatId = await window.modalAPI.startChat(selectedModel.name, customText); 109 | window.modalAPI.openChat(chatId); 110 | window.close(); 111 | } catch (err) { 112 | errorDiv.textContent = 'Error creating chat: ' + (err.message || err); 113 | btnStartChat.disabled = false; 114 | } 115 | }); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "openwebuisimpledesktop", 3 | "productName": "OpenWebUISimpleDesktop", 4 | "version": "0.1.4", 5 | "description": "OpenWebUI-SimpleDesktop", 6 | "author": "N1kozor ", 7 | "main": "main.js", 8 | "scripts": { 9 | "start": "electron .", 10 | "dist": "electron-builder", 11 | "dist-macOS": "electron-builder --mac --x64 --arm64" 12 | }, 13 | "build": { 14 | "files": [ 15 | "main.js", 16 | "preload.js", 17 | "modal.html", 18 | "modal.js", 19 | "modal-preload.js", 20 | "renderer.js", 21 | "index.html", 22 | "styles/**/*", 23 | "assets/**/*" 24 | ], 25 | "appId": "com.openwebuisimpledesktop.desktop", 26 | "productName": "OpenwebuiSimpleDesktop", 27 | "icon": "assets/ico.icns", 28 | "win": { 29 | "icon": "assets/ico.ico", 30 | "target": ["nsis", "portable"] 31 | }, 32 | "linux": { 33 | "icon": "assets/ico.png", 34 | "target": ["AppImage", "deb", "rpm", "pacman", "snap", "apk"] 35 | }, 36 | "nsis": { 37 | "oneClick": false, 38 | "perMachine": false, 39 | "allowToChangeInstallationDirectory": true, 40 | "createDesktopShortcut": true, 41 | "createStartMenuShortcut": true 42 | }, 43 | "mac": { 44 | "icon": "assets/ico.icns", 45 | "category": "public.app-category.productivity", 46 | "target": ["dmg"] 47 | } 48 | }, 49 | "dependencies": {}, 50 | "devDependencies": { 51 | "electron": ">=35.1.5", 52 | "electron-builder": ">=26.0.12" 53 | } 54 | } -------------------------------------------------------------------------------- /preload.js: -------------------------------------------------------------------------------- 1 | const { contextBridge, ipcRenderer } = require('electron'); 2 | 3 | contextBridge.exposeInMainWorld('electronAPI', { 4 | windowControls: { 5 | close: () => ipcRenderer.send('win-close'), 6 | minimize: () => ipcRenderer.send('win-min'), 7 | maximize: () => ipcRenderer.send('win-max'), 8 | }, 9 | getConfig: () => ipcRenderer.invoke('get-config'), 10 | onOpenChat: (callback) => ipcRenderer.on('open-chat', (event, url) => callback(url)), 11 | onReloadApp: (cb) => ipcRenderer.on('reload-app', cb), 12 | saveConfig: (config) => ipcRenderer.send('save-config', config), 13 | onConfigSaved: (cb) => ipcRenderer.on('config-saved', cb), 14 | onConfigSaveError: (cb) => ipcRenderer.on('config-save-error', (event, err) => cb(err)), 15 | onStartChatWithModel: (cb) => ipcRenderer.on('start-chat-with-model', (event, modelName) => cb(modelName)), 16 | scanOpenwebui: () => ipcRenderer.invoke('scan-openwebui') 17 | 18 | 19 | }); -------------------------------------------------------------------------------- /renderer.js: -------------------------------------------------------------------------------- 1 | let config = null; 2 | 3 | function setWizardTheme(theme) { 4 | const overlay = document.getElementById('wizard-overlay'); 5 | const inner = document.getElementById('wizardForm'); 6 | overlay.classList.remove('dark', 'light'); 7 | inner.classList.remove('dark', 'light'); 8 | overlay.classList.add(theme); 9 | inner.classList.add(theme); 10 | document.getElementById('darkThemeBtn').classList.toggle('active', theme === 'dark'); 11 | document.getElementById('lightThemeBtn').classList.toggle('active', theme === 'light'); 12 | } 13 | 14 | function showWizard() { 15 | document.getElementById('wizard-overlay').style.display = 'flex'; 16 | document.getElementById('preloader').style.display = 'none'; 17 | document.getElementById('webviewWrap').style.display = 'none'; 18 | document.querySelector('.window-buttons').style.display = 'none'; 19 | document.querySelector('.drag-overlay').style.display = 'none'; 20 | setTimeout(() => document.getElementById('appNameInput').focus(), 80); 21 | } 22 | 23 | function hideWizard() { 24 | document.getElementById('wizard-overlay').style.display = 'none'; 25 | document.getElementById('webviewWrap').style.display = ''; 26 | document.getElementById('preloader').style.display = ''; 27 | document.querySelector('.window-buttons').style.display = ''; 28 | document.querySelector('.drag-overlay').style.display = ''; 29 | } 30 | 31 | function wizardLogic() { 32 | const wizardStepIds = [ 33 | 'wizardStepAppName', 34 | 'wizardStepShortcut', 35 | 'wizardStepApiToken', 36 | 'wizardStepAppUrl', 37 | 'wizardStepPreloaderTheme', 38 | 'wizardStepReady' 39 | ]; 40 | const steps = wizardStepIds.map(id => document.getElementById(id)); 41 | function showStep(idx) { 42 | steps.forEach((step, i) => { 43 | step.style.display = (i === idx) ? (wizardStepIds[i] === 'wizardStepReady' ? 'flex' : 'block') : 'none'; 44 | }); 45 | if(idx === 0) setTimeout(()=>appNameInput.focus(), 80); 46 | if(idx === 2) setTimeout(()=>apiTokenInput.focus(), 80); 47 | if(idx === 3) setTimeout(()=>webuiUrlInput.focus(), 80); 48 | } 49 | const appNameInput = document.getElementById('appNameInput'); 50 | const apiTokenInput = document.getElementById('apiTokenInput'); 51 | const webuiUrlInput = document.getElementById('webuiUrlInput'); 52 | const nextToShortcut = document.getElementById('nextToShortcut'); 53 | const nextToApiToken = document.getElementById('nextToApiToken'); 54 | const nextToUrl = document.getElementById('nextToUrl'); 55 | const nextToPreloaderTheme = document.getElementById('nextToPreloaderTheme'); 56 | const nextToReady = document.getElementById('nextToReady'); 57 | const wizardStartBtn = document.getElementById('wizardStartBtn'); 58 | const wizardError = document.getElementById('wizard-error'); 59 | const preloaderBtnDark = document.getElementById('preloaderThemeDark'); 60 | const preloaderBtnLight = document.getElementById('preloaderThemeLight'); 61 | const scanNetworkBtn = document.getElementById('scanNetworkBtn'); 62 | const scanResultDiv = document.getElementById('networkScanResult'); 63 | let wizardData = {}; 64 | let preloaderThemeValue = null; 65 | appNameInput.addEventListener('input', () => { 66 | nextToShortcut.disabled = appNameInput.value.trim().length < 1; 67 | }); 68 | nextToShortcut.addEventListener('click', () => { 69 | wizardData.appName = appNameInput.value.trim(); 70 | showStep(1); 71 | }); 72 | nextToApiToken.addEventListener('click', () => { 73 | showStep(2); 74 | apiTokenInput.focus(); 75 | }); 76 | nextToUrl.addEventListener('click', () => { 77 | wizardData.apiToken = apiTokenInput.value; 78 | showStep(3); 79 | webuiUrlInput.focus(); 80 | }); 81 | function validateUrl(url) { 82 | url = url.trim(); 83 | return url.startsWith('http://') || url.startsWith('https://'); 84 | } 85 | webuiUrlInput.addEventListener('input', () => { 86 | nextToPreloaderTheme.disabled = !validateUrl(webuiUrlInput.value); 87 | }); 88 | nextToPreloaderTheme.addEventListener('click', () => { 89 | wizardData.webuiUrl = webuiUrlInput.value.trim(); 90 | showStep(4); 91 | nextToReady.disabled = true; 92 | preloaderThemeValue = null; 93 | preloaderBtnDark.classList.remove('selected'); 94 | preloaderBtnLight.classList.remove('selected'); 95 | }); 96 | [preloaderBtnDark, preloaderBtnLight].forEach(btn => { 97 | btn.onclick = () => { 98 | preloaderBtnDark.classList.toggle('selected', btn === preloaderBtnDark); 99 | preloaderBtnLight.classList.toggle('selected', btn === preloaderBtnLight); 100 | preloaderThemeValue = btn.dataset.theme; 101 | nextToReady.disabled = false; 102 | }; 103 | }); 104 | nextToReady.addEventListener('click', () => { 105 | showStep(5); 106 | wizardStartBtn.focus(); 107 | }); 108 | wizardStartBtn.addEventListener('click', e => { 109 | e.preventDefault(); 110 | wizardError.style.display = 'none'; 111 | const fullConfig = { 112 | iconDarwin: "assets/ico.icns", 113 | iconWin: "assets/ico.ico", 114 | bgColor: "#fff", 115 | borderRadius: 0, 116 | profileNavOffset: 15, 117 | sidebarOffset: 15, 118 | apiToken: wizardData.apiToken || "", 119 | windowButtons: "right", 120 | window: { 121 | width: 1200, 122 | height: 800, 123 | minWidth: 500, 124 | minHeight: 400 125 | }, 126 | appName: wizardData.appName, 127 | webuiUrl: wizardData.webuiUrl, 128 | preloaderTheme: preloaderThemeValue || "dark", 129 | opacity: 1 130 | }; 131 | window.electronAPI.saveConfig(fullConfig); 132 | document.getElementById('wizard-overlay').style.opacity = '0.2'; 133 | document.getElementById('wizard-overlay').style.pointerEvents = 'none'; 134 | }); 135 | appNameInput.addEventListener('keydown', e => { 136 | if (e.key === 'Enter' && !nextToShortcut.disabled) nextToShortcut.click(); 137 | }); 138 | apiTokenInput.addEventListener('keydown', e => { 139 | if (e.key === 'Enter') nextToUrl.click(); 140 | }); 141 | webuiUrlInput.addEventListener('keydown', e => { 142 | if (e.key === 'Enter' && !nextToPreloaderTheme.disabled) nextToPreloaderTheme.click(); 143 | }); 144 | window.electronAPI.onConfigSaveError(err => { 145 | wizardError.textContent = 'Failed to save config: ' + (err?.msg || 'Unknown error.'); 146 | wizardError.style.display = ''; 147 | document.getElementById('wizard-overlay').style.opacity = '1'; 148 | document.getElementById('wizard-overlay').style.pointerEvents = ''; 149 | }); 150 | window.electronAPI.onConfigSaved(() => { 151 | hideWizard(); 152 | window.electronAPI.getConfig().then(cfg => { 153 | if (cfg) runAppWithConfig(cfg); 154 | }); 155 | }); 156 | 157 | if (scanNetworkBtn) { 158 | let scanLoaderInterval = null; 159 | scanNetworkBtn.onclick = async () => { 160 | scanNetworkBtn.disabled = true; 161 | let dots = 0; 162 | scanResultDiv.innerHTML = `
    Scanning local network
    `; 163 | scanLoaderInterval = setInterval(() => { 164 | dots = (dots + 1) % 4; 165 | document.getElementById('dots').textContent = '.'.repeat(dots); 166 | }, 500); 167 | try { 168 | const servers = await window.electronAPI.scanOpenwebui(); 169 | clearInterval(scanLoaderInterval); 170 | if (servers.length === 0) { 171 | scanResultDiv.innerHTML = `
    No OpenWebUI server found on local network.
    `; 172 | } else { 173 | scanResultDiv.innerHTML = 174 | `
    Found OpenWebUI server${servers.length>1?'s':''}:
    ` + 175 | servers.map(s => 176 | `
    177 | 196 | ${s.ip}:${s.port} (v${s.version}) 197 | 198 |
    ` 199 | ).join(""); 200 | document.querySelectorAll('.found-server').forEach(link => { 201 | link.onclick = (e) => { 202 | e.preventDefault(); 203 | webuiUrlInput.value = `http://${link.dataset.ip}:${link.dataset.port}`; 204 | scanResultDiv.innerHTML = `
    Selected: ${webuiUrlInput.value}
    `; 205 | nextToPreloaderTheme.disabled = !validateUrl(webuiUrlInput.value); 206 | }; 207 | }); 208 | } 209 | } catch (err) { 210 | clearInterval(scanLoaderInterval); 211 | scanResultDiv.innerHTML = `
    Error during scan: ${err}
    `; 212 | } finally { 213 | scanNetworkBtn.disabled = false; 214 | clearInterval(scanLoaderInterval); 215 | } 216 | }; 217 | } 218 | } 219 | 220 | function runAppWithConfig(cfg) { 221 | config = cfg; 222 | document.getElementById('preloader-text').innerText = cfg.appName; 223 | const preloader = document.getElementById('preloader'); 224 | preloader.classList.remove('preloader-dark', 'preloader-light'); 225 | if (cfg.preloaderTheme === 'light') { 226 | preloader.classList.add('preloader-light'); 227 | } else { 228 | preloader.classList.add('preloader-dark'); 229 | } 230 | const btnBox = document.querySelector('.window-buttons'); 231 | if (cfg.windowButtons === 'right') { 232 | btnBox.classList.add('window-buttons-right'); 233 | btnBox.classList.remove('window-buttons-left'); 234 | } else { 235 | btnBox.classList.add('window-buttons-left'); 236 | btnBox.classList.remove('window-buttons-right'); 237 | } 238 | document.getElementById('webviewWrap').style.borderRadius = (cfg.borderRadius || 0) + 'px'; 239 | document.getElementById('webview').style.borderRadius = (cfg.borderRadius || 0) + 'px'; 240 | document.body.style.background = cfg.bgColor || '#fff'; 241 | const webview = document.getElementById('webview'); 242 | webview.src = cfg.webuiUrl; 243 | const preloaderElem = document.getElementById('preloader'); 244 | const errorMsg = document.getElementById('connection-error-message'); 245 | const refreshBtn = document.getElementById('connection-error-refresh'); 246 | const webviewWrap = document.getElementById('webviewWrap'); 247 | const dragOverlay = document.querySelector('.drag-overlay'); 248 | let preloaderDone = false; 249 | let minTimePassed = false; 250 | let lastError = false; 251 | setTimeout(() => { 252 | minTimePassed = true; 253 | if (preloaderDone) removePreloader(); 254 | }, 3000); 255 | function removePreloader() { 256 | preloaderElem.classList.add('preloader-fade'); 257 | setTimeout(() => preloaderElem.remove(), 500); 258 | } 259 | webview.addEventListener('did-fail-load', event => { 260 | if (event.isMainFrame) { 261 | webviewWrap.style.display = 'none'; 262 | dragOverlay.style.display = 'none'; 263 | if (errorMsg) errorMsg.style.display = 'flex'; 264 | preloaderElem.classList.add('preloader-fade'); 265 | setTimeout(() => preloaderElem.remove(), 500); 266 | lastError = true; 267 | } 268 | }); 269 | webview.addEventListener('did-finish-load', () => { 270 | if (!lastError) { 271 | preloaderDone = true; 272 | if (minTimePassed) removePreloader(); 273 | webviewWrap.style.display = ''; 274 | dragOverlay.style.display = ''; 275 | if (errorMsg) errorMsg.style.display = 'none'; 276 | webview.executeJavaScript(` const profileNavOffset = ${cfg.profileNavOffset}; const sidebarOffset = ${cfg.sidebarOffset}; const nav = document.querySelector('nav.sticky.top-0'); if (nav && !nav.dataset.movedByElectron) { nav.style.marginTop = profileNavOffset + 'px'; nav.style.zIndex = '100'; nav.dataset.movedByElectron = '1'; } const sidebar = document.querySelector('div.py-2.my-auto.flex.flex-col.justify-between'); if (sidebar && !sidebar.dataset.movedByElectron) { sidebar.style.marginTop = sidebarOffset + 'px'; sidebar.style.height = 'calc(100vh - ' + sidebarOffset + 'px)'; sidebar.style.maxHeight = 'calc(100dvh - ' + sidebarOffset + 'px)'; sidebar.dataset.movedByElectron = '1'; } const observer = new MutationObserver(() => { const nav = document.querySelector('nav.sticky.top-0'); if (nav && !nav.dataset.movedByElectron) { nav.style.marginTop = profileNavOffset + 'px'; nav.style.zIndex = '20'; nav.dataset.movedByElectron = '1'; } const sidebar = document.querySelector('div.py-2.my-auto.flex.flex-col.justify-between'); if (sidebar && !sidebar.dataset.movedByElectron) { sidebar.style.marginTop = sidebarOffset + 'px'; sidebar.style.height = 'calc(100vh - ' + sidebarOffset + 'px)'; sidebar.style.maxHeight = 'calc(100dvh - ' + sidebarOffset + 'px)'; sidebar.dataset.movedByElectron = '1'; } }); observer.observe(document.body, {childList:true, subtree:true}); `); 277 | } 278 | lastError = false; 279 | }); 280 | if (refreshBtn) { 281 | refreshBtn.onclick = () => { 282 | if (errorMsg) errorMsg.style.display = 'none'; 283 | webviewWrap.style.display = ''; 284 | dragOverlay.style.display = ''; 285 | preloaderElem.classList.remove('preloader-fade'); 286 | document.body.appendChild(preloaderElem); 287 | lastError = false; 288 | webview.src = cfg.webuiUrl; 289 | }; 290 | } 291 | } 292 | document.querySelector('.btn-close').onclick = () => window.electronAPI.windowControls.close(); 293 | document.querySelector('.btn-min').onclick = () => window.electronAPI.windowControls.minimize(); 294 | document.querySelector('.btn-max').onclick = () => window.electronAPI.windowControls.maximize(); 295 | 296 | function init() { 297 | setWizardTheme('dark'); 298 | document.getElementById('darkThemeBtn').onclick = () => setWizardTheme('dark'); 299 | document.getElementById('lightThemeBtn').onclick = () => setWizardTheme('light'); 300 | window.electronAPI.getConfig().then(cfg => { 301 | if (!cfg) { 302 | showWizard(); 303 | wizardLogic(); 304 | } else { 305 | config = cfg; 306 | hideWizard(); 307 | runAppWithConfig(cfg); 308 | } 309 | }); 310 | } 311 | 312 | init(); 313 | 314 | window.electronAPI.onOpenChat((url) => { 315 | const webview = document.getElementById('webview'); 316 | webview.src = 'about:blank'; 317 | setTimeout(() => { 318 | webview.src = url; 319 | }, 100); 320 | }); 321 | 322 | window.electronAPI.onReloadApp(() => { 323 | setTimeout(() => { 324 | window.electronAPI.getConfig().then(cfg => { 325 | if (cfg) { 326 | config = cfg; 327 | hideWizard(); 328 | runAppWithConfig(cfg); 329 | } 330 | }); 331 | }, 400); 332 | }); 333 | 334 | window.electronAPI.onStartChatWithModel = (modelName) => { 335 | const webview = document.getElementById('webview'); 336 | webview.src = config.webuiUrl; 337 | webview.addEventListener('did-finish-load', () => { 338 | webview.executeJavaScript(` const interval = setInterval(() => { const textarea = document.querySelector('textarea'); if (textarea) { textarea.value = "Hi!"; textarea.dispatchEvent(new Event('input')); const sendBtn = textarea.closest('form')?.querySelector('button[type="submit"]'); if (sendBtn) { sendBtn.click(); clearInterval(interval); } } }, 200); `); 339 | }, { once: true }); 340 | }; -------------------------------------------------------------------------------- /styles/index.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | width: 100vw; height: 100vh; 3 | margin: 0; padding: 0; border: none; 4 | background: #fff; 5 | font-family: Arial, sans-serif; 6 | color: #181818; 7 | overflow: hidden; 8 | user-select: none; 9 | } 10 | 11 | #preloader { 12 | position: fixed; 13 | left: 0; top: 0; width: 100vw; height: 100vh; 14 | z-index: 9999; 15 | display: flex; 16 | align-items: center; justify-content: center; 17 | font-size: 2.2em; 18 | font-weight: 600; 19 | letter-spacing: 0.04em; 20 | transition: background 0.3s, color 0.3s; 21 | } 22 | #preloader.preloader-light { 23 | background: #fff !important; 24 | color: #171717 !important; 25 | } 26 | #preloader.preloader-dark { 27 | background: #171717 !important; 28 | color: #fafafa !important; 29 | } 30 | #preloader.preloader-light #preloader-text { color: #171717 !important; } 31 | #preloader.preloader-dark #preloader-text { color: #fafafa !important; } 32 | .preloader-fade { 33 | opacity: 0; 34 | pointer-events: none; 35 | transition: opacity 0.6s; 36 | } 37 | #preloader-text { 38 | font-size: 2.7rem; 39 | font-weight: 700; 40 | letter-spacing: 0.01em; 41 | animation: pulsateTxt 2.1s ease-in-out infinite; 42 | max-width: 90vw; 43 | text-align: center; 44 | line-height: 1.1; 45 | } 46 | @keyframes pulsateTxt { 47 | 0% { transform: scale(1); opacity: 1;} 48 | 45% { transform: scale(1.12); opacity: 0.8;} 49 | 100% { transform: scale(1); opacity: 1;} 50 | } 51 | 52 | #wizard-overlay { 53 | position: absolute; left: 0; top: 0; width: 100vw; height: 100vh; 54 | z-index: 10001; align-items: center; justify-content: center; 55 | flex-direction: column; display: flex; 56 | transition: opacity 0.45s cubic-bezier(.45,1.7,.45,1); 57 | } 58 | #wizard-overlay.light { background: #fff; color: #181818; } 59 | #wizard-overlay.dark { background: #171717; color: #fafafa; } 60 | .wizard-inner { 61 | background: #fff; color: #181818; 62 | min-width: 320px; max-width: 400px; width: 100%; 63 | padding: 38px 32px 32px 32px; border-radius: 22px; box-shadow: 0 2px 32px 0 #0002; 64 | display: flex; flex-direction: column; gap: 32px; align-items: center; 65 | animation: fadeinWizard 0.6s cubic-bezier(.45,1.7,.45,1); 66 | } 67 | .wizard-inner.dark { 68 | background: #171717; 69 | color: #fafafa; 70 | box-shadow: 0 2px 32px 0 #000d; 71 | } 72 | .wizard-label, .wizard-inner { transition: background .3s, color .3s; } 73 | .wizard-label { font-size: 1.07rem; color: inherit; margin-bottom: 8px; letter-spacing: 0.01em; font-weight: 500; text-align: center; } 74 | 75 | .wizard-theme-toggle { 76 | position: absolute; top: 22px; right: 32px; display: flex; align-items: center; gap: 12px; z-index: 11000; font-size: 1rem; user-select: none; 77 | } 78 | .wizard-theme-btn { 79 | background: none; border: none; color: inherit; font-size: 1.4rem; cursor: pointer; padding: 2px 6px 2px 6px; border-radius: 7px; transition: background .12s, color .12s; outline: none; 80 | } 81 | .wizard-theme-btn.active { background: #4e4e4e; color: #fff; } 82 | #wizard-overlay.light .wizard-theme-btn.light, 83 | #wizard-overlay.dark .wizard-theme-btn.dark { background: #e4e4e8; color: #171717; } 84 | #wizard-overlay.dark .wizard-theme-btn.dark { background: #232334; color: #fff; } 85 | 86 | .wizard-input:not(#apiTokenInput) { text-align: center; } 87 | .wizard-input:not(#apiTokenInput)::placeholder { text-align: center; font-size: 1.05rem; opacity: .68; } 88 | .wizard-input { 89 | border: 2px solid #e2e2e2; border-radius: 10px; 90 | padding: 14px 16px; font-size: 1.18rem; 91 | background: #fafafa; color: #181818; 92 | font-weight: 600; box-shadow: 0 1px 8px #0001; 93 | width: 270px; 94 | } 95 | #wizardStepApiToken .wizard-input { 96 | width: 100%; 97 | max-width: 100%; 98 | box-sizing: border-box; 99 | } 100 | #wizard-overlay.dark .wizard-input { 101 | background: #232324; 102 | color: #fafafa; 103 | border-color: #3c3c3c; 104 | } 105 | #wizard-overlay.dark .wizard-input:focus { 106 | background: #232324; 107 | color: #fff; 108 | border-color: #4e4e4e; 109 | } 110 | .wizard-input:focus { border-color: #181818; outline: none; background:#fff; } 111 | 112 | .wizard-next { 113 | display: block; margin: 24px auto 0 auto; 114 | background: #4e4e4e; color: #fff; 115 | border: none; border-radius: 99px; 116 | font-size: 1.05rem; font-weight: 600; 117 | padding: 12px 38px; box-shadow: 0 2px 12px #0001; 118 | cursor: pointer; transition: background 0.16s, color 0.16s, opacity 0.19s; 119 | opacity: 0.97; letter-spacing: 0.03em; 120 | } 121 | .wizard-next:disabled { background: #ededed; color: #bbb; opacity: 0.6; cursor: not-allowed; } 122 | .wizard-btn { 123 | margin-top: 12px; border-radius: 99px; padding: 14px 38px; 124 | font-size: 1.15rem; font-weight: 700; 125 | border: none; background: #4e4e4e; color: #fff; 126 | cursor: pointer; box-shadow: 0 1px 14px #0001; 127 | transition: background 0.12s, color 0.12s, opacity 0.21s; 128 | } 129 | .wizard-btn:active { background: #171717; color: #fafafa; opacity: 0.87; } 130 | 131 | .preloader-theme-btn { 132 | background: #232324; color: #fafafa; border: 2px solid #4e4e4e; border-radius: 16px; font-size: 1.08rem; font-weight: 600; width: 90px; height: 75px; padding: 8px 0 4px 0; cursor: pointer; box-shadow: 0 2px 8px #0002; transition: background 0.16s, color 0.16s, border 0.16s, box-shadow 0.18s; 133 | } 134 | .preloader-theme-btn[data-theme="light"] { background: #eee; color: #2a2a2a; border: 2px solid #ddd; } 135 | .preloader-theme-btn.selected, 136 | .preloader-theme-btn:active { border: 2.5px solid #4e4e4e; box-shadow: 0 2px 14px #4e4e4e44; background: #4e4e4e; color: #fff; } 137 | .preloader-theme-btn[data-theme="light"].selected, 138 | .preloader-theme-btn[data-theme="light"]:active { background: #fff; color: #171717; border: 2.5px solid #4e4e4e; } 139 | 140 | #wizard-overlay.dark kbd { 141 | border: 1.5px solid #fafafa; 142 | background: #232324; 143 | color: #fafafa; 144 | box-shadow: 0 1px 2px #0002; 145 | } 146 | #wizard-overlay.light kbd { 147 | border: 1.5px solid #ccc; 148 | background: #fafafa; 149 | color: #222; 150 | box-shadow: 0 1px 2px #0001; 151 | } 152 | kbd { font-family: monospace; font-size: 1.1em; padding: 2px 8px; border-radius: 6px; border: 1.5px solid #bbb; 153 | background: #fafafa; margin: 0 1px; display: inline-block; min-width: 24px; text-align: center; } 154 | #wizardStepReady { text-align: center; display: flex; flex-direction: column; align-items: center; } 155 | @keyframes fadeinWizard { from { opacity: 0; transform: scale(.98);} to { opacity: 1; transform: scale(1);} } 156 | 157 | .webview-wrap { position: absolute; left: 0; top: 0; right: 0; bottom: 0; width: 100vw; height: 100vh; border-radius: 0px; background: #fff; overflow: hidden; } 158 | webview { width: 100vw; height: 100vh; border: none; border-radius: 0px; background: #fff; -webkit-app-region: no-drag; } 159 | .drag-overlay { position: absolute; left: 0; top: 0; width: 100vw; height: 15px; z-index: 150; -webkit-app-region: drag; background: transparent; pointer-events: auto; } 160 | .window-buttons { position: absolute; top: 10px; z-index: 200; background: none; -webkit-app-region: no-drag; display: flex; gap: 10px; } 161 | .window-buttons-left { left: 10px; right: auto; flex-direction: row; } 162 | .window-buttons-right { right: 10px; left: auto; flex-direction: row-reverse; } 163 | .window-btn { width: 12px; height: 12px; border-radius: 50%; border: none; outline: none; opacity: 0.88; cursor: pointer; transition: opacity 0.2s; box-shadow: none; } 164 | .window-btn:active { opacity: 1; } 165 | .btn-close { background: #f45c5c; } 166 | .btn-min { background: #fdbc40; } 167 | .btn-max { background: #37cd4c; } 168 | #connection-error-message { position: absolute; left: 0; top: 0; width: 100vw; height: 100vh; z-index: 10010; background: #fff; display: flex; align-items: center; justify-content: center; } 169 | .connection-error-inner { text-align: center; color: #181818; width: 100%; display: flex; flex-direction: column; align-items: center; } 170 | .connection-error-title { font-size: 2.1rem; font-weight: bold; margin-bottom: 20px; color: #181818; } 171 | .connection-error-details { font-size: 1.18rem; line-height: 1.5; color: #181818; margin-bottom: 26px; } 172 | .connection-error-refresh { background: #4e4e4e; color: #fff; border: none; padding: 12px 38px; font-size: 1.15rem; border-radius: 99px; font-weight: 700; cursor: pointer; transition: background 0.14s, color 0.14s, opacity 0.19s; } 173 | .connection-error-refresh:active { background: #171717; color: #fff; opacity: 0.87; } -------------------------------------------------------------------------------- /styles/modal.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap'); 2 | 3 | :root { 4 | --color-bg: #121212; 5 | --color-modal-bg: #1e1e2f; 6 | --color-border: #33334d; 7 | --color-primary: #4f8ef7; 8 | --color-primary-hover: #3c6fd5; 9 | --color-secondary: #2a2a3d; 10 | --color-error: #f44336; 11 | --color-text-primary: #e0e0e0; 12 | --color-text-secondary: #b0b0c3; 13 | --radius: 16px; 14 | --shadow: 0 4px 20px rgba(0, 0, 0, 0.7); 15 | --font-main: 'Inter', "Segoe UI", "Helvetica Neue", Arial, sans-serif; 16 | } 17 | 18 | body { 19 | font-family: var(--font-main); 20 | background: transparent; 21 | margin: 0; 22 | min-height: 100vh; 23 | } 24 | 25 | html, body, .gpt-modal-wrap { 26 | background: transparent !important; 27 | } 28 | 29 | .gpt-modal-wrap { 30 | min-height: 100vh; 31 | display: flex; 32 | align-items: center; 33 | justify-content: center; 34 | } 35 | 36 | .gpt-modal { 37 | background: var(--color-modal-bg); 38 | border-radius: var(--radius); 39 | max-width: 400px; 40 | width: 90vw; 41 | padding: 32px 28px 22px 28px; 42 | display: flex; 43 | flex-direction: column; 44 | gap: 18px; 45 | 46 | -webkit-app-region: drag; 47 | user-select: none; 48 | color: var(--color-text-primary); 49 | } 50 | 51 | .gpt-modal * { 52 | user-select: text; 53 | } 54 | 55 | .gpt-title { 56 | margin: 0 0 8px 0; 57 | font-size: 1.4rem; 58 | font-weight: 700; 59 | color: var(--color-text-primary); 60 | text-align: center; 61 | letter-spacing: -0.5px; 62 | -webkit-app-region: no-drag; 63 | } 64 | 65 | .gpt-field { 66 | margin-bottom: 6px; 67 | display: flex; 68 | flex-direction: column; 69 | gap: 7px; 70 | } 71 | 72 | .gpt-label { 73 | font-size: 1rem; 74 | font-weight: 600; 75 | color: var(--color-text-secondary); 76 | margin-bottom: 4px; 77 | letter-spacing: 0.1px; 78 | -webkit-app-region: no-drag; 79 | } 80 | 81 | .gpt-select-wrap { 82 | position: relative; 83 | width: 100%; 84 | } 85 | 86 | .gpt-dropdown-input { 87 | width: 100%; 88 | padding: 10px 38px 10px 12px; 89 | font-size: 1rem; 90 | border: 1.5px solid var(--color-border); 91 | border-radius: 9px; 92 | background: var(--color-secondary); 93 | outline: none; 94 | transition: border-color 0.18s, background-color 0.18s; 95 | font-family: inherit; 96 | color: var(--color-text-primary); 97 | box-sizing: border-box; 98 | -webkit-app-region: no-drag; 99 | } 100 | 101 | .gpt-dropdown-input::placeholder { 102 | color: var(--color-text-secondary); 103 | } 104 | 105 | .gpt-dropdown-input:focus { 106 | border-color: var(--color-primary); 107 | background: #292a45; 108 | } 109 | 110 | .gpt-dropdown-arrow { 111 | position: absolute; 112 | right: 11px; 113 | top: 50%; 114 | transform: translateY(-50%); 115 | font-size: 1.2rem; 116 | color: var(--color-text-secondary); 117 | pointer-events: none; 118 | z-index: 2; 119 | -webkit-app-region: no-drag; 120 | } 121 | 122 | .gpt-dropdown-list { 123 | position: absolute; 124 | width: 100%; 125 | background: var(--color-secondary); 126 | list-style: none; 127 | margin: 2px 0 0 0; 128 | padding: 0; 129 | border: 1px solid var(--color-border); 130 | border-radius: 9px; 131 | box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6); 132 | max-height: 220px; 133 | overflow-y: auto; 134 | z-index: 40; 135 | display: none; 136 | -webkit-app-region: no-drag; 137 | } 138 | 139 | .gpt-dropdown-list.active { 140 | display: block; 141 | } 142 | 143 | .gpt-dropdown-list li { 144 | padding: 11px 16px; 145 | font-size: 1rem; 146 | cursor: pointer; 147 | color: var(--color-text-primary); 148 | transition: background 0.15s, color 0.15s; 149 | border-bottom: 1px solid #3a3a5a; 150 | -webkit-app-region: no-drag; 151 | } 152 | 153 | .gpt-dropdown-list li:last-child { 154 | border-bottom: none; 155 | } 156 | 157 | .gpt-dropdown-list li.selected, 158 | .gpt-dropdown-list li:hover { 159 | background: var(--color-primary); 160 | color: #fff; 161 | } 162 | 163 | .gpt-loader { 164 | position: absolute; 165 | right: 38px; 166 | top: 50%; 167 | transform: translateY(-50%); 168 | width: 22px; 169 | height: 22px; 170 | border: 3px solid #3a3a5a; 171 | border-top: 3px solid var(--color-primary); 172 | border-radius: 50%; 173 | animation: spin 1s linear infinite; 174 | display: none; 175 | z-index: 10; 176 | -webkit-app-region: no-drag; 177 | } 178 | 179 | @keyframes spin { 180 | 0% { 181 | transform: translateY(-50%) rotate(0deg); 182 | } 183 | 100% { 184 | transform: translateY(-50%) rotate(360deg); 185 | } 186 | } 187 | 188 | .gpt-message-input { 189 | width: 100%; 190 | padding: 10px 12px; 191 | font-size: 1rem; 192 | border: 1.5px solid var(--color-border); 193 | border-radius: 9px; 194 | background: var(--color-secondary); 195 | outline: none; 196 | font-family: inherit; 197 | color: var(--color-text-primary); 198 | box-sizing: border-box; 199 | transition: border-color 0.18s, background-color 0.18s; 200 | -webkit-app-region: no-drag; 201 | } 202 | 203 | .gpt-message-input::placeholder { 204 | color: var(--color-text-secondary); 205 | } 206 | 207 | .gpt-message-input:focus { 208 | border-color: var(--color-primary); 209 | background: #292a45; 210 | } 211 | 212 | .gpt-actions { 213 | margin-top: 20px; 214 | display: flex; 215 | justify-content: center; 216 | gap: 20px; 217 | } 218 | 219 | .gpt-btn { 220 | padding: 10px 28px; 221 | border-radius: 9px; 222 | font-size: 1rem; 223 | font-weight: 700; 224 | border: none; 225 | cursor: pointer; 226 | transition: background-color 0.2s, color 0.2s; 227 | outline: none; 228 | min-width: 120px; 229 | user-select: none; 230 | -webkit-app-region: no-drag; 231 | box-shadow: 0 3px 8px rgba(0, 0, 0, 0.3); 232 | } 233 | 234 | .gpt-cancel-btn { 235 | background: #3a3a5a; 236 | color: var(--color-text-primary); 237 | } 238 | 239 | .gpt-cancel-btn:hover, 240 | .gpt-cancel-btn:focus { 241 | background: #535376; 242 | } 243 | 244 | .gpt-start-btn { 245 | background: var(--color-primary); 246 | color: #fff; 247 | } 248 | 249 | .gpt-start-btn:hover, 250 | .gpt-start-btn:focus { 251 | background: var(--color-primary-hover); 252 | } 253 | 254 | .gpt-btn:disabled { 255 | background: #555570; 256 | color: #aaaabb; 257 | cursor: not-allowed; 258 | box-shadow: none; 259 | } 260 | 261 | .gpt-error { 262 | min-height: 24px; 263 | color: var(--color-error); 264 | font-size: 1rem; 265 | text-align: center; 266 | margin-bottom: 4px; 267 | -webkit-app-region: no-drag; 268 | } 269 | 270 | @media (max-width: 480px) { 271 | .gpt-modal { 272 | padding: 20px 16px 16px 16px; 273 | } 274 | .gpt-title { 275 | font-size: 1.2rem; 276 | } 277 | .gpt-btn { 278 | min-width: 100px; 279 | padding: 10px 20px; 280 | font-size: 0.95rem; 281 | } 282 | } --------------------------------------------------------------------------------