├── .env.sample ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── deploy.sh ├── get-version.sh ├── package-lock.json ├── package.json ├── scripts ├── create_cross_icon.py └── create_gear_icon.py ├── src ├── index.html ├── index.js ├── js │ ├── api.js │ ├── app.js │ ├── markdown.js │ └── utils.js ├── static │ ├── icon.png │ └── manifest.json └── style │ └── main.css └── webpack.config.js /.env.sample: -------------------------------------------------------------------------------- 1 | DOMAIN=assistant.bloat.app 2 | 3 | SSH_USER=user 4 | WWW_DIR=/var/www/assistant.bloat.app -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .env.staging 3 | .env.production 4 | node_modules/ 5 | dist/ -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ## [1.10.0] – 2024-09-25 11 | - Added o1-preview and o1-mini support. ([API limitations](https://platform.openai.com/docs/guides/reasoning/beta-limitations)) 12 | - Updated dependencies and `get-version.sh` 13 | 14 | ## [1.9.0] – 2024-07-18 15 | - Added GPT-4o mini support. 16 | 17 | ## [1.8.0] – 2024-05-14 18 | - Added GPT-4o support. 19 | - Changed GPT-4 Turbo from `gpt-4-1106-preview` to `gpt-4-turbo`. 20 | 21 | ## [1.7.0] – 2023-11-22 22 | - Last selected model is now remembered ([#103](https://github.com/felixbade/assistant/issues/103)). 23 | 24 | ## [1.6.0] – 2023-11-07 25 | 26 | - Added GPT-4 Turbo support. 27 | - Updated GPT-3.5 Turbo version. 28 | 29 | ## [1.5.2] – 2023-04-22 30 | 31 | ### Fixed 32 | - Long words no longer make the messages view wider than the window ([#81](https://github.com/felixbade/assistant/issues/81)). 33 | 34 | ## [1.5.1] – 2023-04-22 35 | 36 | ### Fixed 37 | - Single linebreaks in user’s message are now rendered in message bubble like in compose box ([#92](https://github.com/felixbade/assistant/issues/92)). 38 | 39 | ## [1.5.0] – 2023-03-30 40 | 41 | ### Added 42 | 43 | - Support for unlimited length conversations. Settings for number of last messages sent ([#70](https://github.com/felixbade/chatgpt-web-ui/issues/70)). 44 | - Settings for initial system message ([#5](https://github.com/felixbade/chatgpt-web-ui/issues/5)). 45 | 46 | ### Fixed 47 | 48 | - Style for <hr> element (markdown: `---`) ([#76](https://github.com/felixbade/chatgpt-web-ui/issues/76)). 49 | 50 | ## [1.4.2] – 2023-03-29 51 | 52 | ### Fixed 53 | 54 | - Screenshot function background in dark mode ([#77](https://github.com/felixbade/chatgpt-web-ui/issues/77)). 55 | 56 | ## [1.4.1] – 2023-03-29 57 | 58 | ### Added 59 | 60 | - Save conversation as markdown ([#78](https://github.com/felixbade/chatgpt-web-ui/issues/78)). 61 | 62 | ## [1.4.0] – 2023-03-20 63 | 64 | ### Added 65 | 66 | - Intro view on page load if API key is not set ([#9](https://github.com/felixbade/chatgpt-web-ui/issues/9)). 67 | - Settings page ([#6](https://github.com/felixbade/chatgpt-web-ui/issues/6)). 68 | - Save conversation as a screenshot ([#17](https://github.com/felixbade/chatgpt-web-ui/issues/17)). 69 | 70 | ## [1.3.1] – 2023-03-19 71 | 72 | ### Fixed 73 | 74 | - Compose box background blur is now full width ([#64](https://github.com/felixbade/chatgpt-web-ui/issues/64)). 75 | 76 | ## [1.3.0] – 2023-03-19 77 | 78 | ### Added 79 | 80 | - Favicon ([#26](https://github.com/felixbade/chatgpt-web-ui/issues/26)). 81 | - Proper styling for tables ([#65](https://github.com/felixbade/chatgpt-web-ui/issues/65), [#71](https://github.com/felixbade/chatgpt-web-ui/issues/71)). 82 | - Model changing (GPT-3.5 / GPT-4) with ctrl+M. 83 | 84 | ### Changed 85 | 86 | - Copy with click now copies code blocks, not whole messages ([#29](https://github.com/felixbade/chatgpt-web-ui/issues/29)). 87 | 88 | ## [1.2.0] – 2023-03-16 89 | 90 | ### Added 91 | 92 | - Support for GPT-4 via dropdown. GPT-3.5 is also supported. ([#67](https://github.com/felixbade/chatgpt-web-ui/issues/67)) 93 | 94 | 95 | ## [1.1.5] – 2023-03-12 96 | 97 | ### Fixed 98 | 99 | - Scrolling to the bottom of the page on new message ([#61](https://github.com/felixbade/chatgpt-web-ui/issues/61)). 100 | 101 | ## [1.1.4] – 2023-03-12 102 | 103 | ### Changed 104 | 105 | - Improved PWA caching with Workbox – all files come from cache by default, but update in the background ([#63](https://github.com/felixbade/chatgpt-web-ui/issues/63)). 106 | 107 | ### Fixed 108 | 109 | - ”Start by getting an API key” no longer flashes on page refresh ([#63](https://github.com/felixbade/chatgpt-web-ui/issues/63)). 110 | 111 | ## [1.1.3] – 2023-03-12 112 | 113 | ### Fixed 114 | 115 | - iOS Safari: text is no longer incorrectly zooming when the phone is in landscape orientation ([#59](https://github.com/felixbade/chatgpt-web-ui/issues/59)). 116 | 117 | ## [1.1.2] – 2023-03-12 118 | 119 | ### Fixed 120 | 121 | - Images no longer expand outside the message bubble ([#62](https://github.com/felixbade/chatgpt-web-ui/issues/62)). 122 | 123 | ## [1.1.1] – 2023-03-12 124 | 125 | ### Fixed 126 | 127 | - Layout with devices that have a notch ([#58](https://github.com/felixbade/chatgpt-web-ui/issues/58)). 128 | 129 | ## [1.1.0] – 2023-03-12 130 | 131 | ### Added 132 | 133 | - Streaming responses ([#45](https://github.com/felixbade/chatgpt-web-ui/issues/45)). 134 | 135 | ### Changed 136 | 137 | - Scrolling is now instant after new data in assistant’s message. User’s messages still use smooth scrolling. 138 | 139 | ### Fixed 140 | 141 | - Ordering of responses when multiple questions are sent before a response is received ([#47](https://github.com/felixbade/chatgpt-web-ui/issues/47)). 142 | 143 | ## [1.0.4] – 2023-03-09 144 | 145 | ### Added 146 | 147 | - Link to API usage page ([#8](https://github.com/felixbade/chatgpt-web-ui/issues/8)). 148 | 149 | ## [1.0.3] – 2023-03-09 150 | 151 | ### Added 152 | 153 | - Background for code blocks and inline code to increase contrast. 154 | - Spacing between paragraphs. 155 | 156 | ### Fixed 157 | 158 | - Code blocks with long lines on narrow screens causing horizontal scroll. 159 | 160 | ## [1.0.2] – 2023-03-07 161 | 162 | ### Fixed 163 | 164 | - Multi-line code block rendering ([#55](https://github.com/felixbade/chatgpt-web-ui/issues/55)). 165 | 166 | ## [1.0.1] – 2023-03-07 167 | 168 | ### Fixed 169 | 170 | - PWA error due to incorrect cacheable files list ([#56](https://github.com/felixbade/chatgpt-web-ui/issues/56)). 171 | 172 | ## [1.0.0] – 2023-03-07 173 | 174 | - See commits for all the stuff that was added. 175 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | You found this page. Maybe you will also find bugs or missing features. You can tell me about them in the GitHub [issues](https://github.com/felixbade/assistant/issues). 4 | 5 | - 🙌 Feature requests 🔥 6 | - 🤩 Bug reports 🫶 7 | - 🤨 Pull requests 👀 8 | 9 | ## Philosophy 10 | - OpenAI has made the best chat AI API. This tools shall be the best human-interface for that. 11 | - ”Getting started” UX is extremely important. It is the main filter for user base growth after marketing. 12 | - User ergonomy is insanely valuable. If the tool makes power-users annoyed, they will find something else eventually. 13 | - New features need a UI, which is distracting to core features. New features must be more valuable than the clutter and complexity they are adding. Even existing, carefully crafted features must be killed if their justification becomes non-obvious later on. It’s a bonsai tree. ✂️ 14 | - The user should be in control, not the platform. 15 | - Let’s show the world just how much is possible with little. 16 | 17 | ## Feature requests 18 | My goal is to make this app a fully equipped productivity tool, while keeping the UX and codebase super simple. The bigger the idea pool of new features, the better choices I can make when prioritizing development. 19 | - Please explain **why** your feature would be useful for you. **What kind of problem would it solve?** A simple explanation is sufficient – even if the value of a new feature is obvious to you, it is not always obvious to me. 20 | 21 | See [existing feature requests](https://github.com/felixbade/assistant/issues?q=is%3Aissue+label%3Aenhancement) for inspiration. 22 | 23 | ## Bug reports 24 | I want to make this tool rock solid and ergonomic, but I can’t test all scenarios. The first step of fixing bugs is finding them, and the second is describing what the bug is about. You can do these without touching the code. The better your report, the more likely it is that I fix it quickly. 25 | 26 | - Please explain how to reproduce the bug. I can’t fix it if I can’t observe it. 27 | - Please explain how it behaves, and how you would expect it to behave. Again, it might be obvious to you, but... 28 | - Some bugs are debatable, but in general, if it's a ”feature” that distracts you, it’s a bug. 29 | 30 | See [existing bug reports](https://github.com/felixbade/assistant/issues?q=is%3Aissue+label%3Abug+) for inspiration. 31 | 32 | If you are not sure whether your issue is a bug report or a feature request, don’t worry, I will add an adequate label (or just fix it). 33 | 34 | ## Pull requests 35 | In general, I’m more interested in feature requests and bug reports than pull requests. My goal is not to add all possible features – rather, to curate a minimal, yet powerful feature set. 36 | 37 | This might change later if I no longer have time to actively develop this project. 38 | 39 | Feel free to make your own fork though! 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2023 Felix Bade 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 10 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 11 | AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 12 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 13 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 14 | OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 15 | PERFORMANCE OF THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Assistant – Web UI for ChatGPT API 2 | 3 | https://assistant.bloat.app 4 | 5 | A mobile-friendly human interface for ChatGPT API. There is no back-end server, so you can easily host your own instance (see details below). 6 | 7 | See [`CHANGELOG.md`](CHANGELOG.md) for what’s new. 8 | 9 | ## 🤔 Why API instead of ChatGPT Plus? 10 | - `gpt-3.5-turbo`: **Cheap** token-based cost, instead of flat 20$/mo 11 | - `gpt-4`: **Unlimited** requests (requires early access) 12 | - **No logouts** – if OpenAI started revoking API keys weekly, a lot of big SaaS products would get very angry 13 | - **Private** – OpenAI’s API policy says your prompts won’t be used for developing, unlike in the playground. 14 | 15 | ## 🔩 Features 16 | - Very polished, mobile-friendly UI 17 | - PWA support: can be added to the phone’s home screen or installed on your computer as a Chrome app 18 | - Requests go directly from the browser to OpenAI – no backend server 19 | - Settings and API key are stored in `localStorage` 20 | - Unlimited conversation length by sending only x latest messages to the API (configurable) 21 | - Markdown rendering 22 | - Bold, italic 23 | - Embedded links 24 | - Code keywords 25 | - Code blocks 26 | - Tables 27 | - Images 28 | - Horizontal lines 29 | - Automatic dark/light theme 30 | - Export the conversation as markdown 31 | - Screenshot the whole conversation even if it doesn't fit the window 32 | - Customize the assistant’s behind-the-scenes system message prompt 33 | - Send follow-up messages even before the previous answer is complete (processed in parallel) 34 | - Change the model with ctrl+M 35 | - Open Assistant with an initial prompt if you want to make integrations into other apps. Example: [https://assistant.bloat.app/#q=hello%20there](https://assistant.bloat.app/#q=hello%20there) 36 | 37 | ## 🚚 In the future 38 | - Search old chats with GPT embeddings (meaning your search words don’t need to be an exact match) 39 | - Speak your messages using the Whisper API 40 | 41 | ## 🔧 Hosting your own version 42 | - `npm install` 43 | - `npm run build:prod` 44 | - The bundle will go under `dist/`. Copy those files somewhere so they show up as a web page (for example GitHub Pages or Nginx). 45 | 46 | ## 💙 Contributing 47 | The best way to contribute is by adding feature requests and bug reports to the GitHub [issues](https://github.com/felixbade/chatgpt-web-ui/issues) – you don’t need to be a programmer for that. See [`CONTRIBUTING.md`](CONTRIBUTING.md) for details. 48 | -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | if [ -z "$1" ]; then 2 | echo "Usage: $0 " 3 | exit 1 4 | else 5 | source $1 6 | fi 7 | 8 | HOST="$SSH_USER@$DOMAIN" 9 | 10 | # # https://stackoverflow.com/a/63438492 11 | rsync -vhra dist/* $HOST:$WWW_DIR --delete-after -------------------------------------------------------------------------------- /get-version.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if ! git status > /dev/null 2>&1; then 4 | # not in a git directory 5 | echo 'unknown' 6 | exit 0 7 | fi 8 | 9 | # Return the current git tag (if present) or the first characters of the commit hash 10 | GIT_HASH=$(git log -n1 --pretty='%h') 11 | DESCRIBE=$(git describe --exact-match --tags $GIT_HASH 2> /dev/null) 12 | 13 | if [[ -n $DESCRIBE ]]; then 14 | # Remove 'v' from beginning of tag, if present before a number 15 | RESULT=$(echo $DESCRIBE | sed 's/^v\([0-9]\)/\1/') 16 | else 17 | RESULT=$GIT_HASH 18 | fi 19 | 20 | # Add a * to the hash if there are uncommitted changes 21 | if [[ -n $(git status -s) ]]; then 22 | RESULT+="*" 23 | fi 24 | 25 | echo $RESULT 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web-assistant-ai", 3 | "version": "1.0.0", 4 | "description": "ChatGPT API wrapper", 5 | "main": "src/index.html", 6 | "scripts": { 7 | "build:dev": "webpack --mode development --env version=$(./get-version.sh)", 8 | "build:prod": "webpack --mode production --env version=$(./get-version.sh)", 9 | "deploy:staging": "./deploy.sh .env.staging", 10 | "deploy:production": "./deploy.sh .env.production", 11 | "staging": "npm run build:prod && npm run deploy:staging", 12 | "production": "npm run build:prod && npm run deploy:production", 13 | "start": "npx webpack-dev-server --mode=development", 14 | "test": "echo \"Error: no test specified\" && exit 1" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/felixbade/open-ai-web-ui.git" 19 | }, 20 | "keywords": [ 21 | "chatgpt" 22 | ], 23 | "author": "Felix Bade", 24 | "license": "ISC", 25 | "bugs": { 26 | "url": "https://github.com/felixbade/open-ai-web-ui/issues" 27 | }, 28 | "homepage": "https://github.com/felixbade/open-ai-web-ui#readme", 29 | "devDependencies": { 30 | "clean-webpack-plugin": "^4.0.0", 31 | "copy-webpack-plugin": "^11.0.0", 32 | "css-loader": "^6.7.3", 33 | "html-webpack-plugin": "^5.5.0", 34 | "mini-css-extract-plugin": "^2.7.2", 35 | "webpack": "^5.75.0", 36 | "webpack-cli": "^5.0.1", 37 | "webpack-dev-server": "^4.11.1" 38 | }, 39 | "dependencies": { 40 | "dompurify": "^3.0.1", 41 | "html2canvas": "^1.4.1", 42 | "marked": "^4.2.12", 43 | "workbox-webpack-plugin": "^6.5.4" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /scripts/create_cross_icon.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from xml.dom import minidom 3 | from xml.etree.ElementTree import Element, SubElement, tostring 4 | 5 | def generate_x_icon_svg(line_thickness, cross_length): 6 | canvas_size = (line_thickness + cross_length) 7 | 8 | xmlns = "http://www.w3.org/2000/svg" 9 | svg = Element('svg', { 10 | 'xmlns': xmlns, 11 | 'width': f'{canvas_size*2}', 12 | 'height': f'{canvas_size*2}', 13 | 'viewBox': f'-{line_thickness} -{line_thickness} {canvas_size + line_thickness} {canvas_size + line_thickness}' 14 | }) 15 | 16 | path = SubElement(svg, 'path', { 17 | 'stroke': 'var(--settings-icon-border)', 18 | # 'stroke': 'black', 19 | 'stroke-width': f'{line_thickness}', 20 | 'fill': 'none', 21 | 'd': f"M0,0 L{cross_length},{cross_length} M0,{cross_length} L{cross_length},0", 22 | }) 23 | 24 | raw_svg = tostring(svg) 25 | pretty_svg = minidom.parseString(raw_svg).toprettyxml(indent=" ") 26 | return pretty_svg 27 | 28 | if __name__ == "__main__": 29 | parser = argparse.ArgumentParser() 30 | parser.add_argument('-t', '--thickness', type=float, required=True, help="Set the line thickness.") 31 | parser.add_argument('-c', '--cross_length', type=float, required=True, help="Set the length of the cross.") 32 | parser.add_argument('-o', '--output', type=str, default='x_icon.svg', help="Output SVG file name (default: x_icon.svg)") 33 | 34 | args = parser.parse_args() 35 | 36 | svg = generate_x_icon_svg(args.thickness, args.cross_length) 37 | 38 | with open(args.output, 'w') as f: 39 | f.write(svg) 40 | -------------------------------------------------------------------------------- /scripts/create_gear_icon.py: -------------------------------------------------------------------------------- 1 | # Partially written by ChatGPT 2 | # Example: 3 | # python create_svg_gear.py -w 5 -n 9 -i 35 -t 0.4 -b 0.2 -d 12 4 | 5 | 6 | import svgwrite 7 | import math 8 | import argparse 9 | 10 | def wavy_gear_svg(radius_outer, radius_inner, num_teeth, line_width, teeth_depth, teeth_width, base_width, file_name): 11 | canvas_size = 2 * (radius_outer + line_width) 12 | dwg = svgwrite.Drawing(file_name, profile='tiny', size=("100%", "100%"), viewBox=(f"0 0 {canvas_size} {canvas_size}")) 13 | 14 | angle_step = 2 * math.pi / num_teeth 15 | gear_path = svgwrite.path.Path(d='M', stroke='black', fill='white', fill_rule='evenodd', stroke_width=line_width) 16 | 17 | for i in range(num_teeth): 18 | angle = i * angle_step - math.pi/2 19 | angle_next = angle + angle_step 20 | 21 | gear_center = canvas_size / 2 22 | 23 | tooth_width = angle_step * teeth_width 24 | base_width2 = angle_step * base_width 25 | tip_angle = angle + angle_step / 2 26 | 27 | def push_angle(radius, a): 28 | x = radius * math.cos(a) + gear_center 29 | y = radius * math.sin(a) + gear_center 30 | gear_path.push(round(x, 3), round(y, 3)) 31 | 32 | # Tooth Tip 33 | if i != 0: 34 | gear_path.push("L") 35 | push_angle(radius_outer, angle + tooth_width/2) 36 | 37 | # Tooth Base 38 | gear_path.push("L") 39 | push_angle(radius_outer - teeth_depth, tip_angle - base_width2 / 2) 40 | gear_path.push("L") 41 | push_angle(radius_outer - teeth_depth, tip_angle + base_width2 / 2) 42 | 43 | # Tooth Tip 44 | gear_path.push("L") 45 | push_angle(radius_outer, angle_next - tooth_width/2) 46 | 47 | 48 | # Close the path 49 | if i == num_teeth - 1: 50 | gear_path.push("Z") 51 | 52 | dwg.add(gear_path) 53 | dwg.add(dwg.circle(center=(gear_center, gear_center), r=radius_inner / 2, stroke='black', stroke_width=line_width, fill='white')) 54 | dwg.save() 55 | 56 | def main(): 57 | parser = argparse.ArgumentParser(description="Generate a wavy gear SVG icon with configurable parameters") 58 | parser.add_argument("-o", "--radius_outer", type=int, default=50, help="Outer radius of the gear") 59 | parser.add_argument("-i", "--radius_inner", type=int, default=32, help="Inner radius of the gear") 60 | parser.add_argument("-n", "--num_teeth", type=int, default=20, help="Number of teeth in the gear") 61 | parser.add_argument("-w", "--line_width", type=float, default=0.5, help="Line width for the gear") 62 | parser.add_argument("-d", "--teeth_depth", type=float, default=10, help="Depth of the teeth in the gear") 63 | parser.add_argument("-t", "--teeth_width", type=float, default=0.4, help="Width of the teeth tip in the gear, as a proportion of the space between teeth") 64 | parser.add_argument("-b", "--base_width", type=float, default=0.4, help="Width of the teeth base in the gear, as a proportion of the space between teeth") 65 | parser.add_argument("-f", "--file_name", type=str, default="settings_gear_icon.svg", help="Output file name for the SVG icon") 66 | 67 | args = parser.parse_args() 68 | 69 | wavy_gear_svg(radius_outer=args.radius_outer, radius_inner=args.radius_inner, num_teeth=args.num_teeth, line_width=args.line_width, teeth_depth=args.teeth_depth, teeth_width=args.teeth_width, base_width=args.base_width, file_name=args.file_name) 70 | 71 | if __name__ == "__main__": 72 | main() 73 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Assistant 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 57 | 58 | 112 | 113 |
114 | 119 | 127 |
128 |
129 |
130 |
131 |
132 | 133 |
134 | 135 |
136 | 137 | 138 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import './style/main.css' 2 | import './js/app.js' 3 | 4 | if ('serviceWorker' in navigator) { 5 | navigator.serviceWorker.register('/service-worker.js').then(registration => { 6 | console.log('ServiceWorker registration successful with scope: ', registration.scope) 7 | }, error => { 8 | console.log('ServiceWorker registration failed: ', error) 9 | }) 10 | } -------------------------------------------------------------------------------- /src/js/api.js: -------------------------------------------------------------------------------- 1 | export const getModels = (apiKey) => { 2 | const endpoint = 'https://api.openai.com/v1/models' 3 | 4 | return fetch(endpoint, { 5 | method: 'GET', 6 | headers: { 7 | 'Content-Type': 'application/json', 8 | 'Authorization': `Bearer ${apiKey}` 9 | } 10 | }) 11 | .then(response => response.json()) 12 | } 13 | 14 | export const chatCompletion = (apiKey, data) => { 15 | const endpoint = 'https://api.openai.com/v1/chat/completions' 16 | 17 | if (!data.model) { 18 | data.model = 'gpt-3.5-turbo' 19 | } 20 | 21 | return fetch(endpoint, { 22 | method: 'POST', 23 | headers: { 24 | 'Content-Type': 'application/json', 25 | 'Authorization': `Bearer ${apiKey}` 26 | }, 27 | body: JSON.stringify(data) 28 | }) 29 | .then(response => response.json()) 30 | .catch(error => { 31 | console.error('Error:', error) 32 | }) 33 | } 34 | 35 | export const chatCompletionStream = (apiKey, data, callback) => { 36 | const endpoint = 'https://api.openai.com/v1/chat/completions' 37 | 38 | if (!data.model) { 39 | data.model = 'gpt-3.5-turbo' 40 | } 41 | 42 | data.stream = true 43 | 44 | return fetch(endpoint, { 45 | method: 'POST', 46 | headers: { 47 | 'Content-Type': 'application/json', 48 | 'Authorization': `Bearer ${apiKey}` 49 | }, 50 | body: JSON.stringify(data) 51 | }) 52 | .then(response => { 53 | const reader = response.body.getReader() 54 | 55 | let received = '' 56 | 57 | const inputHandler = ({ done, value }) => { 58 | if (done) { 59 | return 60 | } 61 | const decoder = new TextDecoder('utf-8') 62 | received += decoder.decode(value) 63 | 64 | while (received.indexOf('\n') !== -1) { 65 | const index = received.indexOf('\n') 66 | const line = received.substring(0, index) 67 | received = received.substring(index+1) 68 | 69 | const content = line.replace('data: ', '') 70 | if (!content) continue 71 | if (content === '[DONE]') return 72 | let json 73 | try { 74 | json = JSON.parse(content) 75 | } catch (e) { 76 | // multi-line json? 77 | received = line + received 78 | continue 79 | } 80 | 81 | callback(json) 82 | } 83 | 84 | reader.read().then(inputHandler) 85 | } 86 | 87 | reader.read().then(inputHandler) 88 | .catch(error => { 89 | console.error('Error reading stream:', error) 90 | reader.cancel() 91 | }) 92 | }) 93 | .catch(error => { 94 | console.error('Error:', error) 95 | }) 96 | } -------------------------------------------------------------------------------- /src/js/app.js: -------------------------------------------------------------------------------- 1 | import { chatCompletionStream, getModels } from './api' 2 | import { 3 | parseHashParams, 4 | isScrolledToBottom, 5 | updateTextareaSize, 6 | setupPersistentInputs 7 | } from './utils' 8 | import { markdownToDocumentFragment } from './markdown' 9 | import html2canvas from 'html2canvas' 10 | 11 | const setupAPIKeyInput = () => { 12 | window.addEventListener('storage', event => { 13 | if (event.key === 'api-key') { 14 | updateApiKeyStatus() 15 | } 16 | }) 17 | 18 | // storage event is fired only when other tabs change the storage 19 | const apiKeyElements = document.querySelectorAll('.api-key-input') 20 | for (const apiKeyElement of apiKeyElements) { 21 | apiKeyElement.addEventListener('input', () => { 22 | updateApiKeyStatus() 23 | }) 24 | } 25 | 26 | const savedAPIKey = localStorage.getItem('api-key') 27 | const introView = document.querySelector('#intro-view') 28 | if (!savedAPIKey) { 29 | introView.classList.remove('hidden') 30 | } 31 | 32 | document.querySelector('#intro-continue').addEventListener('click', () => { 33 | introView.classList.add('hidden') 34 | document.querySelector('#prompt').focus() 35 | }) 36 | } 37 | 38 | const clearApiKeyStatus = () => { 39 | const statusElements = document.querySelectorAll('.api-key-status') 40 | statusElements.forEach(x => x.classList.remove('error')) 41 | statusElements.forEach(x => x.classList.remove('success')) 42 | statusElements.forEach(x => x.innerText = '') 43 | } 44 | 45 | const updateApiKeyStatus = () => { 46 | const statusElements = document.querySelectorAll('.api-key-status') 47 | const continueElement = document.querySelector('#intro-continue') 48 | 49 | clearApiKeyStatus() 50 | continueElement.classList.add('secondary') 51 | 52 | const apiKey = localStorage.getItem('api-key') 53 | if (!apiKey) { 54 | return 55 | } 56 | 57 | statusElements.forEach(x => x.innerText = 'Checking...') 58 | 59 | const models = getModels(apiKey) 60 | models.then(response => { 61 | if (response.error) { 62 | statusElements.forEach(x => x.classList.add('error')) 63 | if (response.error.code === 'invalid_api_key') { 64 | statusElements.forEach(x => x.innerText = 'This API key doesn’t work.') 65 | } else { 66 | statusElements.forEach(x => x.innerText = 'There was an error when checking the API key.') 67 | } 68 | } else { 69 | statusElements.forEach(x => x.innerText = 'This API key is working!') 70 | statusElements.forEach(x => x.classList.add('success')) 71 | continueElement.classList.remove('secondary') 72 | } 73 | }, error => { 74 | statusElements.forEach(x => x.innerText = 'There was an error when checking the API key.') 75 | statusElements.forEach(x => x.classList.add('error')) 76 | }) 77 | } 78 | 79 | const setupSettingsHandlers = () => { 80 | const settingsView = document.querySelector('#settings-view') 81 | 82 | document.querySelector('#settings-button').addEventListener('click', () => { 83 | settingsView.classList.remove('hidden') 84 | clearApiKeyStatus() 85 | 86 | // textarea height is incorrectly calculated when it's hidden from the viewport 87 | // calculate it again 88 | // this logic should be generalized but works for now like this 89 | for (const textarea of document.querySelectorAll('textarea')) { 90 | updateTextareaSize(textarea) 91 | } 92 | }) 93 | 94 | document.querySelector('#settings-exit-button').addEventListener('click', () => { 95 | settingsView.classList.add('hidden') 96 | // document.querySelector('#prompt').focus() // annoying on mobile 97 | }) 98 | 99 | document.querySelector('#settings-show-intro').addEventListener('click', () => { 100 | settingsView.classList.add('hidden') 101 | document.querySelector('#intro-view').classList.remove('hidden') 102 | updateApiKeyStatus() 103 | }) 104 | } 105 | 106 | 107 | const getUserSelectedModel = () => { 108 | const modelSelect = document.querySelector('#model-select') 109 | return modelSelect.options[modelSelect.selectedIndex].value 110 | } 111 | 112 | 113 | const saveScreenshot = () => { 114 | const elementToSave = document.querySelector('#output') 115 | const backgroundColor = getComputedStyle(document.body).backgroundColor 116 | 117 | // Use html2canvas to render the element as a canvas 118 | html2canvas(elementToSave, { backgroundColor }).then(canvas => { 119 | // Convert the canvas to a downloadable data URL (image/png format) 120 | const dataURL = canvas.toDataURL('image/png') 121 | 122 | // Create a temporary anchor to download the image 123 | const tempAnchor = document.createElement('a') 124 | tempAnchor.href = dataURL 125 | tempAnchor.download = 'assistant.png' 126 | 127 | // Append the anchor to the document, simulate a click and remove the anchor 128 | document.body.appendChild(tempAnchor) 129 | tempAnchor.click() 130 | document.body.removeChild(tempAnchor) 131 | }) 132 | } 133 | 134 | 135 | const saveMarkdown = (messages) => { 136 | // Convert messages array to markdown string 137 | const markdownContent = messages.map((message) => { 138 | const capitalized = text => text[0].toUpperCase() + text.slice(1) 139 | return `## ${capitalized(message.role)}\n${message.content}` 140 | }).join('\n\n') 141 | 142 | // Create a downloadable data URL (text/markdown format) 143 | const dataURL = 'data:text/markdown;charset=utf-8,' + encodeURIComponent(markdownContent) 144 | 145 | // Create a temporary anchor to download the markdown file 146 | const tempAnchor = document.createElement('a') 147 | tempAnchor.href = dataURL 148 | tempAnchor.download = 'assistant.md' 149 | 150 | // Append the anchor to the document, simulate a click and remove the anchor 151 | document.body.appendChild(tempAnchor) 152 | tempAnchor.click() 153 | document.body.removeChild(tempAnchor) 154 | } 155 | 156 | 157 | let mouseDown = false 158 | let hasSelectedText = false 159 | 160 | document.addEventListener('mousedown', () => { 161 | mouseDown = true 162 | hasSelectedText = false 163 | }) 164 | 165 | document.addEventListener('mousemove', () => { 166 | if (mouseDown && window.getSelection().toString().length > 0) { 167 | hasSelectedText = true 168 | } 169 | }) 170 | 171 | document.addEventListener('mouseup', () => { 172 | mouseDown = false 173 | }) 174 | 175 | let timeoutInterval = null 176 | const showNotification = text => { 177 | let notification = document.querySelector('#notification') 178 | notification.className = 'notification show' 179 | notification.innerText = text 180 | if (timeoutInterval) { 181 | clearInterval(timeoutInterval) 182 | } 183 | timeoutInterval = setTimeout(() => { 184 | notification.className = "notification" 185 | }, 4000) 186 | } 187 | 188 | const injectCopyEventListeners = fragment => { 189 | for (const code of fragment.querySelectorAll('code, pre')) { 190 | code.addEventListener('click', event => { 191 | if (hasSelectedText) { 192 | return 193 | } 194 | event.stopPropagation() 195 | navigator.clipboard.writeText(code.innerText) 196 | .then(() => { 197 | showNotification('Copied!') 198 | }) 199 | .catch((error) => { 200 | showNotification('Error copying text to clipboard:', error) 201 | }) 202 | }) 203 | } 204 | } 205 | 206 | const addMessage = (message, type) => { 207 | const element = document.querySelector('#output') 208 | 209 | const messageContainer = document.createElement('div') 210 | messageContainer.classList.add(`${type}-container`) 211 | element.appendChild(messageContainer) 212 | 213 | const messageBubble = document.createElement('div') 214 | messageBubble.classList.add(`${type}-bubble`) 215 | messageBubble.classList.add('message-bubble') 216 | const fragment = markdownToDocumentFragment(message) 217 | injectCopyEventListeners(fragment) 218 | messageBubble.appendChild(fragment) 219 | messageContainer.appendChild(messageBubble) 220 | 221 | const copiedIndicator = document.createElement('span') 222 | copiedIndicator.classList.add('copied-indicator') 223 | messageContainer.appendChild(copiedIndicator) 224 | 225 | return messageContainer 226 | } 227 | 228 | window.addEventListener('click', () => { 229 | const oldCopiedIndicators = document.querySelectorAll('.copied-indicator') 230 | for (const indicator of oldCopiedIndicators) { 231 | indicator.innerText = '' 232 | } 233 | }) 234 | 235 | const addSentMessage = message => { 236 | return addMessage(message, 'my-message') 237 | } 238 | 239 | const addReceivedMessage = message => { 240 | return addMessage(message, 'response') 241 | } 242 | 243 | const addErrorMessage = (message, type) => { 244 | const element = document.querySelector('#output') 245 | 246 | const messageContainer = document.createElement('div') 247 | messageContainer.classList.add(`${type}-container`) 248 | messageContainer.classList.add('error') 249 | element.appendChild(messageContainer) 250 | 251 | messageContainer.innerText = message 252 | 253 | return messageContainer 254 | } 255 | 256 | const removeErrorMessages = () => { 257 | for (const errorMessage of document.querySelectorAll('.error')) { 258 | errorMessage.remove() 259 | } 260 | } 261 | 262 | window.addEventListener('load', () => { 263 | setupPersistentInputs() 264 | setupAPIKeyInput() 265 | setupSettingsHandlers() 266 | 267 | let messages = [ 268 | { 269 | 'role': 'system', 270 | 'content': localStorage.getItem('initial-system-message') 271 | } 272 | ] 273 | 274 | document.querySelector('#screenshot-button').addEventListener('click', () => { 275 | saveScreenshot() 276 | }) 277 | 278 | document.querySelector('#save-md-button').addEventListener('click', () => { 279 | saveMarkdown(messages) 280 | }) 281 | 282 | const sendMessage = (message) => { 283 | removeErrorMessages() 284 | addSentMessage(message) 285 | 286 | const typingIndicatorElement = addReceivedMessage('● ● ●') 287 | 288 | // scroll down always after sending message, even if wasn't before 289 | document.body.scrollIntoView({ block: 'end', behavior: 'smooth' }) 290 | 291 | messages.push({ 292 | 'role': 'user', 293 | 'content': message 294 | }) 295 | 296 | const apiKey = localStorage.getItem('api-key') 297 | const model = getUserSelectedModel() 298 | 299 | let newMessage = {} 300 | let newMessageBubble = null 301 | 302 | // +1 because always include the new message 303 | const maxMessages = parseInt(localStorage.getItem('maximum-messages')) + 1 304 | const systemMessage = messages[0] 305 | // .slice(1) so the systemMessage doesn't appear twice 306 | const truncatedMessages = [systemMessage, ...messages.slice(1).slice(-maxMessages)] 307 | 308 | chatCompletionStream(apiKey, { 309 | messages: truncatedMessages, 310 | model 311 | }, 312 | (response) => { 313 | typingIndicatorElement.remove() 314 | const wasScrolledToBottom = isScrolledToBottom() 315 | 316 | if ('error' in response) { 317 | addErrorMessage(response.error.message, 'assistant') 318 | 319 | } else if ('choices' in response) { 320 | const delta = response.choices[0].delta 321 | if ('role' in delta) { 322 | newMessage = { 323 | 'role': delta.role, 324 | 'content': '' 325 | } 326 | messages.push(newMessage) 327 | newMessageBubble = addReceivedMessage(newMessage.content) 328 | } 329 | if ('content' in delta) { 330 | newMessage.content += delta.content 331 | newMessageBubble.firstChild.innerHTML = '' 332 | 333 | const fragment = markdownToDocumentFragment(newMessage.content + '\n') 334 | injectCopyEventListeners(fragment) 335 | newMessageBubble.firstChild.appendChild(fragment) 336 | } 337 | } 338 | 339 | if (wasScrolledToBottom) { 340 | // Instant, non-smooth scroll 341 | window.scrollTo(0, document.body.scrollHeight) 342 | } 343 | }) 344 | } 345 | 346 | const hashParams = parseHashParams() 347 | if ('q' in hashParams && hashParams.q) { 348 | sendMessage(hashParams.q) 349 | } 350 | 351 | const submitMessageForm = () => { 352 | const input = document.querySelector('#prompt').value 353 | document.querySelector('#prompt').value = '' 354 | updateTextareaSize(textbox) 355 | sendMessage(input) 356 | } 357 | 358 | 359 | const textbox = document.querySelector('#prompt') 360 | 361 | textbox.addEventListener('keydown', (event) => { 362 | // We need to use the deprecated event.keyCode here, because Chrome doesn't handle 363 | // Chinese pinyin keyboard correctly 364 | if (event.keyCode === 13 && !event.ctrlKey && !event.altKey && !event.shiftKey) { 365 | event.preventDefault() 366 | submitMessageForm() 367 | } 368 | }) 369 | 370 | document.querySelector('form').addEventListener('submit', (event) => { 371 | event.preventDefault() 372 | submitMessageForm() 373 | }) 374 | 375 | for (const textarea of document.querySelectorAll('textarea')) { 376 | textarea.addEventListener('input', () => { 377 | updateTextareaSize(textarea) 378 | }) 379 | updateTextareaSize(textarea) 380 | } 381 | 382 | document.addEventListener('keydown', event => { 383 | if (event.ctrlKey && event.key.toLowerCase() === 'm') { 384 | rotateSelectValue('model-select') 385 | } 386 | }) 387 | 388 | const rotateSelectValue = selectId => { 389 | const select = document.querySelector(`#${selectId}`) 390 | 391 | if (select.selectedIndex < select.options.length - 1) { 392 | select.selectedIndex++ 393 | } else { 394 | select.selectedIndex = 0 395 | } 396 | 397 | // Dispatch a synthetic change event 398 | const event = new Event('change', { bubbles: true }) 399 | select.dispatchEvent(event) 400 | } 401 | 402 | // Check if there is a "last-selected-model" key in localStorage 403 | // If there is, select that model 404 | const modelSelect = document.querySelector('#model-select') 405 | const lastSelectedModel = localStorage.getItem('last-selected-model') 406 | if (lastSelectedModel) { 407 | for (const option of modelSelect.options) { 408 | if (option.value === lastSelectedModel) { 409 | option.selected = true 410 | break 411 | } 412 | } 413 | } 414 | 415 | // Listen to changes in the model select 416 | // When the model is changed, save the new model in localStorage 417 | modelSelect.addEventListener('change', () => { 418 | const selectedModel = getUserSelectedModel() 419 | localStorage.setItem('last-selected-model', selectedModel) 420 | }) 421 | 422 | }) -------------------------------------------------------------------------------- /src/js/markdown.js: -------------------------------------------------------------------------------- 1 | import { sanitize } from 'dompurify' 2 | import { parse } from 'marked' 3 | 4 | // renderMarkdown('& < & `& < &`') 5 | // returns: '

& < &amp; & < &amp;

' 6 | // but as an element 7 | 8 | export const markdownToDocumentFragment = md => { 9 | // escape &<> 10 | md = md.replaceAll('&', '&') 11 | md = md.replaceAll('<', '<') 12 | md = md.replaceAll('>', '>') 13 | 14 | // markdown -> sanitized html -> element 15 | const html = sanitize(parse(md, { 16 | gfm: true, 17 | breaks: true, 18 | })) 19 | const documentFragment = htmlToDocumentFragment(html) 20 | 21 | // marked also escapes &<>, but only in code blocks 22 | // now they are double-escaped 23 | // unwrap one layer from elements 24 | // & - & - &amp; - & 25 | // < - < - &lt; - < 26 | // > - > - &gt; - > 27 | // 28 | // if marked.parse didn't escape &<> for some reason, 29 | // like upgrading marked version later and forgetting about this function, 30 | // it's not a security problem because we are not unescaping < and > in 31 | // the innerHTML level 32 | for (const code of documentFragment.querySelectorAll('code')) { 33 | code.innerHTML = code.innerHTML.replaceAll('&', '&') 34 | } 35 | 36 | // since we are in the browser, it's more efficient to keep it as an element 37 | // instead of converting element -> html -> element later 38 | return documentFragment 39 | } 40 | 41 | const htmlToDocumentFragment = html => { 42 | var template = document.createElement('template') 43 | template.innerHTML = html 44 | return template.content 45 | } -------------------------------------------------------------------------------- /src/js/utils.js: -------------------------------------------------------------------------------- 1 | export const parseHashParams = () => { 2 | const hashParams = {} 3 | const hash = window.location.hash.substring(1) // remove the # 4 | const params = hash.split('&') 5 | params.forEach(param => { 6 | const [key, value] = param.split('=') 7 | hashParams[decodeURIComponent(key)] = decodeURIComponent(value) 8 | }) 9 | return hashParams 10 | } 11 | 12 | export const isScrolledToBottom = () => { 13 | const { scrollTop, clientHeight, scrollHeight } = document.documentElement 14 | return scrollTop + clientHeight >= scrollHeight 15 | } 16 | 17 | export const updateTextareaSize = (element) => { 18 | element.style.height = 0 19 | 20 | const style = window.getComputedStyle(element) 21 | const paddingTop = parseFloat(style.getPropertyValue('padding-top')) 22 | const paddingBottom = parseFloat(style.getPropertyValue('padding-bottom')) 23 | 24 | const height = element.scrollHeight - paddingTop - paddingBottom 25 | 26 | element.style.height = `${height}px` 27 | } 28 | 29 | export const setupPersistentInputs = () => { 30 | const persistentInputs = document.querySelectorAll('[data-persistent-name]') 31 | const getName = element => element.getAttribute('data-persistent-name') 32 | 33 | // Load default values from HTML if no saved data 34 | for (const persistentInput of persistentInputs) { 35 | const name = getName(persistentInput) 36 | const savedValue = localStorage.getItem(name) 37 | if (!savedValue) { 38 | localStorage.setItem(name, persistentInput.value) 39 | } 40 | } 41 | 42 | // Update fields with saved data 43 | for (const persistentInput of persistentInputs) { 44 | const name = getName(persistentInput) 45 | persistentInput.value = localStorage.getItem(name) 46 | 47 | persistentInput.addEventListener('input', () => { 48 | const value = persistentInput.value 49 | localStorage.setItem(name, value) 50 | console.log('saving:', name, value) 51 | 52 | for (const otherInput of persistentInputs) { 53 | if (getName(otherInput) === name) { 54 | otherInput.value = value 55 | } 56 | } 57 | }) 58 | 59 | // from other tabs 60 | window.addEventListener('storage', event => { 61 | if (event.key === name) { 62 | persistentInput.value = event.newValue 63 | } 64 | }) 65 | } 66 | } -------------------------------------------------------------------------------- /src/static/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/felixbade/assistant/39619265ab1197d71a03df3e7473a8dcc56edac0/src/static/icon.png -------------------------------------------------------------------------------- /src/static/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Assistant", 3 | "start_url": "/", 4 | "display": "standalone", 5 | "icons": [ 6 | { 7 | "src": "icon.png", 8 | "sizes": "512x512", 9 | "type": "image/png" 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /src/style/main.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --bg: white; 3 | --compose-box-bg: hsl(0, 0%, 100%, 0.7); 4 | --fg: black; 5 | --my-message-bg: hsl(215, 90%, 50%); 6 | --my-message-fg: white; 7 | --response-bg: hsl(0, 0%, 91%); 8 | --link: hsl(215, 100%, 45%); 9 | --input-border: hsla(0, 0%, 0%, 20%); 10 | --settings-icon-border: hsla(0, 0%, 0%, 40%); 11 | --info: hsl(120, 50%, 80%); 12 | --info-link: hsl(210, 100%, 30%); 13 | --error-fg: hsl(5, 70%, 50%); 14 | --success-fg: hsl(140, 70%, 30%); 15 | --code-bg: hsla(0, 0%, 100%, 0.7); 16 | --code-border: hsla(0, 0%, 0%, 0.15); 17 | --my-message-code-bg: hsla(0, 0%, 0%, 0.2); 18 | --my-message-code-border: hsla(0, 0%, 0%, 0.15); 19 | 20 | --chat-max-width: 50em; 21 | --block-border-radius: 0.5em; 22 | } 23 | 24 | html { 25 | -webkit-text-size-adjust: 100%; /* Prevent font scaling in landscape while allowing user zoom */ 26 | } 27 | 28 | body { 29 | margin: 0; 30 | font-family: sans-serif; 31 | line-height: 140%; 32 | background-color: var(--bg); 33 | color: var(--fg); 34 | display: flex; 35 | justify-content: center; 36 | } 37 | 38 | .container { 39 | width: var(--chat-max-width); 40 | padding: 0.7em 1em; 41 | margin-left: env(safe-area-inset-left); 42 | margin-right: env(safe-area-inset-right); 43 | margin-bottom: calc(4em + env(safe-area-inset-bottom)); 44 | max-width: calc(100% - 2em); /* Don't overflow the window with over-sized texts. Subtract paddings */ 45 | } 46 | 47 | #settings-button, #settings-exit-button { 48 | float: right; 49 | margin-right: -0.1em; 50 | height: 25px; 51 | } 52 | 53 | #settings-button:hover, #settings-exit-button:hover { 54 | opacity: 80%; 55 | cursor: pointer; 56 | } 57 | 58 | /* From https://gist.github.com/MoOx/9137295 */ 59 | .reset-button { 60 | border: none; 61 | margin: 0; 62 | padding: 0; 63 | width: auto; 64 | overflow: visible; 65 | 66 | background: transparent; 67 | 68 | /* inherit font & color from ancestor */ 69 | color: inherit; 70 | font: inherit; 71 | 72 | /* Normalize `line-height`. Cannot be changed from `normal` in Firefox 4+. */ 73 | line-height: normal; 74 | 75 | /* Corrects font smoothing for webkit */ 76 | -webkit-font-smoothing: inherit; 77 | -moz-osx-font-smoothing: inherit; 78 | 79 | /* Corrects inability to style clickable `input` types in iOS */ 80 | -webkit-appearance: none; 81 | } 82 | 83 | 84 | .full-window-overlay { 85 | position: absolute; 86 | box-sizing: border-box; 87 | min-height: 100vh; 88 | width: 100%; 89 | padding-left: calc(max((100vw - var(--chat-max-width))/2, 1em + env(safe-area-inset-left))); 90 | padding-right: calc(max((100vw - var(--chat-max-width))/2, 1em + env(safe-area-inset-right))); 91 | padding-bottom: calc(0.8em + env(safe-area-inset-bottom)); 92 | padding-top: 0.7em; 93 | z-index: 2; 94 | background-color: var(--compose-box-bg); 95 | backdrop-filter: blur(20px); 96 | -webkit-backdrop-filter: blur(20px); /* Needed for Safari in 2023 */ 97 | } 98 | 99 | #settings-view h1 { 100 | margin-top: 0.3em; 101 | } 102 | 103 | #settings-view h2 { 104 | font-weight: 400; 105 | font-size: 1.3em; 106 | margin: 1em 0 0.6em 0; 107 | } 108 | 109 | #settings-view input, #settings-view .textarea-border-radius { 110 | margin: 0.2em 0; 111 | } 112 | 113 | #system-message { 114 | max-height: 20vh; 115 | } 116 | 117 | .assistant-icon-container { 118 | text-align: center; 119 | margin-top: 2em; 120 | margin-bottom: 2em; 121 | } 122 | 123 | .assistant-icon { 124 | width: 7em; 125 | border-radius: 1.5em; 126 | } 127 | 128 | .start-view { 129 | background-color: var(--info); 130 | padding: 1em; 131 | border-radius: 1em; 132 | display: inline-block; 133 | margin: 1em 0; 134 | font-size: 1.1em; 135 | } 136 | 137 | .start-view a, .start-view a:visited { 138 | color: var(--info-link); 139 | text-decoration: underline; 140 | } 141 | 142 | #compose-box { 143 | background-color: var(--compose-box-bg); 144 | backdrop-filter: blur(20px); 145 | -webkit-backdrop-filter: blur(20px); /* Needed for Safari in 2023 */ 146 | position: fixed; 147 | bottom: 0; 148 | 149 | width: 100%; 150 | box-sizing: border-box; 151 | padding: 0.9em; 152 | padding-left: calc(max(0.9em + env(safe-area-inset-left), (100% - var(--chat-max-width))/2)); 153 | padding-right: calc(max(0.9em + env(safe-area-inset-right), (100% - var(--chat-max-width))/2)); 154 | padding-bottom: calc(0.8em + env(safe-area-inset-bottom)); 155 | 156 | display: flex; 157 | justify-content: space-between; 158 | } 159 | 160 | #prompt { 161 | font-size: 1.1em; 162 | margin: 0; 163 | max-height: 70vh; 164 | } 165 | 166 | #submit { 167 | margin: 0; 168 | } 169 | 170 | #prompt-container { 171 | flex: 1; 172 | margin-right: 0.5em; 173 | } 174 | 175 | input { 176 | font-size: 16px; 177 | } 178 | 179 | .response-container { 180 | display: flex; 181 | align-items: start; 182 | flex-direction: column; 183 | } 184 | 185 | .my-message-container { 186 | display: flex; 187 | align-items: end; 188 | flex-direction: column; 189 | } 190 | 191 | .message-bubble { 192 | margin-top: 0.6em; 193 | display: inline-block; 194 | padding-top: 0.1em; 195 | padding-bottom: 0.15em; 196 | padding-left: 0.8em; 197 | padding-right: 0.8em; 198 | border-radius: 1.0em; 199 | max-width: 100%; 200 | box-sizing: border-box; 201 | word-wrap: break-word; 202 | } 203 | 204 | .response-bubble { 205 | background-color: var(--response-bg); 206 | color: var(--fg); 207 | } 208 | 209 | .my-message-bubble { 210 | background-color: var(--my-message-bg); 211 | color: var(--my-message-fg); 212 | } 213 | 214 | .my-message-bubble a { 215 | color: var(--my-message-fg); 216 | text-decoration: underline; 217 | } 218 | 219 | .my-message-bubble a:hover { 220 | text-decoration-thickness: 2px; 221 | } 222 | 223 | .error { 224 | color: var(--error-fg); 225 | } 226 | 227 | .success { 228 | color: var(--success-fg); 229 | } 230 | 231 | .copied-indicator { 232 | font-size: 0.8em; 233 | } 234 | 235 | hr { 236 | border: none; 237 | border-top: 1px solid var(--input-border); 238 | background: none; 239 | margin: 1.4em 0; 240 | } 241 | 242 | p { 243 | margin: 0.5em 0; 244 | } 245 | 246 | br { 247 | line-height: inherit; 248 | } 249 | 250 | code { 251 | white-space: pre-wrap; 252 | border: 1px solid var(--code-border); 253 | border-radius: 0.4em; 254 | padding: 0.1em 0.3em; 255 | background-color: var(--code-bg); 256 | } 257 | 258 | pre > code { 259 | border: none; 260 | padding: 0; 261 | color: var(--fg); 262 | background: none; 263 | } 264 | 265 | pre { 266 | border: 1px solid var(--code-border); 267 | border-radius: var(--block-border-radius); 268 | padding: 0.5em 0.9em; 269 | background-color: var(--code-bg); 270 | } 271 | 272 | pre, code { 273 | cursor: pointer; 274 | } 275 | 276 | .my-message-bubble code { 277 | color: var(--my-message-fg); 278 | border: 1px solid var(--my-message-code-border); 279 | background-color: var(--my-message-code-bg); 280 | } 281 | 282 | .my-message-bubble pre > code { 283 | border: none; 284 | background: none; 285 | } 286 | 287 | .my-message-bubble pre { 288 | border: 1px solid var(--my-message-code-border); 289 | background-color: var(--my-message-code-bg); 290 | } 291 | 292 | 293 | table { 294 | border-collapse: separate; 295 | border-spacing: 0; 296 | margin: 1em 0; 297 | } 298 | 299 | th, td { 300 | border: 1px solid var(--code-border); 301 | padding: 0.4em 0.7em; 302 | background-color: var(--code-bg); 303 | } 304 | 305 | /* Border drawing logic */ 306 | th { border-style: solid solid solid none; } 307 | th:first-child { border-left-style: solid; } 308 | td { border-style: none solid solid none; } 309 | td:first-child { border-left-style: solid; } 310 | 311 | /* Rounded corners */ 312 | tr:first-child th:first-child { border-top-left-radius: var(--block-border-radius); } 313 | tr:first-child th:last-child { border-top-right-radius: var(--block-border-radius); } 314 | tr:last-child td:first-child { border-bottom-left-radius: var(--block-border-radius); } 315 | tr:last-child td:last-child { border-bottom-right-radius: var(--block-border-radius); } 316 | 317 | /* Colors in my messages */ 318 | .my-message-bubble th, .my-message-bubble td { 319 | border-color: var(--my-message-code-border); 320 | background-color: var(--my-message-code-bg); 321 | } 322 | 323 | 324 | .message-bubble img { 325 | width: 100%; 326 | } 327 | 328 | a, a:visited { 329 | text-decoration: none; 330 | color: var(--link); 331 | } 332 | 333 | a:hover { 334 | text-decoration: underline; 335 | } 336 | 337 | .disclaimer { 338 | font-size: 0.75em; 339 | line-height: 130%; 340 | margin: 0.35em 0; 341 | } 342 | 343 | footer { 344 | margin-top: 10px; 345 | font-size: 0.7em; 346 | opacity: 70%; 347 | } 348 | 349 | input[type=text], input[type=number], textarea { 350 | font-family: -apple-system, sans-serif; 351 | line-height: 120%; 352 | background-color: var(--bg); 353 | color: var(--fg); 354 | border: 1px solid var(--input-border); 355 | padding: 0.2em 0.5em; 356 | border-radius: 1em; 357 | } 358 | 359 | .textarea-border-radius { 360 | border-radius: 1em; 361 | overflow: hidden; 362 | display: flex; 363 | border: 1px solid var(--input-border); 364 | } 365 | 366 | .textarea-border-radius > textarea { 367 | margin: -1px !important; 368 | border: none; 369 | flex: 1; 370 | } 371 | 372 | input[type=number] { 373 | width: 5em; 374 | } 375 | 376 | input[type=text]:focus, input[type=number]:focus, textarea:focus { 377 | outline: none; 378 | } 379 | 380 | textarea { 381 | resize: none; 382 | height: auto; 383 | padding: 0.3em 0.7em; 384 | font-size: 1em; 385 | } 386 | 387 | button { 388 | background-color: var(--my-message-bg); 389 | color: var(--my-message-fg); 390 | border: none; 391 | padding: 0.4em 0.8em; 392 | border-radius: 1em; 393 | font-size: 1em; 394 | margin: 0.2em 0; 395 | font-weight: 500; 396 | } 397 | 398 | button.secondary { 399 | background-color: var(--response-bg); 400 | color: var(--fg); 401 | } 402 | 403 | select { 404 | font-family: -apple-system, sans-serif; 405 | line-height: 120%; 406 | background-color: var(--bg); 407 | color: var(--fg); 408 | border: 1px solid var(--input-border); 409 | padding: 0.2em 0.5em; 410 | border-radius: 1em; 411 | appearance: none; 412 | -webkit-appearance: none; 413 | -moz-appearance: none; 414 | background-image: url("data:image/svg+xml;utf8,"); 415 | background-repeat: no-repeat; 416 | background-position: right 0.5em center; 417 | background-size: 14px; 418 | padding-right: 2em; 419 | font-size: 1em; 420 | } 421 | 422 | select:focus { 423 | outline: none; 424 | } 425 | 426 | .hidden { 427 | display: none !important; 428 | } 429 | 430 | 431 | .notification { 432 | visibility: hidden; 433 | min-width: 100px; 434 | margin-left: -50px; 435 | box-sizing: border-box; 436 | background-color: var(--fg); 437 | color: var(--bg); 438 | text-align: center; 439 | border-radius: 1em; 440 | padding: 0.8em; 441 | position: fixed; 442 | z-index: 1; 443 | left: 50%; 444 | top: 1.5em; 445 | font-size: 1em; 446 | } 447 | 448 | .show { 449 | visibility: visible !important; 450 | animation: fadein 0.35s ease, fadeout 0.5s ease 3.65s; 451 | } 452 | 453 | @keyframes fadein { 454 | from {top: 0; opacity: 0;} 455 | to {top: 1.5em; opacity: 1;} 456 | } 457 | 458 | @keyframes fadeout { 459 | from {top: 1.5em; opacity: 1;} 460 | to {top: 0; opacity: 0;} 461 | } 462 | 463 | 464 | @media (prefers-color-scheme: dark) { 465 | :root { 466 | --bg: hsl(215, 20%, 9%); 467 | --compose-box-bg: hsl(215, 20%, 9%, 0.7); 468 | --fg: hsl(0, 0%, 95%); 469 | --my-message-bg: hsl(215, 75%, 48%); 470 | --my-message-fg: white; 471 | --response-bg: hsl(215, 15%, 20%); 472 | --link: hsl(215, 100%, 65%); 473 | --input-border: hsla(0, 0%, 90%, 40%); 474 | --settings-icon-border: hsla(0, 0%, 100%, 50%); 475 | --info: hsl(130, 30%, 25%); 476 | --info-link: hsl(220, 100%, 85%); 477 | --error-fg: hsl(5, 70%, 60%); 478 | --success-fg: hsl(140, 70%, 45%); 479 | --code-bg: hsla(0, 0%, 0%, 0.25); 480 | --code-border: hsla(0, 0%, 0%, 0.15); 481 | --my-message-code-bg: hsla(0, 0%, 0%, 0.25); 482 | --my-message-code-border: hsla(0, 0%, 0%, 0.15); 483 | } 484 | 485 | * { 486 | color-scheme: dark; 487 | } 488 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 4 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 5 | const { CleanWebpackPlugin } = require('clean-webpack-plugin'); 6 | const { GenerateSW } = require('workbox-webpack-plugin'); 7 | 8 | module.exports = (env) => { 9 | return { 10 | entry: './src/index.js', 11 | output: { 12 | filename: 'main-[contenthash].js', 13 | path: path.resolve(__dirname, 'dist'), 14 | }, 15 | plugins: [ 16 | new CleanWebpackPlugin(), 17 | new HtmlWebpackPlugin({ 18 | template: './src/index.html', 19 | filename: 'index.html', 20 | minify: { 21 | collapseWhitespace: true, 22 | removeComments: true, 23 | removeRedundantAttributes: false, // do not remove type="text" 24 | }, 25 | version: env.version, 26 | }), 27 | new MiniCssExtractPlugin({ 28 | filename: 'style-[contenthash].css', 29 | }), 30 | new CopyWebpackPlugin({ 31 | patterns: [ 32 | { from: 'src/static', to: '' }, 33 | ], 34 | }), 35 | new GenerateSW({ 36 | clientsClaim: true, 37 | skipWaiting: true, 38 | runtimeCaching: [{ 39 | // match everything 40 | urlPattern: /$/, 41 | 42 | // return a cached response immediately 43 | // but always update the content in the background 44 | handler: 'StaleWhileRevalidate', 45 | }], 46 | }), 47 | ], 48 | module: { 49 | rules: [ 50 | { 51 | test: /\.css$/, 52 | use: [MiniCssExtractPlugin.loader, 'css-loader'], 53 | }, 54 | ], 55 | }, 56 | } 57 | }; --------------------------------------------------------------------------------