├── .gitignore ├── Dockerfile ├── README.md ├── autoload.php ├── database.php ├── db ├── install_sqlite.php ├── migrations │ ├── mysql │ │ ├── 0001-add-model-and-mode.sql │ │ ├── 0002-add-function-name-and-function-arguments.sql │ │ ├── 0003-update-role-length.sql │ │ └── 0004-id-to-uuid.sql │ └── sqlite │ │ ├── 0001-add-model-and-mode.php │ │ └── 0002-add-function-name-and-function-arguments.php ├── mysql.sql └── sqlite.sql ├── public ├── assets │ ├── css │ │ └── style.css │ └── js │ │ ├── script.js │ │ └── ui-script.js ├── create_title.php ├── data.php ├── delete_chat.php ├── index.php ├── message.php └── text_to_audio.php ├── requirements.txt ├── sandbox ├── Dockerfile ├── requirements.txt └── run_code.sh ├── settings.sample.php ├── speech └── generate.py └── src ├── Assistant.php ├── ChatGPT.php ├── CodeInterpreter.php ├── ConversationInterface.php ├── Message.php ├── PythonResult.php ├── Run.php ├── SQLConversation.php ├── SessionConversation.php ├── Thread.php └── Uuid.php /.gitignore: -------------------------------------------------------------------------------- 1 | api_key.txt 2 | vendor/ 3 | composer.phar 4 | settings.php 5 | chatwtf.db 6 | speech/output/*.wav 7 | speech/input/*.txt 8 | data/ 9 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM php:8.2-apache 2 | 3 | RUN mkdir -p /var/www/chatwtf 4 | 5 | WORKDIR /var/www/chatwtf 6 | 7 | ENV APACHE_DOCUMENT_ROOT /var/www/chatwtf/public 8 | 9 | RUN sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/sites-available/000-default.conf 10 | RUN sed -ri -e 's!/var/www/!${APACHE_DOCUMENT_ROOT}/!g' /etc/apache2/apache2.conf /etc/apache2/conf-available/*.conf 11 | 12 | RUN apt-get update && apt-get install -y \ 13 | python3 \ 14 | python3-pip 15 | 16 | RUN apt-get install -y libsqlite3-dev 17 | RUN docker-php-ext-install pdo pdo_sqlite 18 | 19 | COPY requirements.txt . 20 | RUN python3 -m pip install --break-system-packages -r requirements.txt 21 | 22 | COPY . /var/www/chatwtf 23 | 24 | RUN chown -R www-data:www-data /var/www/chatwtf 25 | 26 | EXPOSE 80 27 | 28 | CMD ["apache2-foreground"] 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ChatWTF 2 | 3 | This is a ChatGPT-like chatbot that uses the ChatGPT API. It was created for my YouTube channel. You can find the playlist of videos [here](https://www.youtube.com/watch?v=ru5m-BKDn6E&list=PLz8w2NTEwxvqH7yCAp6PAL0dKeiVU7uv4). 4 | 5 | ## Quick Start 6 | 7 | 1. Clone the repository 8 | 2. Add your OpenAI API key to `settings.php` (see `settings.sample.php`) 9 | 3. Start a server in `public/` 10 | 11 | ```console 12 | $ php -S localhost:8080 -t public 13 | ``` 14 | 15 | 4. Go to http://localhost:8080 16 | 17 | ## Docker 18 | 19 | ```console 20 | $ sudo docker build -t chatwtf . 21 | $ sudo docker run -p 8080:80 chatwtf 22 | ``` 23 | 24 | Note: If you get `caught SIGWINCH, shutting down gracefully`, add the `-d` flag to run it in the background. 25 | 26 | ## Database 27 | 28 | The chatbot uses PHP sessions to store the conversations by default. You can also use an SQL database. There is a SQLite dump and a MySQL dump in the `db` folder. You can install the SQLite version by running the `install_sqlite.php` script. 29 | 30 | Database config has to be put into `settings.php` (see `settings.sample.php`). You need to also change `storage_type` to `sql` in the settings in order to use a database. 31 | 32 | ## API key 33 | 34 | You will need an API key from OpenAI to use the code. The API key must be added to the `settings.sample.php` file, which you will need to rename to `settings.php`. 35 | 36 | ## CodeInterpreter 37 | 38 | By default (when enabled from `settings.php`), CodeInterpreter mode **runs Python code directly on your machine** but it asks for confirmation first. To enable a sandbox environment with Docker, change `code_interpreter` > `sandbox` > `enabled` to `true` in `settings.php` and set `code_interpreter` > `sandbox` > `container` to the name of the Python sandbox Docker container. 39 | 40 | You can create such a sandbox container by running: 41 | 42 | ```shell 43 | $ sudo docker build -t chatwtf-sandbox ./sandbox 44 | ``` 45 | 46 | Note that the sandbox doesn't work (and might not be needed) if you're already running the project inside a Docker container. 47 | 48 | ## Modify to your liking 49 | 50 | You can change the system message in the settings to make the chatbot do what you want. 51 | 52 | ## Support 53 | 54 | If you like this code or use it in some useful way, consider buying me a coffee: https://www.buymeacoffee.com/unconv 55 | -------------------------------------------------------------------------------- /autoload.php: -------------------------------------------------------------------------------- 1 | setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); 16 | 17 | return $db; 18 | } 19 | 20 | function get_conversation_class( $db ): ConversationInterface { 21 | $settings = require( __DIR__ . "/settings.php" ); 22 | 23 | $conversation_class = [ 24 | "session" => SessionConversation::class, 25 | "sql" => SQLConversation::class, 26 | ]; 27 | 28 | return new $conversation_class[$settings['storage_type']]( $db ); 29 | } 30 | -------------------------------------------------------------------------------- /db/install_sqlite.php: -------------------------------------------------------------------------------- 1 | exec( file_get_contents( __DIR__ . "/sqlite.sql" ) ); 11 | -------------------------------------------------------------------------------- /db/migrations/mysql/0001-add-model-and-mode.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `conversations` 2 | ADD COLUMN `model` varchar(64) COLLATE utf8mb4_swedish_ci AFTER `title`, 3 | ADD COLUMN `mode` varchar(16) COLLATE utf8mb4_swedish_ci AFTER `model`; 4 | -------------------------------------------------------------------------------- /db/migrations/mysql/0002-add-function-name-and-function-arguments.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `messages` 2 | ADD COLUMN `function_name` varchar(64) NULL COLLATE utf8mb4_swedish_ci AFTER `content`, 3 | ADD COLUMN `function_arguments` TEXT NULL COLLATE utf8mb4_swedish_ci AFTER `function_name`; 4 | -------------------------------------------------------------------------------- /db/migrations/mysql/0003-update-role-length.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `messages` CHANGE `role` `role` VARCHAR(13) CHARACTER SET utf8mb4 COLLATE utf8mb4_swedish_ci NOT NULL; 2 | -------------------------------------------------------------------------------- /db/migrations/mysql/0004-id-to-uuid.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `conversations` CHANGE `id` `id` VARCHAR(36) NOT NULL; 2 | ALTER TABLE `conversations` ADD `created_time` DATETIME NULL DEFAULT NULL AFTER `mode`; 3 | ALTER TABLE `messages` CHANGE `conversation` `conversation` VARCHAR(36) NOT NULL; 4 | -------------------------------------------------------------------------------- /db/migrations/sqlite/0001-add-model-and-mode.php: -------------------------------------------------------------------------------- 1 | exec( ' 7 | ALTER TABLE "conversations" ADD COLUMN "model" VARCHAR(64) NOT NULL DEFAULT ""; 8 | ALTER TABLE "conversations" ADD COLUMN "mode" VARCHAR(16) NOT NULL DEFAULT ""; 9 | ' ); 10 | -------------------------------------------------------------------------------- /db/migrations/sqlite/0002-add-function-name-and-function-arguments.php: -------------------------------------------------------------------------------- 1 | exec( ' 7 | ALTER TABLE "messages" ADD COLUMN "function_name" VARCHAR(64) NULL DEFAULT NULL; 8 | ALTER TABLE "messages" ADD COLUMN "function_arguments" TEXT NULL DEFAULT NULL; 9 | ' ); 10 | -------------------------------------------------------------------------------- /db/mysql.sql: -------------------------------------------------------------------------------- 1 | -- phpMyAdmin SQL Dump 2 | -- version 5.1.1deb5ubuntu1 3 | -- https://www.phpmyadmin.net/ 4 | -- 5 | -- Host: localhost:3306 6 | -- Generation Time: Jul 28, 2023 at 01:40 PM 7 | -- Server version: 8.0.33-0ubuntu0.22.04.2 8 | -- PHP Version: 8.1.2-1ubuntu2.11 9 | 10 | SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO"; 11 | START TRANSACTION; 12 | SET time_zone = "+00:00"; 13 | 14 | 15 | /*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; 16 | /*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; 17 | /*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; 18 | /*!40101 SET NAMES utf8mb4 */; 19 | 20 | -- 21 | -- Database: `chatwtf` 22 | -- 23 | 24 | -- -------------------------------------------------------- 25 | 26 | -- 27 | -- Table structure for table `conversations` 28 | -- 29 | 30 | CREATE TABLE `conversations` ( 31 | `id` VARCHAR(36) NOT NULL, 32 | `title` varchar(64) COLLATE utf8mb4_swedish_ci NOT NULL, 33 | `model` varchar(64) COLLATE utf8mb4_swedish_ci NOT NULL, 34 | `mode` varchar(16) COLLATE utf8mb4_swedish_ci NOT NULL, 35 | `created_time` DATETIME NULL 36 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_swedish_ci; 37 | 38 | -- -------------------------------------------------------- 39 | 40 | -- 41 | -- Table structure for table `messages` 42 | -- 43 | 44 | CREATE TABLE `messages` ( 45 | `id` int NOT NULL, 46 | `role` varchar(13) COLLATE utf8mb4_swedish_ci NOT NULL, 47 | `content` text COLLATE utf8mb4_swedish_ci NOT NULL, 48 | `function_name` varchar(64) COLLATE utf8mb4_swedish_ci NULL, 49 | `function_arguments` text COLLATE utf8mb4_swedish_ci NULL, 50 | `timestamp` datetime NOT NULL, 51 | `conversation` VARCHAR(36) NOT NULL 52 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_swedish_ci; 53 | 54 | -- 55 | -- Indexes for dumped tables 56 | -- 57 | 58 | -- 59 | -- Indexes for table `conversations` 60 | -- 61 | ALTER TABLE `conversations` 62 | ADD PRIMARY KEY (`id`); 63 | 64 | -- 65 | -- Indexes for table `messages` 66 | -- 67 | ALTER TABLE `messages` 68 | ADD PRIMARY KEY (`id`), 69 | ADD KEY `conversation` (`conversation`); 70 | 71 | -- 72 | -- AUTO_INCREMENT for dumped tables 73 | -- 74 | 75 | -- 76 | -- AUTO_INCREMENT for table `messages` 77 | -- 78 | ALTER TABLE `messages` 79 | MODIFY `id` int NOT NULL AUTO_INCREMENT; 80 | COMMIT; 81 | 82 | /*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; 83 | /*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; 84 | /*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; -------------------------------------------------------------------------------- /db/sqlite.sql: -------------------------------------------------------------------------------- 1 | PRAGMA foreign_keys=OFF; 2 | BEGIN TRANSACTION; 3 | CREATE TABLE IF NOT EXISTS "conversations" ( 4 | "id" VARCHAR(36) NOT NULL , 5 | "title" VARCHAR(64) NOT NULL , 6 | "model" VARCHAR(64) NOT NULL , 7 | "mode" VARCHAR(16) NOT NULL , 8 | "created_time" DATETIME NULL , 9 | PRIMARY KEY ("id") 10 | ); 11 | CREATE TABLE IF NOT EXISTS "messages" ( 12 | "id" INTEGER NOT NULL , 13 | "role" VARCHAR(13) NOT NULL , 14 | "content" TEXT NOT NULL , 15 | "function_name" VARCHAR(64) NULL DEFAULT NULL , 16 | "function_arguments" TEXT NULL DEFAULT NULL , 17 | "timestamp" DATETIME NOT NULL , 18 | "conversation" VARCHAR(36) NOT NULL , 19 | PRIMARY KEY ("id") 20 | ); 21 | CREATE INDEX "conversation" ON "messages" ("conversation"); 22 | COMMIT; 23 | -------------------------------------------------------------------------------- /public/assets/css/style.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | width: 100%; 3 | height: 100%; 4 | margin: 0; 5 | padding: 0; 6 | } 7 | 8 | :root { 9 | --color-white: #fff; 10 | --color-main: #2c2d30; 11 | --color-main-fade: #2c2d3000; 12 | --color-secondary: #171717; 13 | --color-secondary-fade: #17171700; 14 | --color-button-hover: #242629; 15 | --color-button-hover-fade: #24262900; 16 | --color-user-icon: #8e0000; 17 | --color-groupings: #9ca6b5; 18 | --color-gpt-icon: #000000; 19 | --color-black: #1e1e1f; 20 | --color-user-menu-hover: #383b42; 21 | --color-text: #f5f9ff; 22 | --color-gpt3: #5fc319; 23 | --color-gpt4: #f22626; 24 | --color-secondary-p: #c9ccd1; 25 | --color-logo: #848484; 26 | --color-model-name: #ffffff; 27 | --color-assistant-bg: #3f4042; 28 | --color-assistant-text: #e1e6ed; 29 | --color-disclaimer: #d0d2e1; 30 | --color-border1: #484a4e; 31 | --color-user-menu-border: #34373a; 32 | --color-user-menu-selected-border: #4a5562; 33 | --color-border2: #292d32; 34 | --color-user-message-border: #2f353d; 35 | --link-color: #11ba00; 36 | --mode-selector-color: #1e1f21; 37 | } 38 | 39 | body { 40 | background: var(--color-main); 41 | display: flex; 42 | font-size: 1em; 43 | font-family: system-ui, sans-serif; 44 | } 45 | 46 | a:link, a:visited { 47 | color: var(--link-color); 48 | } 49 | 50 | img { 51 | max-width: 100%; 52 | max-height: 55vh; 53 | } 54 | 55 | #sidebar { 56 | position: relative; 57 | left: 0; 58 | background: var(--color-secondary); 59 | width: 260px; 60 | padding: 8px; 61 | box-sizing: border-box; 62 | display: flex; 63 | justify-content: space-between; 64 | flex-direction: column; 65 | transition: all 0.2s ease-in-out; 66 | height: 100%; 67 | } 68 | 69 | .float-top { 70 | display: flex; 71 | flex-direction: column; 72 | height: calc( 100% - 50px ); 73 | } 74 | 75 | #sidebar.hidden { 76 | left: -260px; 77 | margin-right: -260px; 78 | } 79 | 80 | #sidebar.hidden .hide-sidebar { 81 | left: 53px; 82 | transform: rotate(180deg); 83 | padding: 15px 13px 11px 13px; 84 | } 85 | 86 | button, 87 | .button { 88 | display: block; 89 | background: inherit; 90 | border: 1px solid var(--color-border1); 91 | border-radius: 5px; 92 | color: var(--color-white); 93 | padding: 13px; 94 | box-sizing: border-box; 95 | text-align: left; 96 | cursor: pointer; 97 | } 98 | 99 | button:hover, 100 | .button:hover { 101 | background: var(--color-button-hover); 102 | } 103 | 104 | .sidebar-controls { 105 | display: flex; 106 | gap: 10px; 107 | margin-bottom: 8px; 108 | } 109 | 110 | .sidebar-controls button { 111 | padding: 12px 13px 12px 13px; 112 | } 113 | 114 | .hide-sidebar { 115 | position: relative; 116 | left: 0; 117 | top: 0; 118 | transition: all 0.2s ease-in-out; 119 | transform: rotate(0deg); 120 | background: var(--color-black); 121 | } 122 | 123 | .new-chat i { 124 | margin-right: 13px; 125 | } 126 | 127 | .new-chat { 128 | flex: 1; 129 | } 130 | 131 | .conversations, 132 | .conversations li { 133 | list-style: none; 134 | list-style-type: none; 135 | margin: 0; 136 | padding: 0; 137 | } 138 | 139 | .conversations { 140 | width: 100%; 141 | overflow-y: auto; 142 | padding-right: 8px; 143 | } 144 | 145 | .conversations li { 146 | position: relative; 147 | } 148 | 149 | .conversations li .fa { 150 | margin-right: 7px; 151 | } 152 | 153 | .conversations li > button { 154 | width: 100%; 155 | border: none; 156 | font-size: 0.9em; 157 | white-space: nowrap; 158 | overflow: hidden; 159 | } 160 | 161 | .conversations li.active > button { 162 | background: var(--color-main); 163 | } 164 | 165 | .edit-buttons { 166 | display: none; 167 | position: absolute; 168 | right: 8px; 169 | top: 0; 170 | } 171 | 172 | .conversations li:hover .edit-buttons { 173 | display: flex; 174 | } 175 | 176 | .fade { 177 | position: absolute; 178 | right: 0; 179 | top: 0; 180 | background: var(--color-user-icon); 181 | width: 40px; 182 | height: 100%; 183 | border-radius: 5px; 184 | background: transparent; 185 | background: linear-gradient(90deg, var(--color-secondary-fade) 0%, var(--color-secondary) 50%); 186 | } 187 | 188 | .conversations li.active .fade { 189 | background: linear-gradient(90deg, var(--color-main-fade) 0%, var(--color-main) 50%); 190 | } 191 | 192 | .conversations li:hover .fade { 193 | width: 80px; 194 | background: linear-gradient(90deg, var(--color-button-hover-fade) 0%, var(--color-button-hover) 30%); 195 | } 196 | 197 | .edit-buttons button { 198 | border: none; 199 | padding: 0; 200 | margin: 13px 1px 13px 1px; 201 | opacity: 0.7; 202 | } 203 | 204 | .edit-buttons button:hover { 205 | background: none; 206 | opacity: 1; 207 | } 208 | 209 | .conversations li.grouping { 210 | color: var(--color-groupings); 211 | font-size: 0.7em; 212 | font-weight: bold; 213 | padding-left: 13px; 214 | margin-top: 12px; 215 | margin-bottom: 12px; 216 | } 217 | 218 | i.user-icon { 219 | padding: 6px; 220 | color: var(--color-white); 221 | background: var(--color-user-icon); 222 | display: inline-block; 223 | text-align: center; 224 | width: 15px; 225 | border-radius: 3px; 226 | margin-right: 6px; 227 | font-style: normal; 228 | width: 18px; 229 | height: 18px; 230 | font-size: 15px; 231 | text-transform: uppercase; 232 | font-family: system-ui, sans-serif; 233 | } 234 | 235 | .gpt.user-icon { 236 | background: var(--color-gpt-icon); 237 | } 238 | 239 | .user-menu { 240 | position: relative; 241 | border-top: 1px solid var(--color-border1); 242 | } 243 | 244 | .user-menu button { 245 | width: 100%; 246 | border: none; 247 | } 248 | 249 | .user-menu .dots { 250 | position: relative; 251 | top: 11px; 252 | float: right; 253 | opacity: 0.7; 254 | } 255 | 256 | .user-menu > ul, 257 | .user-menu li { 258 | list-style: none; 259 | list-style-type: none; 260 | padding: 0; 261 | margin: 0; 262 | } 263 | 264 | .user-menu > ul { 265 | display: none; 266 | position: absolute; 267 | top: 0; 268 | left: 0; 269 | opacity: 0; 270 | transform: translateY(-100%); 271 | background: var(--color-black); 272 | border-radius: 10px; 273 | width: 100%; 274 | transition: all 0.2s ease-in-out; 275 | } 276 | 277 | .user-menu > ul.show-animate { 278 | display: block; 279 | } 280 | 281 | .user-menu > ul.show { 282 | opacity: 1; 283 | margin-top: -8px; 284 | } 285 | 286 | .user-menu li button { 287 | border-radius: 0; 288 | } 289 | 290 | .user-menu li button:hover { 291 | background: var(--color-user-menu-hover); 292 | } 293 | 294 | .user-menu li:first-child button { 295 | border-top-left-radius: 5px; 296 | border-top-right-radius: 5px; 297 | } 298 | 299 | .user-menu li:last-child button { 300 | border-top: 1px solid var(--color-user-menu-border); 301 | border-bottom-left-radius: 5px; 302 | border-bottom-right-radius: 5px; 303 | } 304 | 305 | ::-webkit-scrollbar { 306 | width: 9px; 307 | } 308 | 309 | ::-webkit-scrollbar-track { 310 | background-color: transparent; 311 | } 312 | 313 | ::-webkit-scrollbar-thumb { 314 | background-color: transparent; 315 | } 316 | 317 | :hover::-webkit-scrollbar-thumb { 318 | background-color: var(--color-text); 319 | border-radius: 5px; 320 | } 321 | 322 | ::-webkit-scrollbar-thumb:hover { 323 | background-color: var(--color-text); 324 | border-radius: 5px; 325 | } 326 | 327 | main { 328 | width: 100%; 329 | margin: 0 auto; 330 | display: flex; 331 | flex-direction: column; 332 | align-content: center; 333 | justify-content: space-between; 334 | padding: 0 0 30px 0; 335 | box-sizing: border-box; 336 | } 337 | 338 | main .view { 339 | display: none; 340 | flex-direction: column; 341 | } 342 | 343 | main .view.show { 344 | display: flex; 345 | } 346 | 347 | .top-menu { 348 | position: relative; 349 | display: flex; 350 | margin: 24px auto; 351 | z-index: 2; 352 | } 353 | 354 | .model-selector { 355 | position: relative; 356 | border-radius: 11px; 357 | background: var(--color-secondary); 358 | display: flex; 359 | padding: 4px; 360 | gap: 4px; 361 | } 362 | 363 | .model-selector .model-button { 364 | font-size: 0.8em; 365 | border-radius: 9px; 366 | text-align: center; 367 | width: 150px; 368 | border: none; 369 | font-weight: bold; 370 | opacity: 0.5; 371 | cursor: pointer; 372 | } 373 | 374 | .model-selector .model-button:hover { 375 | background: none; 376 | opacity: 1; 377 | } 378 | 379 | .model-selector .model-button.selected { 380 | border: 1px solid var(--color-user-menu-selected-border); 381 | background: var(--color-user-menu-hover); 382 | opacity: 1; 383 | } 384 | 385 | .model-selector button .fa { 386 | margin-right: 5px; 387 | } 388 | 389 | .gpt-3 .fa { 390 | color: var(--color-gpt3); 391 | } 392 | 393 | .gpt-4 .fa { 394 | color: var(--color-gpt4); 395 | } 396 | 397 | .model-info { 398 | display: none; 399 | position: absolute; 400 | bottom: 5px; 401 | left: 0; 402 | transform: translateY(100%); 403 | padding: 15px; 404 | cursor: default; 405 | } 406 | 407 | .model-info-box { 408 | padding: 20px 20px 10px 20px; 409 | border-radius: 15px; 410 | background: var(--color-secondary); 411 | color: var(--color-white); 412 | text-align: left; 413 | } 414 | 415 | .model-selector > .button:hover .model-info { 416 | display: block; 417 | } 418 | 419 | .model-selector p { 420 | font-size: 1.1em; 421 | margin: 0 0 15px 0; 422 | } 423 | 424 | p.secondary { 425 | font-size: 1em; 426 | color: var(--color-secondary-p); 427 | } 428 | 429 | .mode-selector { 430 | border-radius: 11px; 431 | background: var(--mode-selector-color); 432 | margin-left: 6px; 433 | border: none; 434 | width: 48px; 435 | padding: 15px; 436 | position: relative; 437 | text-align: center; 438 | } 439 | 440 | .mode-selector:hover { 441 | background: var(--color-secondary); 442 | } 443 | 444 | .mode-selector:hover .mode-selector-wrap { 445 | display: block; 446 | } 447 | 448 | .mode-selector ul, 449 | .mode-selector li { 450 | list-style: none; 451 | list-style-type: none; 452 | margin: 0; 453 | padding: 0; 454 | } 455 | 456 | .mode-selector-wrap { 457 | display: none; 458 | position: absolute; 459 | bottom: 0px; 460 | right: 0; 461 | transform: translateY( 100% ); 462 | } 463 | 464 | .mode-selector ul { 465 | width: 150px; 466 | background: var(--color-secondary); 467 | border-radius: 5px; 468 | margin-top: 6px; 469 | overflow: hidden; 470 | } 471 | 472 | .mode-selector button { 473 | width: 100%; 474 | border: none; 475 | border-radius: 0; 476 | } 477 | 478 | .mode-selector button i.fa { 479 | width: 22px; 480 | } 481 | 482 | .logo { 483 | position: relative; 484 | z-index: 1; 485 | color: var(--color-logo); 486 | font-weight: bold; 487 | text-align: center; 488 | font-size: 2.3em; 489 | } 490 | 491 | .view.conversation-view { 492 | overflow-y: auto; 493 | } 494 | 495 | .model-name { 496 | background: var(--color-main); 497 | text-align: center; 498 | color: var(--color-model-name); 499 | padding: 23px; 500 | border-bottom: 1px solid var(--color-border2); 501 | font-size: 0.85em; 502 | } 503 | 504 | .message { 505 | display: flex; 506 | gap: 20px; 507 | padding: 25px 60px 15px 60px; 508 | border-bottom: 1px solid var(--color-border2); 509 | font-size: 0.95em; 510 | position: relative; 511 | } 512 | 513 | .message .content { 514 | padding-top: 5px; 515 | width: 100%; 516 | } 517 | 518 | .action-selector { 519 | max-width: 300px; 520 | display: flex; 521 | gap: 5px; 522 | } 523 | 524 | .run-code { 525 | background: #008c1a; 526 | color: #fff; 527 | } 528 | 529 | .dont-run-code { 530 | background: #8c0000; 531 | color: #fff; 532 | } 533 | 534 | .user.message { 535 | color: var(--color-text); 536 | } 537 | 538 | .assistant.message { 539 | background: var(--color-assistant-bg); 540 | color: var(--color-assistant-text); 541 | } 542 | 543 | #message-form { 544 | margin: 0 auto; 545 | width: 100%; 546 | box-sizing: border-box; 547 | max-width: 850px; 548 | text-align: center; 549 | padding: 0px 45px 0 45px; 550 | box-shadow: var(--color-main) 0 0 50px; 551 | } 552 | 553 | .message-wrapper { 554 | position: relative; 555 | } 556 | 557 | #message::placeholder { 558 | color: var(--color-groupings); 559 | } 560 | 561 | #message { 562 | background: var(--color-user-menu-hover); 563 | border-radius: 13px; 564 | width: 100%; 565 | box-sizing: border-box; 566 | border: 1px solid var(--color-user-message-border); 567 | resize: none; 568 | padding: 17px 85px 17px 15px; 569 | font-family: inherit; 570 | font-size: 1em; 571 | color: var(--color-white); 572 | box-shadow: rgba(0,0,0,0.2) 0 0 45px; 573 | outline: none; 574 | } 575 | 576 | .disclaimer { 577 | margin-top: 12px; 578 | color: var(--color-disclaimer); 579 | font-size: 0.7em; 580 | } 581 | 582 | #send-button { 583 | position: absolute; 584 | right: 15px; 585 | top: 50%; 586 | transform: translateY(-50%); 587 | background: var(--color-gpt3); 588 | border-radius: 5px; 589 | display: inline-block; 590 | font-size: 1em; 591 | padding: 7px 9px 7px 7px; 592 | color: var(--color-white); 593 | border: none; 594 | margin-top: -2px; 595 | } 596 | 597 | button#send-button:hover { 598 | border: none; 599 | background: var(--color-gpt3); 600 | color: var(--color-white); 601 | } 602 | 603 | p { 604 | margin: 0 0 1.5em 0; 605 | } 606 | 607 | button.copy { 608 | border-radius: 5px; 609 | background: #fff; 610 | color: #050509; 611 | padding: 0; 612 | width: 20px; 613 | height: 20px; 614 | border: 0; 615 | position: absolute; 616 | top: 10px; 617 | right: 10px; 618 | text-align: center; 619 | } 620 | 621 | table { 622 | border-collapse: collapse; 623 | width: 100%; 624 | } 625 | 626 | th { 627 | background-color: #ddd; 628 | font-weight: bold; 629 | color: #474747; 630 | } 631 | 632 | td, th { 633 | border: 1px solid #ccc; 634 | padding: 8px; 635 | text-align: left; 636 | } 637 | 638 | pre { 639 | width: 100%; 640 | } 641 | 642 | pre code.hljs { 643 | white-space: pre-wrap; 644 | border-radius: 10px; 645 | font-size: 1.1em; 646 | margin: 5px; 647 | position: relative; 648 | } 649 | 650 | #cursor { 651 | width: 5px; 652 | height: 20px; 653 | background-color: #fff; 654 | display: inline-block; 655 | animation: blink 1s infinite; 656 | } 657 | 658 | @keyframes blink { 659 | 0% { 660 | opacity: 0; 661 | } 662 | 50% { 663 | opacity: 1; 664 | } 665 | 100% { 666 | opacity: 0; 667 | } 668 | } 669 | 670 | @media (max-width: 700px) { 671 | main { 672 | z-index: 1; 673 | } 674 | 675 | #sidebar { 676 | position: fixed; 677 | top: 0; 678 | left: -260px; 679 | margin-right: -260px; 680 | z-index: 2; 681 | } 682 | 683 | #sidebar.hidden { 684 | left: 0; 685 | margin-right: 0; 686 | } 687 | 688 | .hide-sidebar { 689 | left: 53px; 690 | transform: rotate(180deg); 691 | padding: 15px 13px 11px 13px; 692 | } 693 | 694 | #sidebar.hidden .hide-sidebar { 695 | left: 0; 696 | transition: all 0.2s ease-in-out; 697 | transform: rotate(0deg); 698 | } 699 | 700 | .message { 701 | padding-left: 20px; 702 | padding-right: 20px; 703 | gap: 15px; 704 | } 705 | 706 | #message { 707 | font-size: 0.9em; 708 | } 709 | 710 | .disclaimer { 711 | font-size: 0.5em; 712 | } 713 | } 714 | 715 | @media (max-width: 335px) { 716 | main { 717 | padding: 0 0 20px 0; 718 | } 719 | 720 | body { 721 | font-size: 0.8em; 722 | } 723 | 724 | #message-form { 725 | padding: 0px 20px 0 20px; 726 | } 727 | 728 | #message { 729 | padding: 13px 85px 13px 12px; 730 | } 731 | 732 | .message { 733 | padding: 15px 20px 7px 20px; 734 | gap: 8px; 735 | } 736 | 737 | #send-button { 738 | font-size: 0.8em; 739 | padding: 7px 8px 7px 7px; 740 | right: 11px; 741 | } 742 | 743 | .disclaimer { 744 | font-size: 0.6em; 745 | margin-top: 6px; 746 | } 747 | } 748 | 749 | @media (max-width: 260px) { 750 | .message { 751 | flex-direction: column; 752 | } 753 | 754 | .message i.user-icon { 755 | padding: 4px; 756 | width: 15px; 757 | height: 15px; 758 | font-size: 12px; 759 | } 760 | } 761 | -------------------------------------------------------------------------------- /public/assets/js/script.js: -------------------------------------------------------------------------------- 1 | const message_input = document.querySelector( "#message" ); 2 | const send_button = document.querySelector( "#send-button" ); 3 | const message_list = document.querySelector( "#chat-messages" ); 4 | const new_chat_link = document.querySelector( "li.new-chat" ); 5 | const conversations_list = document.querySelector( "ul.conversations" ); 6 | const mode_buttons = document.querySelectorAll( ".mode-selector button" ); 7 | const current_mode_icon = document.querySelector( ".current-mode-icon" ); 8 | const current_mode_name = document.querySelector( ".current-mode-name" ); 9 | 10 | const mode_names = { 11 | "normal": "", 12 | "speech": "(Speech)", 13 | "code_interpreter": "(CodeInterpreter)" 14 | }; 15 | 16 | mode_buttons.forEach( (button) => { 17 | button.addEventListener( "click", () => { 18 | selected_mode = button.getAttribute( "data-mode" ); 19 | 20 | const new_icon = button.getAttribute( "data-icon" ); 21 | const old_icon = current_mode_icon.getAttribute( "data-icon" ); 22 | 23 | current_mode_icon.classList.remove( "fa-" + old_icon ); 24 | current_mode_icon.classList.add( "fa-" + new_icon ); 25 | current_mode_icon.setAttribute( "data-icon", new_icon); 26 | 27 | current_mode_name.textContent = mode_names[selected_mode]; 28 | } ); 29 | } ); 30 | 31 | const markdown_converter = new showdown.Converter({ 32 | requireSpaceBeforeHeadingText: true, 33 | tables: true, 34 | underline: false, 35 | }); 36 | 37 | // detect Enter on message input to send message 38 | message_input.addEventListener( "keydown", function( e ) { 39 | if( e.keyCode == 13 && !e.shiftKey ) { 40 | e.preventDefault(); 41 | submit_message(); 42 | return false; 43 | } 44 | } ); 45 | 46 | // detect click on send button to send message 47 | send_button.addEventListener( "click", function( e ) { 48 | e.preventDefault(); 49 | submit_message(); 50 | return false; 51 | } ); 52 | 53 | /** 54 | * Submits the currently typed in message to ChatWTF 55 | */ 56 | function submit_message() { 57 | message_box.style.height = "auto"; 58 | add_message( "user", escapeHtml( message_input.value ) ); 59 | send_message( message_input.value ); 60 | } 61 | 62 | /** 63 | * Creates a title for a conversation 64 | * based on user's question and 65 | * ChatGPT's answer 66 | * 67 | * @param {string} question User's question 68 | * @param {string} answer ChatGPT's answer 69 | * @param {HTMLElement} title_link Title link element 70 | * @param {string} chat_id ID of the conversation 71 | */ 72 | function create_title( question, answer, title_link, chat_id ) { 73 | const data = new FormData(); 74 | data.append('question', question); 75 | data.append('answer', answer); 76 | data.append('chat_id', chat_id); 77 | 78 | fetch( base_uri + "create_title.php", { 79 | method: 'POST', 80 | body: data 81 | }) 82 | .then(response => response.text()) 83 | .then(responseText => { 84 | title_link.querySelector(".title-text").textContent = responseText; 85 | title_link.setAttribute( "title", responseText ); 86 | }) 87 | .catch(error => { 88 | console.log(error); 89 | }); 90 | } 91 | 92 | function scrolled_to_bottom() { 93 | return ( Math.ceil( message_list.scrollTop ) + message_list.offsetHeight ) >= message_list.scrollHeight; 94 | } 95 | 96 | class AudioQueue { 97 | queue = []; 98 | handling = false; 99 | 100 | async add( text ) { 101 | const audio = await create_text_to_speech( text ); 102 | this.queue.push( audio ); 103 | if( this.queue.length === 1 ) { 104 | await this.handle(); 105 | } 106 | } 107 | 108 | async handle() { 109 | if( this.handling ) { 110 | return false; 111 | } 112 | 113 | this.handling = true; 114 | 115 | const audio = this.queue.shift(); 116 | await play_audio( audio ); 117 | 118 | if( this.queue.length > 0 ) { 119 | this.handling = false; 120 | await this.handle(); 121 | } 122 | 123 | this.handling = false; 124 | } 125 | } 126 | 127 | /** 128 | * Sends a message to ChatGPT and appends the 129 | * message and the response to the chat 130 | */ 131 | async function send_message( message_text ) { 132 | show_view( ".conversation-view" ); 133 | 134 | // intialize message with blinking cursor 135 | let message = add_message( "assistant", '
' ); 136 | 137 | // empty the message input field 138 | message_input.value = ""; 139 | 140 | // send message 141 | let data = new FormData(); 142 | data.append( "chat_id", chat_id ); 143 | data.append( "model", chatgpt_model ); 144 | data.append( "mode", selected_mode ); 145 | data.append( "message", message_text ); 146 | 147 | // send message and get chat id 148 | chat_id = await fetch( base_uri + "message.php", { 149 | method: "POST", 150 | body: data 151 | } ).then((response) => { 152 | return response.text(); 153 | }); 154 | 155 | // listen for response tokens 156 | const eventSource = new EventSource( 157 | base_uri + "message.php?chat_id=" + chat_id + "&model=" + chatgpt_model + "&mode=" + selected_mode 158 | ); 159 | 160 | // handle errors 161 | eventSource.addEventListener( "error", function() { 162 | update_message( message, "Sorry, there was an error in the request. Check your error logs." ); 163 | } ); 164 | 165 | // intitialize ChatGPT response 166 | let response = ""; 167 | 168 | // initialize audio handling 169 | const audio_queue = new AudioQueue(); 170 | let paragraph = ""; 171 | 172 | // when a new token arrives 173 | eventSource.addEventListener( "message", function( event ) { 174 | let json = JSON.parse( event.data ); 175 | 176 | if( json.hasOwnProperty( "role" ) && json.role === "function_call" ) { 177 | if( json.function_name === "python" ) { 178 | const args = JSON.parse( json.function_arguments ); 179 | update_message( message, "I would like to run the following code:\n\n```\n" + args.code + "\n```" ); 180 | 181 | message.querySelector(".content").insertAdjacentHTML( "beforeend", ` 182 |
183 | 184 | 185 |
186 | ` ); 187 | } 188 | return; 189 | } 190 | 191 | const speech_mode = selected_mode === "speech"; 192 | 193 | // append token to response 194 | response += json.content; 195 | paragraph += json.content; 196 | 197 | if( paragraph.indexOf( "\n\n" ) !== -1 ) { 198 | if( speech_mode && paragraph.trim() !== "" ) { 199 | audio_queue.add( paragraph ); 200 | } 201 | 202 | paragraph = ""; 203 | } 204 | 205 | let scrolled = scrolled_to_bottom(); 206 | 207 | // update message in UI 208 | update_message( message, response ); 209 | 210 | if( scrolled ) { 211 | message_list.scrollTop = message_list.scrollHeight; 212 | } 213 | } ); 214 | 215 | eventSource.addEventListener( "stop", async function( event ) { 216 | eventSource.close(); 217 | 218 | const speech_mode = selected_mode === "speech"; 219 | 220 | if( new_chat ) { 221 | let title_link = create_chat_link( chat_id ); 222 | 223 | create_title( message_text, response, title_link, chat_id ); 224 | 225 | new_chat = false; 226 | } 227 | 228 | if( speech_mode && paragraph.trim() !== "" ) { 229 | audio_queue.add( paragraph ); 230 | paragraph = ""; 231 | } 232 | } ); 233 | 234 | message_input.focus(); 235 | } 236 | 237 | async function create_text_to_speech( text ) { 238 | return new Promise( (resolve, _) => { 239 | const data = new FormData(); 240 | data.append( "text", text ); 241 | 242 | fetch( base_uri + "text_to_audio.php", { 243 | method: 'POST', 244 | body: data 245 | } ).then( response => { 246 | return response.json(); 247 | } ).then( data => { 248 | if( data.status !== "OK" ) { 249 | console.log( new Error( "Unable to create audio" ) ); 250 | return; 251 | } 252 | 253 | var audio = new Audio(); 254 | audio.src = "data:audio/mpeg;base64," + data.response; 255 | 256 | audio.addEventListener( 'canplaythrough', () => { 257 | resolve( audio ); 258 | } ); 259 | } ); 260 | } ); 261 | } 262 | 263 | async function play_audio( audio ) { 264 | return new Promise( (resolve, _) => { 265 | audio.addEventListener( 'ended', () => { 266 | resolve(); 267 | } ); 268 | audio.play(); 269 | } ); 270 | } 271 | 272 | /** 273 | * Creates a new chat conversation link 274 | * to the sidebar 275 | * 276 | * @returns Title link element 277 | */ 278 | function create_chat_link() { 279 | conversations_list.querySelector(".active")?.classList.remove("active"); 280 | 281 | let title_link = document.createElement( "li" ); 282 | title_link.classList.add( "active" ); 283 | 284 | title_link.insertAdjacentHTML("afterbegin", ` 285 | 286 |
287 |
288 | 289 | 290 |
291 | `); 292 | 293 | conversations_list.prepend(title_link); 294 | 295 | return title_link; 296 | } 297 | 298 | /** 299 | * Adds a message to the message list 300 | * 301 | * @param {string} role user/assistant 302 | * @param {string} message The message to add 303 | * @returns The added message element 304 | */ 305 | function add_message( role, message ) { 306 | const message_item = document.createElement("div"); 307 | message_item.classList.add(role); 308 | message_item.classList.add("message"); 309 | 310 | let user_icon_class = ""; 311 | let user_icon_letter = "U"; 312 | if( role === "assistant" ) { 313 | user_icon_letter = "G"; 314 | user_icon_class = "gpt"; 315 | } 316 | 317 | message_item.insertAdjacentHTML("beforeend", ` 318 |
319 | 320 | ${user_icon_letter} 321 | 322 |
323 |
324 | ${message} 325 |
326 | `); 327 | 328 | message_list.appendChild(message_item); 329 | 330 | // add code highlighting 331 | message_item.querySelectorAll('.content pre code').forEach( (el) => { 332 | hljs.highlightElement(el); 333 | } ); 334 | 335 | // scroll down the message list 336 | message_list.scrollTop = message_list.scrollHeight; 337 | 338 | return message_item; 339 | } 340 | 341 | /** 342 | * Updates a chat message with the given text 343 | * 344 | * @param {HTMLElement} message The message element to update 345 | * @param {string} new_message The new message 346 | */ 347 | function update_message( message, new_message ) { 348 | const content = message.querySelector(".content"); 349 | 350 | // convert message from Markdown to HTML 351 | html_message = convert_markdown( new_message ); 352 | 353 | // update message content 354 | content.innerHTML = html_message; 355 | 356 | // delete old copy buttons 357 | message.querySelectorAll("button.copy").forEach(btn => { 358 | btn.remove(); 359 | }); 360 | 361 | // add code highlighting 362 | content.querySelectorAll('pre code').forEach( (el) => { 363 | let code = el.textContent; 364 | hljs.highlightElement(el); 365 | 366 | el.appendChild( 367 | create_copy_button( code ) 368 | ); 369 | } ); 370 | 371 | message.appendChild( 372 | create_copy_button( new_message ) 373 | ); 374 | } 375 | 376 | function create_copy_button( text_to_copy ) { 377 | let icon = document.createElement( "i" ); 378 | icon.classList.add( "fa", "fa-clipboard" ); 379 | 380 | let copy_button = document.createElement( "button" ); 381 | copy_button.classList.add( "copy" ); 382 | copy_button.appendChild( icon ); 383 | copy_button.addEventListener( "click", function() { 384 | navigator.clipboard.writeText( text_to_copy ); 385 | icon.classList.remove( "fa-clipboard" ); 386 | icon.classList.add( "fa-check" ); 387 | } ); 388 | 389 | return copy_button; 390 | } 391 | 392 | function convert_links( text ) { 393 | text = text.replace( /\(data\//g, '(data.php?chat_id=' + chat_id + '&file=data/' ); 394 | text = text.replace( /\(sandbox:\/data\//g, '(data.php?chat_id=' + chat_id + '&file=data/' ); 395 | text = text.replace( /\(sandbox:\/mnt\/data\//g, '(data.php?chat_id=' + chat_id + '&file=data/' ); 396 | text = text.replace( /\(sandbox:data\//g, '(data.php?chat_id=' + chat_id + '&file=data/' ); 397 | return text; 398 | } 399 | 400 | /** 401 | * Converts Markdown formatted response into HTML 402 | * 403 | * @param {string} text Markdown formatted text 404 | * @returns HTML formatted text 405 | */ 406 | function convert_markdown( text ) { 407 | text = convert_links( text ); 408 | text = text.trim(); 409 | 410 | // add ending code block tags when missing 411 | let code_block_count = (text.match(/```/g) || []).length; 412 | if( code_block_count % 2 !== 0 ) { 413 | text += "\n```"; 414 | } 415 | 416 | // HTML-escape parts of text that are not inside ticks. 417 | // This prevents ': '>', 465 | '"': '"', 466 | "'": ''' 467 | }; 468 | 469 | return text.replace(/[&<>"']/g, function(m) { return map[m]; }); 470 | } 471 | 472 | // events to run when page loads 473 | document.addEventListener( "DOMContentLoaded", function() { 474 | // markdown format all messages in view 475 | let messages = document.querySelectorAll( ".message" ); 476 | messages.forEach( function( message ) { 477 | update_message( message, message.querySelector(".content").textContent ); 478 | } ); 479 | 480 | // event listeners 481 | document.body.addEventListener( "click", function(e) { 482 | const delete_button = e.target.closest( "button.delete" ); 483 | if( delete_button ) { 484 | delete_button_action( delete_button ); 485 | return; 486 | } 487 | 488 | if( e.target.closest( ".run-code" ) ) { 489 | add_message( "user", "Yes, run the code." ); 490 | send_message( "Yes, run the code." ); 491 | e.target.closest( ".action-selector" ).remove(); 492 | return; 493 | } 494 | 495 | if( e.target.closest( ".dont-run-code" ) ) { 496 | add_message( "user", "No, don't run the code." ); 497 | send_message( "No, don't run the code." ); 498 | e.target.closest( ".action-selector" ).remove(); 499 | return; 500 | } 501 | } ); 502 | 503 | // scroll down the message list 504 | message_list.scrollTop = message_list.scrollHeight; 505 | } ); 506 | 507 | /** 508 | * Handles the chat conversation delete button click 509 | * 510 | * @param {Element} button The delete button 511 | */ 512 | function delete_button_action( button ) { 513 | if( ! confirm( "Are you sure you want to delete this conversation?" ) ) { 514 | return; 515 | } 516 | 517 | const chat_id = button.getAttribute( "data-id" ); 518 | const data = new FormData(); 519 | data.append( "chat_id", chat_id ); 520 | // @todo: add error handling 521 | fetch( base_uri + "delete_chat.php", { 522 | method: 'POST', 523 | body: data 524 | } ); 525 | button.parentNode.parentNode.remove(); 526 | } 527 | -------------------------------------------------------------------------------- /public/assets/js/ui-script.js: -------------------------------------------------------------------------------- 1 | const sidebar = document.querySelector("#sidebar"); 2 | const hide_sidebar = document.querySelector(".hide-sidebar"); 3 | const new_chat_button = document.querySelector(".new-chat"); 4 | 5 | hide_sidebar.addEventListener( "click", function() { 6 | sidebar.classList.toggle( "hidden" ); 7 | } ); 8 | 9 | const user_menu = document.querySelector(".user-menu ul"); 10 | const show_user_menu = document.querySelector(".user-menu button"); 11 | 12 | show_user_menu.addEventListener( "click", function() { 13 | if( user_menu.classList.contains("show") ) { 14 | user_menu.classList.toggle( "show" ); 15 | setTimeout( function() { 16 | user_menu.classList.toggle( "show-animate" ); 17 | }, 200 ); 18 | } else { 19 | user_menu.classList.toggle( "show-animate" ); 20 | setTimeout( function() { 21 | user_menu.classList.toggle( "show" ); 22 | }, 50 ); 23 | } 24 | } ); 25 | 26 | const models = document.querySelectorAll(" .model-button"); 27 | 28 | for( const model of models ) { 29 | model.addEventListener("click", () => { 30 | document.querySelector(".model-button.selected")?.classList.remove("selected"); 31 | model.classList.add("selected"); 32 | chatgpt_model = model.getAttribute("data-model"); 33 | chatgpt_model_name = model.getAttribute("data-name"); 34 | document.querySelectorAll( ".current-model" ).forEach( (e) => { 35 | e.textContent = chatgpt_model_name; 36 | } ); 37 | }); 38 | } 39 | 40 | const message_box = document.querySelector("#message"); 41 | 42 | message_box.addEventListener("keyup", function() { 43 | message_box.style.height = "auto"; 44 | let height = message_box.scrollHeight + 2; 45 | if( height > 200 ) { 46 | height = 200; 47 | } 48 | message_box.style.height = height + "px"; 49 | }); 50 | 51 | function show_view( view_selector ) { 52 | document.querySelectorAll(".view").forEach(view => { 53 | view.style.display = "none"; 54 | }); 55 | 56 | document.querySelector(view_selector).style.display = "flex"; 57 | } 58 | 59 | new_chat_button.addEventListener("click", function() { 60 | location.href=base_uri + "/"; 61 | }); 62 | 63 | document.querySelectorAll(".conversation-button").forEach(button => { 64 | button.addEventListener("click", function() { 65 | location.href = base_uri + "index.php?chat_id=" + button.getAttribute("data-id"); 66 | }) 67 | }); 68 | -------------------------------------------------------------------------------- /public/create_title.php: -------------------------------------------------------------------------------- 1 | smessage( "Create a short title to be used in a conversation list in a chatbot. It should describe what the conversation is about (not a 'book title')" ); 21 | $chatgpt->umessage( "Create a concise title for the following chat conversation:\nQ: " . $question . "\nA: " . $answer ); 22 | 23 | return trim( $chatgpt->response()->content, '"' ); 24 | } 25 | 26 | session_start(); 27 | 28 | $settings = require( __DIR__ . "/../settings.php" ); 29 | 30 | require( __DIR__ . "/../database.php" ); 31 | require( __DIR__ . "/../autoload.php" ); 32 | 33 | $db = get_db(); 34 | $conversation_class = get_conversation_class( $db ); 35 | 36 | $title = chatgpt_create_title( 37 | $_POST['question'], 38 | $_POST['answer'], 39 | $settings['api_key'] 40 | ); 41 | 42 | $chat_id = $_POST['chat_id']; 43 | 44 | $conversation = $conversation_class->find( $chat_id, $db ); 45 | 46 | if( $conversation ) { 47 | $conversation->set_title( $title ); 48 | $conversation->save(); 49 | 50 | echo $title; 51 | } else { 52 | throw new \Exception( "Unable to create title" ); 53 | } 54 | -------------------------------------------------------------------------------- /public/data.php: -------------------------------------------------------------------------------- 1 | find( $chat_id, $db ); 15 | 16 | if( $conversation ) { 17 | $conversation->delete(); 18 | 19 | echo "DELETED"; 20 | } else { 21 | echo "ERROR"; 22 | } 23 | -------------------------------------------------------------------------------- /public/index.php: -------------------------------------------------------------------------------- 1 | find( $chat_id, $db ); 13 | 14 | if( ! $conversation ) { 15 | $chat_id = null; 16 | } 17 | } 18 | 19 | $new_chat = ! $chat_id; 20 | 21 | $base_uri = $settings['base_uri'] ?? ""; 22 | 23 | if( $base_uri != "" ) { 24 | $base_uri = rtrim( $base_uri, "/" ) . "/"; 25 | } 26 | 27 | $speech_enabled = ( $settings['speech_enabled'] ?? false ) === true; 28 | 29 | $current_model = $conversation?->get_model() ?? $settings['model']; 30 | $current_mode = $conversation?->get_mode() ?? "normal"; 31 | 32 | if( empty( $current_mode ) ) { 33 | $current_mode = "normal"; 34 | } 35 | 36 | $mode_icons = [ 37 | "normal" => "message", 38 | "speech" => "volume-high", 39 | "code_interpreter" => "terminal", 40 | ]; 41 | 42 | $current_mode_icon = $mode_icons[$current_mode]; 43 | 44 | $mode_names = [ 45 | "normal" => "", 46 | "speech" => "(Speech)", 47 | "code_interpreter" => "(CodeInterpreter)", 48 | ]; 49 | 50 | $current_mode_name = $mode_names[$current_mode]; 51 | 52 | ?> 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | ChatWTF 65 | 73 | 74 | 75 | 115 |
116 |
" id="chat-messages"> 117 |
118 | 119 |
120 | get_messages( $chat_id, $db ) : []; 122 | 123 | $function_result = ""; 124 | foreach( $chat_history as $chat_message ) { 125 | if( $chat_message->role === "system" ) { 126 | continue; 127 | } 128 | $role = htmlspecialchars( $chat_message->role ); 129 | 130 | $classmap = [ 131 | "assistant" => "assistant", 132 | "user" => "user", 133 | "tool" => "assistant", 134 | "function" => "assistant", // Backward compatibility 135 | "function_call" => "assistant", 136 | ]; 137 | 138 | $message_class = $classmap[$role]; 139 | 140 | if( $message_class === "assistant" ) { 141 | $user_icon_class = "gpt"; 142 | $user_icon_letter = "G"; 143 | } else { 144 | $user_icon_class = ""; 145 | $user_icon_letter = "U"; 146 | } 147 | 148 | $message_content = ""; 149 | if( $role === "function_call" ) { 150 | if( $chat_message->function_name === "python" ) { 151 | $code = CodeInterpreter::parse_arguments( $chat_message->function_arguments ); 152 | $message_content = htmlspecialchars( "I want to run the following code:\n\n```\n" . $code . "\n```" ); 153 | } else { 154 | $message_content = htmlspecialchars( "" ); 155 | } 156 | } elseif( 157 | $role === "tool" || 158 | $role === "function" // Backward compatibility 159 | ) { 160 | $result_text = CodeInterpreter::parse_result( $chat_message->content ); 161 | $function_result = htmlspecialchars( "Result from code:\n\n```\n" . $result_text . "\n```\n\n" ); 162 | continue; 163 | } else { 164 | $message_content = $function_result . htmlspecialchars( $chat_message->content ); 165 | $function_result = ""; 166 | } 167 | ?> 168 |
169 |
170 | 171 | 172 | 173 |
174 |
175 | 176 |
177 |
178 | 181 |
182 |
"> 183 |
184 |
185 |
" data-model="gpt-3.5-turbo" data-name="GPT-3.5"> 186 | GPT-3.5 187 |
188 |
189 |

Our fastest model, great for most every day tasks.

190 | 191 |

Available to Free and Plus users

192 |
193 |
194 |
195 |
" data-model="gpt-4" data-name="GPT-4"> 196 | GPT-4 197 |
198 |
199 |

Our most capable model, great for creative stuff.

200 | 201 |

Available for Plus users.

202 |
203 |
204 |
205 |
206 | ["Normal", "message"], 209 | ]; 210 | if( ( $settings['speech_enabled'] ?? false ) === true ) { 211 | $options["speech"] = ["Speech", "volume-high"]; 212 | } 213 | if( ( $settings['code_interpreter']['enabled'] ?? false ) === true ) { 214 | $options["code_interpreter"] = ["CodeInterpreter", "terminal"]; 215 | } 216 | if( count( $options ) > 1 ) { 217 | ?> 218 |
219 | 220 |
221 |
    222 | $value ) { 224 | $name = htmlspecialchars( $value[0] ); 225 | $icon = htmlspecialchars( $value[1] ); 226 | 227 | echo '
  • '; 228 | } 229 | ?> 230 |
231 |
232 |
233 | 236 |
237 | 238 | 241 |
242 | 243 |
244 |
245 | 246 | 247 |
248 |
ChatWTF uses the OpenAI ChatGPT API but is not affiliated with OpenAI
249 |
250 |
251 | 252 | 253 | 254 | 255 | -------------------------------------------------------------------------------- /public/message.php: -------------------------------------------------------------------------------- 1 | find( $chat_id, $db ); 31 | 32 | if( ! $conversation ) { 33 | $conversation = new $conversation_class( $db ); 34 | $conversation->set_title( "Untitled chat" ); 35 | $conversation->set_mode( $mode ); 36 | $conversation->set_model( $model ); 37 | $conversation->save(); 38 | $chat_id = $conversation->get_id(); 39 | } 40 | 41 | if( $code_interpreter_enabled ) { 42 | $code_interpreter = new CodeInterpreter( $chat_id ); 43 | } 44 | 45 | $context = $conversation->get_messages(); 46 | 47 | if( empty( $context ) && ! empty( $settings['system_message'] ) ) { 48 | $system_message = new Message( 49 | role: "system", 50 | content: $settings['system_message'], 51 | ); 52 | 53 | $context[] = $system_message; 54 | $conversation->add_message( $system_message ); 55 | } 56 | 57 | if( isset( $_POST['message'] ) ) { 58 | $last_message = $context[count( $context ) - 1] ?? null; 59 | 60 | $message = new Message( 61 | role: "user", 62 | content: $_POST['message'], 63 | ); 64 | 65 | $wants_to_run_code = ( 66 | $code_interpreter_enabled && 67 | $last_message && 68 | $last_message->role === "function_call" && 69 | $last_message->function_name === "python" 70 | ); 71 | 72 | if( $wants_to_run_code ) { 73 | if( $_POST['message'] === "Yes, run the code." ) { 74 | $code = CodeInterpreter::parse_arguments( $last_message->function_arguments ); 75 | 76 | $response = $code_interpreter->python( $code ); 77 | 78 | $message = new Message( 79 | role: "tool", 80 | content: $response, 81 | function_name: "python", 82 | ); 83 | 84 | $conversation->add_message( $message ); 85 | } else { 86 | $msg = new Message( 87 | role: "tool", 88 | content: "User denied running the code. Ask what they want to do.", 89 | function_name: "python", 90 | ); 91 | 92 | $conversation->add_message( $msg ); 93 | } 94 | } else { 95 | $conversation->add_message( $message ); 96 | } 97 | 98 | echo $conversation->get_id(); 99 | exit; 100 | } 101 | 102 | header( "Content-type: text/event-stream" ); 103 | 104 | $error = null; 105 | 106 | // create a new completion 107 | try { 108 | $chatgpt = new ChatGPT( $settings['api_key'] ); 109 | 110 | if( $code_interpreter_enabled ) { 111 | $chatgpt = $code_interpreter->init_chatgpt( $chatgpt ); 112 | } 113 | 114 | if( isset( $settings['model'] ) ) { 115 | $chatgpt->set_model( $model ); 116 | } 117 | 118 | if( isset( $settings['params'] ) ) { 119 | $chatgpt->set_params( $settings['params'] ); 120 | } 121 | 122 | if( isset( $settings['params'] ) ) { 123 | $chatgpt->set_params( $settings['params'] ); 124 | } 125 | 126 | foreach( $context as $message ) { 127 | switch( $message->role ) { 128 | case "user": 129 | $chatgpt->umessage( $message->content ); 130 | break; 131 | case "assistant": 132 | $chatgpt->amessage( $message->content ); 133 | break; 134 | case "function_call": 135 | $chatgpt->amessage( 136 | tool_calls: [ 137 | (object) [ 138 | // TODO: Add support for real function ID 139 | "id" => $message->function_name, 140 | "type" => "function", 141 | "function" => (object) [ 142 | "name" => $message->function_name, 143 | "arguments" => $message->function_arguments, 144 | ] 145 | ] 146 | ] 147 | ); 148 | break; 149 | case "function": // Backward compatibility 150 | case "tool": 151 | // TODO: Add support for real function ID 152 | $chatgpt->fresult( $message->function_name, $message->content ); 153 | break; 154 | case "system": 155 | $chatgpt->smessage( $message->content ); 156 | break; 157 | } 158 | } 159 | 160 | if( $code_interpreter_enabled ) { 161 | $last_message = $context[count( $context ) - 1] ?? null; 162 | if( 163 | $last_message->role === "tool" || 164 | $last_message->role === "function" // Backward compatibility 165 | ) { 166 | $result_text = CodeInterpreter::parse_result( $last_message->content ); 167 | $result_response = "Result from code:\n```\n" . $result_text . "\n```\n\n"; 168 | 169 | echo "data: " . json_encode( [ 170 | "content" => $result_response, 171 | ] ) . "\n\n"; 172 | flush(); 173 | } 174 | 175 | $response = $chatgpt->response( 176 | raw_function_response: true, 177 | stream_type: StreamType::Event 178 | ); 179 | 180 | if( isset( $response->function_call ) ) { 181 | $function_call = $response->function_call; 182 | } elseif( ! empty( $response->tool_calls ) ) { 183 | // TODO: Support multiple functions or force 184 | // ChatGPT to call only one function at a time 185 | $function_call = $response->tool_calls[0]->function; 186 | } else { 187 | $function_call = null; 188 | } 189 | 190 | if( isset( $function_call ) ) { 191 | $code = CodeInterpreter::parse_arguments( $function_call->arguments ); 192 | 193 | echo "data: " . json_encode( [ 194 | "role" => "function_call", 195 | "function_name" => $function_call->name, 196 | "function_arguments" => json_encode( ["code" => $code] ), 197 | ] ) . "\n\n"; 198 | flush(); 199 | 200 | $message = new Message( 201 | role: "function_call", 202 | function_name: $function_call->name, 203 | function_arguments: $function_call->arguments, 204 | ); 205 | 206 | $conversation->add_message( $message ); 207 | 208 | echo "event: stop\n"; 209 | echo "data: stopped\n\n"; 210 | exit; 211 | } 212 | 213 | $response_text = $response->content; 214 | } else { 215 | $response_text = $chatgpt->stream( StreamType::Event )->content; 216 | } 217 | 218 | } catch ( Exception $e ) { 219 | $error = "Sorry, there was an unknown error in the OpenAI request"; 220 | } 221 | 222 | if( $error !== null ) { 223 | $response_text = $error; 224 | echo "data: " . json_encode( ["content" => $error] ) . "\n\n"; 225 | flush(); 226 | } 227 | 228 | $assistant_message = new Message( 229 | role: "assistant", 230 | content: $response_text, 231 | ); 232 | 233 | $conversation->add_message( $assistant_message ); 234 | 235 | echo "event: stop\n"; 236 | echo "data: stopped\n\n"; 237 | -------------------------------------------------------------------------------- /public/text_to_audio.php: -------------------------------------------------------------------------------- 1 | "ERROR", 9 | "response" => "Speech not enabled", 10 | ] ) ); 11 | } 12 | 13 | if( ! isset( $_POST['text'] ) ) { 14 | die( json_encode( [ 15 | "status" => "ERROR", 16 | "response" => "No text prodived", 17 | ] ) ); 18 | } 19 | 20 | $text = $_POST['text']; 21 | 22 | $input_file = tempnam( sys_get_temp_dir(), "cwtftext" ); 23 | $output_file = tempnam( sys_get_temp_dir(), "cwtfaudio" ); 24 | 25 | $write = file_put_contents( $input_file, trim( $text ) ); 26 | 27 | if( $write === false ) { 28 | die( json_encode( [ 29 | "status" => "ERROR", 30 | "response" => "Unable to write to input file", 31 | ] ) ); 32 | } 33 | 34 | $speech_script = __DIR__ . "/../speech/generate.py"; 35 | 36 | exec( "python3 " . escapeshellarg( $speech_script ) . " " . escapeshellarg( $input_file ) . " " . escapeshellarg( $output_file ) . " " . escapeshellarg( $settings['elevenlabs_api_key'] ), $output, $result_code ); 37 | 38 | unlink( $input_file ); 39 | 40 | if( ! file_exists( $output_file ) ) { 41 | die( json_encode( [ 42 | "status" => "ERROR", 43 | "response" => "Unable to create output file", 44 | "output" => implode( "\n", $output ), 45 | "result_code" => $result_code, 46 | ] ) ); 47 | } 48 | 49 | $b64_audio = base64_encode( file_get_contents( $output_file ) ); 50 | 51 | unlink( $output_file ); 52 | 53 | die( json_encode( [ 54 | "status" => "OK", 55 | "response" => $b64_audio, 56 | ] ) ); 57 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | elevenlabs 2 | -------------------------------------------------------------------------------- /sandbox/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8-bookworm 2 | 3 | WORKDIR /usr/src/app 4 | 5 | COPY requirements.txt ./ 6 | RUN pip install --no-cache-dir -r requirements.txt 7 | 8 | COPY run_code.sh ./ 9 | 10 | VOLUME /usr/src/app/data 11 | -------------------------------------------------------------------------------- /sandbox/requirements.txt: -------------------------------------------------------------------------------- 1 | requests 2 | Pillow 3 | matplotlib 4 | mpld3 5 | pandas 6 | numpy 7 | scipy 8 | scikit-learn 9 | dash 10 | bokeh 11 | streamlit 12 | beautifulsoup4 13 | lxml 14 | ipywidgets 15 | -------------------------------------------------------------------------------- /sandbox/run_code.sh: -------------------------------------------------------------------------------- 1 | cat data/code.py | python3 -i 2 | -------------------------------------------------------------------------------- /settings.sample.php: -------------------------------------------------------------------------------- 1 | "", 14 | 15 | // add an optional system message here 16 | "system_message" => "", 17 | 18 | // model to use in OpenAI API 19 | "model" => "gpt-3.5-turbo", 20 | 21 | // custom parameters for ChatGPT 22 | "params" => [ 23 | //"temperature" => 0.9, 24 | //"max_tokens" => 256, 25 | ], 26 | 27 | // base uri of app (e.g. /my/app/path) 28 | "base_uri" => "", 29 | 30 | // storage type 31 | "storage_type" => "session", // session or sql 32 | 33 | // database settings (if using sql storage type) 34 | "db" => [ 35 | "dsn" => "sqlite:db/chatwtf.db", 36 | //"dsn" => "mysql:host=localhost;dbname=chatwtf", 37 | "username" => null, 38 | "password" => null, 39 | ], 40 | 41 | // CodeInterpreter settings 42 | "code_interpreter" => [ 43 | "enabled" => false, 44 | "sandbox" => [ 45 | "enabled" => false, 46 | "container" => "chatwtf-sandbox", 47 | ] 48 | ], 49 | 50 | // ElevenLabs settings 51 | "elevenlabs_api_key" => "", 52 | "speech_enabled" => false, 53 | ]; 54 | -------------------------------------------------------------------------------- /speech/generate.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from elevenlabs.client import ElevenLabs 4 | from elevenlabs import save 5 | import sys 6 | import os 7 | 8 | def print_usage(): 9 | print("USAGE: generate.py INPUT_FILE OUTPUT_FILE [API_KEY]") 10 | sys.exit(1) 11 | 12 | if len(sys.argv) < 2: 13 | print("ERROR: No input file provided") 14 | print_usage() 15 | 16 | if len(sys.argv) < 3: 17 | print("ERROR: No output file provided") 18 | print_usage() 19 | 20 | if len(sys.argv) < 4: 21 | elevenlabs_api_key = os.getenv("ELEVENLABS_API_KEY") 22 | else: 23 | elevenlabs_api_key = sys.argv[3] 24 | 25 | if not elevenlabs_api_key: 26 | print("ERROR: No API key provided") 27 | print_usage() 28 | 29 | client = ElevenLabs( 30 | api_key=elevenlabs_api_key, 31 | ) 32 | 33 | input_file = sys.argv[1] 34 | output_file = sys.argv[2] 35 | 36 | with open(input_file, "r") as f: 37 | input_text = f.read() 38 | 39 | audio = client.generate( 40 | text=input_text, 41 | ) 42 | 43 | save(audio, output_file) 44 | -------------------------------------------------------------------------------- /src/Assistant.php: -------------------------------------------------------------------------------- 1 | id; 13 | } 14 | 15 | public function get_functions() { 16 | $functions = array_filter( $this->tools, function( $tool ) { 17 | return $tool->type === "function"; 18 | } ); 19 | 20 | return array_map( fn( $tool ) => json_decode( json_encode( $tool->function ), true ), $functions ); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/ChatGPT.php: -------------------------------------------------------------------------------- 1 | $messages */ 14 | protected array $messages = []; 15 | protected array $functions = []; 16 | protected $savefunction = null; 17 | protected $loadfunction = null; 18 | protected bool $loaded = false; 19 | protected $tool_choice = "auto"; 20 | protected string $model = "gpt-3.5-turbo"; 21 | protected array $params = []; 22 | protected bool $assistant_mode = false; 23 | protected ?Assistant $assistant = null; 24 | protected ?string $thread_id = null; 25 | protected ?Run $run = null; 26 | 27 | public function __construct( 28 | protected string $api_key, 29 | protected ?string $chat_id = null 30 | ) { 31 | if( $this->chat_id === null ) { 32 | $this->chat_id = uniqid( more_entropy: true ); 33 | } 34 | } 35 | 36 | public function load() { 37 | if( is_callable( $this->loadfunction ) ) { 38 | $this->messages = ($this->loadfunction)( $this->chat_id ); 39 | $this->loaded = true; 40 | } 41 | } 42 | 43 | public function assistant_mode( bool $enabled ) { 44 | $this->assistant_mode = $enabled; 45 | } 46 | 47 | public function set_assistant( Assistant|string $assistant ) { 48 | if( is_string( $assistant ) ) { 49 | $this->assistant = $this->fetch_assistant( $assistant ); 50 | } else { 51 | $this->assistant = $assistant; 52 | } 53 | } 54 | 55 | public function set_thread( Thread|string $thread ) { 56 | if( is_string( $thread ) ) { 57 | $this->thread_id = $thread; 58 | } else { 59 | $this->thread_id = $thread->get_id(); 60 | } 61 | } 62 | 63 | public function set_model( string $model ) { 64 | $this->model = $model; 65 | } 66 | 67 | public function get_model() { 68 | return $this->model; 69 | } 70 | 71 | public function set_param( string $param, $value ) { 72 | $this->params[$param] = $value; 73 | } 74 | 75 | public function set_params( array $params ) { 76 | $this->params = $params; 77 | } 78 | 79 | public function get_params() { 80 | return $this->params; 81 | } 82 | 83 | public function version() { 84 | preg_match( "/gpt-(([0-9]+)\.?([0-9]+)?)/", $this->model, $matches ); 85 | return floatval( $matches[1] ); 86 | } 87 | 88 | public function force_tool_choice( array|string $tool_choice ) { 89 | $this->tool_choice = $tool_choice; 90 | } 91 | 92 | public function smessage( string $system_message ) { 93 | $message = [ 94 | "role" => "system", 95 | "content" => $system_message, 96 | ]; 97 | 98 | $this->messages[] = $message; 99 | 100 | if( is_callable( $this->savefunction ) ) { 101 | ($this->savefunction)( (object) $message, $this->chat_id ); 102 | } 103 | } 104 | 105 | public function umessage( string $user_message ) { 106 | $message = new stdClass; 107 | $message->role = "user"; 108 | $message->content = $user_message; 109 | 110 | $this->messages[] = $message; 111 | 112 | if( $this->assistant_mode ) { 113 | $this->add_assistants_message( $message ); 114 | } 115 | 116 | if( is_callable( $this->savefunction ) ) { 117 | ($this->savefunction)( $message, $this->chat_id ); 118 | } 119 | } 120 | 121 | /** 122 | * Add an assistant message to the context 123 | * 124 | * @param ?string $assistant_message The text content of the message or null for tool calls 125 | * @param array $tool_calls An array of tool calls in the OpenAI API format 126 | * 127 | * @return void 128 | */ 129 | public function amessage( 130 | ?string $assistant_message = null, 131 | ?array $tool_calls = null 132 | ): void { 133 | $message = new stdClass; 134 | $message->role = "assistant"; 135 | $message->content = $assistant_message; 136 | 137 | if( $tool_calls ) { 138 | $message->tool_calls = $tool_calls; 139 | } 140 | 141 | $this->messages[] = $message; 142 | 143 | if( is_callable( $this->savefunction ) ) { 144 | ($this->savefunction)( $message, $this->chat_id ); 145 | } 146 | } 147 | 148 | public function fresult( 149 | string $tool_call_id, 150 | string $function_return_value 151 | ) { 152 | $message = new stdClass; 153 | $message->role = "tool"; 154 | $message->content = $function_return_value; 155 | $message->tool_call_id = $tool_call_id; 156 | 157 | $this->messages[] = $message; 158 | 159 | if( is_callable( $this->savefunction ) ) { 160 | ($this->savefunction)( $message, $this->chat_id ); 161 | } 162 | } 163 | 164 | public function assistant_response( 165 | bool $raw_function_response = false, 166 | ?StreamType $stream_type = null, 167 | ) { 168 | if( $this->run?->get_status() !== "requires_action" ) { 169 | $this->run = $this->create_run( 170 | thread_id: $this->thread_id, 171 | assistant_id: $this->assistant->get_id(), 172 | ); 173 | } 174 | 175 | while( true ) { 176 | usleep( 1000*100 ); 177 | 178 | $this->run = $this->fetch_run( 179 | thread_id: $this->thread_id, 180 | run_id: $this->run->get_id() 181 | ); 182 | 183 | if( ! $this->run->is_pending() ) { 184 | break; 185 | } 186 | } 187 | 188 | if( $this->run->get_status() === "requires_action" ) { 189 | $required_action = $this->run->get_required_action(); 190 | 191 | if( $required_action->type !== "submit_tool_outputs" ) { 192 | throw new \Exception( "Unrecognized required action type '".$required_action->type."'" ); 193 | } 194 | 195 | $message = new stdClass; 196 | $message->role = "assistant"; 197 | $message->content = null; 198 | $message->tool_calls = $required_action->submit_tool_outputs->tool_calls; 199 | } else { 200 | $messages = $this->get_thread_messages( 201 | thread_id: $this->thread_id, 202 | limit: 1, 203 | order: "desc", 204 | ); 205 | 206 | $message = new stdClass; 207 | $message->role = $messages[0]->role; 208 | $message->content = $messages[0]->content[0]->text->value; 209 | } 210 | 211 | $message = $this->handle_functions( $message, $raw_function_response, stream_type: $stream_type ); 212 | 213 | return $message; 214 | } 215 | 216 | public function response( 217 | bool $raw_function_response = false, 218 | ?StreamType $stream_type = null, 219 | ) { 220 | if( $this->assistant_mode ) { 221 | return $this->assistant_response( 222 | $raw_function_response, 223 | $stream_type, // TODO: streaming is not supported yet 224 | ); 225 | } 226 | 227 | $params = [ 228 | "model" => $this->model, 229 | "messages" => $this->messages, 230 | ]; 231 | 232 | $params = array_merge( $params, $this->params ); 233 | 234 | $functions = $this->get_functions(); 235 | 236 | if( ! empty( $functions ) ) { 237 | $params["tools"] = $functions; 238 | $params["tool_choice"] = $this->tool_choice; 239 | } 240 | 241 | // make ChatGPT API request 242 | $ch = curl_init( "https://api.openai.com/v1/chat/completions" ); 243 | curl_setopt( $ch, CURLOPT_HTTPHEADER, [ 244 | "Content-Type: application/json", 245 | "Authorization: Bearer " . $this->api_key 246 | ] ); 247 | 248 | curl_setopt( $ch, CURLOPT_POST, true ); 249 | curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true ); 250 | 251 | if( $stream_type ) { 252 | $params["stream"] = true; 253 | 254 | $response_text = ""; 255 | $partial_data = ""; 256 | $functions = []; 257 | 258 | curl_setopt( $ch, CURLOPT_WRITEFUNCTION, function( $ch, $data ) use ( &$response_text, &$partial_data, &$functions, $stream_type ) { 259 | $response_text .= $this->parse_stream_data( $ch, $data, $stream_type, $partial_data, $functions ); 260 | 261 | if( connection_aborted() ) { 262 | return 0; 263 | } 264 | 265 | return strlen( $data ); 266 | } ); 267 | } 268 | 269 | curl_setopt( $ch, CURLOPT_POSTFIELDS, json_encode( 270 | $params 271 | ) ); 272 | 273 | $curl_exec = curl_exec( $ch ); 274 | 275 | // get ChatGPT reponse 276 | if( $stream_type ) { 277 | $message = new stdClass; 278 | $message->role = "assistant"; 279 | $message->content = $response_text; 280 | 281 | if( count( $functions ) ) { 282 | $message->tool_calls = $functions; 283 | } else { 284 | if( $stream_type === StreamType::Event ) { 285 | echo "event: stop\n"; 286 | echo "data: stopped\n\n"; 287 | } 288 | } 289 | } else { 290 | $response = json_decode( $curl_exec ); 291 | 292 | // somewhat handle errors 293 | if( ! isset( $response->choices[0]->message ) ) { 294 | if( isset( $response->error ) ) { 295 | $error = trim( $response->error->message . " (" . $response->error->type . ")" ); 296 | } else { 297 | $error = $curl_exec; 298 | } 299 | throw new \Exception( "Error in OpenAI request: " . $error ); 300 | } 301 | 302 | // add response to messages 303 | $message = $response->choices[0]->message; 304 | } 305 | 306 | $this->messages[] = $message; 307 | 308 | if( is_callable( $this->savefunction ) ) { 309 | ($this->savefunction)( $message, $this->chat_id ); 310 | } 311 | 312 | $message = end( $this->messages ); 313 | 314 | $message = $this->handle_functions( $message, $raw_function_response, stream_type: $stream_type ); 315 | 316 | return $message; 317 | } 318 | 319 | public function stream( StreamType $stream_type ) { 320 | while( ob_get_level() ) ob_end_flush(); 321 | return $this->response( stream_type: $stream_type ); 322 | } 323 | 324 | protected function parse_stream_data( CurlHandle $ch, string $data, StreamType $stream_type, string &$partial_data, array &$functions ): string { 325 | $json = json_decode( $data ); 326 | 327 | if( isset( $json->error ) ) { 328 | $error = $json->error->message; 329 | $error .= " (" . $json->error->code . ")"; 330 | $error = "`" . trim( $error ) . "`"; 331 | 332 | if( $stream_type == StreamType::Event ) { 333 | echo "data: " . json_encode( ["content" => $error] ) . "\n\n"; 334 | 335 | echo "event: stop\n"; 336 | echo "data: stopped\n\n"; 337 | } elseif( $stream_type == StreamType::Plain ) { 338 | echo $error; 339 | } else { 340 | echo $data; 341 | } 342 | 343 | flush(); 344 | die(); 345 | } 346 | 347 | $response_text = ""; 348 | 349 | $deltas = explode( "\n\n", $data ); 350 | 351 | foreach( $deltas as $delta ) { 352 | $partial_data .= $delta; 353 | 354 | try { 355 | $json = json_decode( substr( $partial_data, 6 ), flags: JSON_THROW_ON_ERROR ); 356 | $partial_data = ""; 357 | } catch( JsonException $e ) { 358 | continue; 359 | } 360 | 361 | $content = ""; 362 | 363 | if( ! isset( $json->choices[0]->delta ) ) { 364 | error_log( "Invalid ChatGPT response: '" . $partial_data . "'" ); 365 | continue; 366 | } 367 | 368 | if( isset( $json->choices[0]->delta->tool_calls ) ) { 369 | foreach( $json->choices[0]->delta->tool_calls as $tool_call ) { 370 | if( ! isset( $functions[$tool_call->index] ) ) { 371 | $functions[$tool_call->index] = new stdClass; 372 | $functions[$tool_call->index]->index = $tool_call->index; 373 | } 374 | 375 | if( isset( $tool_call->id ) ) { 376 | if( ! isset( $functions[$tool_call->index]->id ) ) { 377 | $functions[$tool_call->index]->id = ""; 378 | } 379 | 380 | $functions[$tool_call->index]->id .= $tool_call->id; 381 | } 382 | 383 | if( isset( $tool_call->type ) ) { 384 | if( ! isset( $functions[$tool_call->index]->type ) ) { 385 | $functions[$tool_call->index]->type = ""; 386 | } 387 | 388 | $functions[$tool_call->index]->type .= $tool_call->type; 389 | } 390 | 391 | if( ! isset( $functions[$tool_call->index]->function ) ) { 392 | $functions[$tool_call->index]->function = new stdClass; 393 | } 394 | 395 | if( isset( $tool_call->function->name ) ) { 396 | if( ! isset( $functions[$tool_call->index]->function->name ) ) { 397 | $functions[$tool_call->index]->function->name = ""; 398 | } 399 | 400 | $functions[$tool_call->index]->function->name .= $tool_call->function->name; 401 | } 402 | 403 | if( isset( $tool_call->function->arguments ) ) { 404 | if( ! isset( $functions[$tool_call->index]->function->arguments ) ) { 405 | $functions[$tool_call->index]->function->arguments = ""; 406 | } 407 | 408 | $functions[$tool_call->index]->function->arguments .= $tool_call->function->arguments; 409 | } 410 | } 411 | } 412 | 413 | if( isset( $json->choices[0]->delta->content ) ) { 414 | $content = $json->choices[0]->delta->content; 415 | } 416 | 417 | $response_text .= $content; 418 | 419 | if( $stream_type == StreamType::Event ) { 420 | echo "data: " . json_encode( ["content" => $content] ) . "\n\n"; 421 | } elseif( $stream_type == StreamType::Plain ) { 422 | echo $content; 423 | } else { 424 | echo $data; 425 | } 426 | 427 | flush(); 428 | } 429 | 430 | return $response_text; 431 | } 432 | 433 | protected function handle_functions( stdClass $message, bool $raw_function_response = false, ?StreamType $stream_type = null ) { 434 | if( isset( $message->tool_calls ) ) { 435 | $function_calls = array_filter( 436 | $message->tool_calls, 437 | fn( $tool_call ) => $tool_call->type === "function" 438 | ); 439 | 440 | if( $raw_function_response ) { 441 | // for backwards compatibility 442 | if( count( $function_calls ) === 1 ) { 443 | $message->function_call = $function_calls[0]->function; 444 | } 445 | 446 | return $message; 447 | } 448 | 449 | $tool_outputs = []; 450 | 451 | foreach( $function_calls as $tool_call ) { 452 | // get function name and arguments 453 | $function_call = $tool_call->function; 454 | $function_name = $function_call->name; 455 | $arguments = json_decode( $function_call->arguments, true ); 456 | 457 | // sometimes ChatGPT responds with only a string of the 458 | // first argument instead of a JSON object 459 | if( $arguments === null ) { 460 | $arguments = [$function_call->arguments]; 461 | } 462 | 463 | $callable = $this->get_function( $function_name ); 464 | 465 | if( is_callable( $callable ) ) { 466 | $result = $callable( ...array_values( $arguments ) ); 467 | } else { 468 | $result = "Function '$function_name' unavailable."; 469 | } 470 | 471 | $tool_outputs[$tool_call->id] = $result; 472 | 473 | $this->fresult( $tool_call->id, $result ); 474 | } 475 | 476 | if( $this->assistant_mode ) { 477 | $this->submit_tool_outputs( 478 | $this->thread_id, 479 | $this->run->get_id(), 480 | $tool_outputs, 481 | ); 482 | } 483 | 484 | return $this->response( stream_type: $stream_type ); 485 | } 486 | 487 | return $message; 488 | } 489 | 490 | protected function get_function( string $function_name ): callable|false { 491 | if( $this->assistant_mode ) { 492 | $functions = $this->assistant->get_functions(); 493 | } else { 494 | $functions = $this->functions; 495 | } 496 | 497 | foreach( $functions as $function ) { 498 | if( $function["name"] === $function_name ) { 499 | return $function["function"] ?? $function["name"]; 500 | } 501 | } 502 | 503 | return false; 504 | } 505 | 506 | protected function get_functions( ?array $function_list = null ) { 507 | $tools = []; 508 | 509 | if( $function_list === null ) { 510 | $function_list = $this->functions; 511 | } 512 | 513 | foreach( $function_list as $function ) { 514 | $properties = []; 515 | $required = []; 516 | 517 | foreach( $function["parameters"] as $parameter ) { 518 | $properties[$parameter['name']] = [ 519 | "type" => $parameter['type'], 520 | "description" => $parameter['description'], 521 | ]; 522 | 523 | if( isset( $parameter["items"] ) ) { 524 | $properties[$parameter['name']]["items"] = $parameter["items"]; 525 | } 526 | 527 | if( array_key_exists( "required", $parameter ) && $parameter["required"] !== false ) { 528 | $required[] = $parameter["name"]; 529 | } 530 | } 531 | 532 | $tools[] = [ 533 | "type" => "function", 534 | "function" => [ 535 | "name" => $function["name"], 536 | "description" => $function["description"], 537 | "parameters" => [ 538 | "type" => "object", 539 | "properties" => $properties, 540 | "required" => $required, 541 | ], 542 | ], 543 | ]; 544 | } 545 | 546 | return $tools; 547 | } 548 | 549 | public function add_function( array|callable $function ) { 550 | if( is_callable( $function, true ) ) { 551 | $function = $this->parse_function( $function ); 552 | 553 | if( ! is_callable( $function['function'] ) ) { 554 | throw new \Exception( "Function must be callable (public)" ); 555 | } 556 | } 557 | $this->functions[] = $function; 558 | } 559 | 560 | protected function parse_function( array|callable $function ) { 561 | if( is_array( $function ) ) { 562 | if( ! is_callable( $function, true ) ) { 563 | throw new \Exception( "Invalid class method provided" ); 564 | } 565 | 566 | $reflection = new ReflectionMethod( ...$function ); 567 | } else { 568 | $reflection = new ReflectionFunction( $function ); 569 | } 570 | 571 | $doc_comment = $reflection->getDocComment() ?: ""; 572 | $description = $this->parse_description( $doc_comment ); 573 | 574 | $function_data = [ 575 | "function" => $function, 576 | "name" => $reflection->getName(), 577 | "description" => $description, 578 | "parameters" => [], 579 | ]; 580 | 581 | $matches = []; 582 | preg_match_all( '/@param\s+(\S+)\s+\$(\S+)[^\S\r\n]?([^\r\n]+)?/', $doc_comment, $matches ); 583 | 584 | $types = $matches[1]; 585 | $names = $matches[2]; 586 | $descriptions = $matches[3]; 587 | 588 | $params = $reflection->getParameters(); 589 | foreach( $params as $param ) { 590 | $name = $param->getName(); 591 | $index = array_search( $name, $names ); 592 | $description = $descriptions[$index] ?? ""; 593 | $type = $param->getType()?->getName() ?? $types[$index] ?? "string"; 594 | 595 | try { 596 | $param->getDefaultValue(); 597 | $required = false; 598 | } catch( \ReflectionException $e ) { 599 | $required = true; 600 | } 601 | 602 | $data = [ 603 | "name" => $name, 604 | "type" => $this->parse_type( $type ), 605 | "description" => $description, 606 | "required" => $required, 607 | ]; 608 | 609 | if( strpos( $type, "array<" ) === 0 ) { 610 | $array_type = trim( substr( $type, 5 ), "<>" ); 611 | $data["type"] = "array"; 612 | $data["items"] = [ 613 | "type" => $this->parse_type( $array_type ), 614 | ]; 615 | } 616 | 617 | if( strpos( $type, "[]" ) !== false ) { 618 | $array_type = substr( $type, 0, -2 ); 619 | $data["type"] = "array"; 620 | $data["items"] = [ 621 | "type" => $this->parse_type( $array_type ), 622 | ]; 623 | } 624 | 625 | $function_data["parameters"][] = $data; 626 | } 627 | 628 | return $function_data; 629 | } 630 | 631 | protected function parse_type( string $type ) { 632 | return match( $type ) { 633 | "int" => "number", 634 | "integer" => "number", 635 | "string" => "string", 636 | "float" => "number", 637 | default => "string", 638 | }; 639 | } 640 | 641 | protected function parse_description( string $doc_comment ) { 642 | $lines = explode( "\n", $doc_comment ); 643 | $description = ""; 644 | 645 | $started = false; 646 | foreach( $lines as $line ) { 647 | $matches = []; 648 | if( preg_match( '/\s+?\*\s+?([^@](.*?))?$/', $line, $matches ) === 1 ) { 649 | $description .= ($matches[1] ?? "") . "\n"; 650 | $started = true; 651 | } elseif( $started ) { 652 | break; 653 | } 654 | } 655 | 656 | return trim( $description ); 657 | } 658 | 659 | public function messages() { 660 | return $this->messages; 661 | } 662 | 663 | public function loadfunction( callable $loadfunction, bool $autoload = true ) { 664 | $this->loadfunction = $loadfunction; 665 | if( $autoload && ! $this->loaded ) { 666 | $this->load(); 667 | } 668 | } 669 | 670 | public function savefunction( callable $savefunction ) { 671 | $this->savefunction = $savefunction; 672 | } 673 | 674 | protected function openai_api_post( 675 | string $url, 676 | string|array $postfields = "", 677 | array $extra_headers = [], 678 | bool $post = true, 679 | ): stdClass { 680 | $ch = curl_init( $url ); 681 | 682 | $headers = [ 683 | "Content-Type: application/json", 684 | "Authorization: Bearer " . $this->api_key, 685 | ...$extra_headers, 686 | ]; 687 | 688 | curl_setopt( $ch, CURLOPT_HTTPHEADER, $headers ); 689 | 690 | curl_setopt( $ch, CURLOPT_POST, $post ); 691 | curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true ); 692 | 693 | if( $post ) { 694 | curl_setopt( $ch, CURLOPT_POSTFIELDS, $postfields ); 695 | } 696 | 697 | $response = curl_exec( $ch ); 698 | 699 | curl_close( $ch ); 700 | 701 | $data = json_decode( $response ); 702 | 703 | if( ! isset( $data->id ) && ! isset( $data->data ) ) { 704 | if( isset( $data->error ) ) { 705 | throw new \Exception( "Error in OpenAI request: " . $data->error->message ); 706 | } 707 | 708 | throw new \Exception( "Error in OpenAI request: " . $data ); 709 | } 710 | 711 | return $data; 712 | } 713 | 714 | public function create_assistant( 715 | string $model, 716 | string $name = "", 717 | string $instructions = "", 718 | array $functions = [], 719 | ): Assistant { 720 | foreach( $functions as $i => $function ) { 721 | $functions[$i] = $this->parse_function( $function ); 722 | } 723 | 724 | $tools = $this->get_functions( $functions ); 725 | 726 | $response = $this->openai_api_post( 727 | url: "https://api.openai.com/v1/assistants", 728 | extra_headers: ["OpenAI-Beta: assistants=v1"], 729 | postfields: json_encode( [ 730 | "model" => $model, 731 | "name" => $name, 732 | "instructions" => $instructions, 733 | "tools" => $tools, 734 | ] ) 735 | ); 736 | 737 | return new Assistant( 738 | name: $response->name, 739 | model: $response->model, 740 | tools: $response->tools, 741 | id: $response->id, 742 | ); 743 | } 744 | 745 | public function create_thread(): Thread { 746 | $response = $this->openai_api_post( 747 | url: "https://api.openai.com/v1/threads", 748 | extra_headers: ["OpenAI-Beta: assistants=v1"], 749 | ); 750 | 751 | return new Thread( 752 | id: $response->id, 753 | ); 754 | } 755 | 756 | public function create_run( 757 | string $thread_id, 758 | string $assistant_id, 759 | ): Run { 760 | $response = $this->openai_api_post( 761 | url: "https://api.openai.com/v1/threads/".$thread_id."/runs", 762 | extra_headers: ["OpenAI-Beta: assistants=v1"], 763 | postfields: json_encode( [ 764 | "assistant_id" => $assistant_id, 765 | ] ) 766 | ); 767 | 768 | return new Run( 769 | thread_id: $thread_id, 770 | required_action: $response->required_action ?? null, 771 | status: $response->status, 772 | id: $response->id, 773 | ); 774 | } 775 | 776 | public function fetch_run( 777 | string $thread_id, 778 | string $run_id, 779 | ): Run { 780 | $response = $this->openai_api_post( 781 | url: "https://api.openai.com/v1/threads/" . $thread_id . "/runs/" . $run_id, 782 | extra_headers: ["OpenAI-Beta: assistants=v1"], 783 | post: false, 784 | ); 785 | 786 | return new Run( 787 | thread_id: $thread_id, 788 | required_action: $response->required_action ?? null, 789 | status: $response->status, 790 | id: $response->id, 791 | ); 792 | } 793 | 794 | public function fetch_assistant( string $assistant_id ): Assistant { 795 | $response = $this->openai_api_post( 796 | url: "https://api.openai.com/v1/assistants/" . $assistant_id, 797 | extra_headers: ["OpenAI-Beta: assistants=v1"], 798 | post: false, 799 | ); 800 | 801 | return new Assistant( 802 | model: $response->model, 803 | id: $response->id, 804 | tools: $response->tools, 805 | name: $response->name, 806 | ); 807 | } 808 | 809 | public function get_thread_messages( 810 | string $thread_id, 811 | int $limit, 812 | string $order = "asc", 813 | ): array { 814 | $response = $this->openai_api_post( 815 | url: "https://api.openai.com/v1/threads/" . $thread_id . "/messages?limit=" . $limit . "&order=" . $order, 816 | extra_headers: ["OpenAI-Beta: assistants=v1"], 817 | post: false, 818 | ); 819 | 820 | return $response->data; 821 | } 822 | 823 | public function add_assistants_message( 824 | stdClass $message, 825 | ): void { 826 | $this->openai_api_post( 827 | url: "https://api.openai.com/v1/threads/" . $this->thread_id . "/messages", 828 | extra_headers: ["OpenAI-Beta: assistants=v1"], 829 | postfields: json_encode( [ 830 | "role" => $message->role, 831 | "content" => $message->content, 832 | ] ) 833 | ); 834 | } 835 | 836 | public function submit_tool_outputs( 837 | string $thread_id, 838 | string $run_id, 839 | array $tool_call_outputs, 840 | ): void { 841 | $tool_outputs = []; 842 | 843 | foreach( $tool_call_outputs as $tool_call_id => $tool_call_output ) { 844 | $tool_outputs[] = [ 845 | "tool_call_id" => $tool_call_id, 846 | "output" => $tool_call_output, 847 | ]; 848 | } 849 | 850 | $this->openai_api_post( 851 | url: "https://api.openai.com/v1/threads/".$thread_id."/runs/".$run_id."/submit_tool_outputs", 852 | extra_headers: ["OpenAI-Beta: assistants=v1"], 853 | postfields: json_encode( [ 854 | "tool_outputs" => $tool_outputs 855 | ] ) 856 | ); 857 | } 858 | } 859 | -------------------------------------------------------------------------------- /src/CodeInterpreter.php: -------------------------------------------------------------------------------- 1 | settings = require( __DIR__ . "/../settings.php" ); 11 | 12 | $chat_id = Uuid::sanitize( $chat_id ); 13 | 14 | $this->chat_dir = "data/" . $chat_id; 15 | $this->data_dir = $this->chat_dir . "/data"; 16 | 17 | $chat_dir = $this->chat_dir; 18 | $data_dir = $this->data_dir; 19 | 20 | $abs_data_dir = __DIR__ . "/../" . $data_dir; 21 | if( ! file_exists( $abs_data_dir ) ) { 22 | mkdir( $abs_data_dir, 0777, true ); 23 | } 24 | 25 | register_shutdown_function( function() use ( $data_dir, $chat_dir ) { 26 | if( count( glob( $data_dir . "/*" ) ) === 0 ) { 27 | if( is_dir( $data_dir ) ) { 28 | rmdir( $data_dir ); 29 | rmdir( $chat_dir ); 30 | } 31 | } 32 | } ); 33 | } 34 | 35 | public function init_chatgpt( ChatGPT $chatgpt ) { 36 | $chatgpt->smessage( "You are an AI assistant that can read files and run Python code in order to answer the user's question. You can access a folder called 'data/' from the Python code to read or write files (e.g. plt.savefig('data/filename.png')). Always save visualizations and charts into a file and add them to your response. When creating links to files in the data directory in your response, use the format [link text](data/filename). When the task requires to process or read user provided data from files, always read the file content first, before running Python code. Don't assume the contents of files. When processing CSV files, read the file first before writing any Python code. You can also use Python code to download files or images from URLs. Note that Python code will always be run in an isolated environment, without access to variables from previous code. You can include images in your response with the format '![image name](data/image_filename.jpg)'. Include visualizations as images in your response. Don't repeat the Python code in your confirmation answer." ); 37 | $chatgpt->add_function( [$this, "python"] ); 38 | 39 | // TODO: make this work with function call streaming 40 | //$chatgpt->add_function( [$this, "read_file_contents"] ); 41 | 42 | return $chatgpt; 43 | } 44 | 45 | /** 46 | * Parses the code from ChatGPT arguments 47 | * 48 | * @param string $arguments The arguments from ChatGPT 49 | * @return string the code 50 | */ 51 | public static function parse_arguments( string $arguments ): string { 52 | $args = json_decode( $arguments ); 53 | 54 | if( $args === null ) { 55 | $code = $arguments; 56 | } else { 57 | if( isset( $args->code ) ) { 58 | $code = $args->code; 59 | } else { 60 | $code = ""; 61 | } 62 | } 63 | 64 | $code = self::fix_code_hallucinations( $code ); 65 | 66 | return $code; 67 | } 68 | 69 | public static function fix_code_hallucinations( string $code ) { 70 | // fix ChatGPT hallucinations 71 | if( str_contains( $code, '"code": "' ) ) { 72 | error_log( "NOTICE: Fixing ChatGPT hallucinated arguments" ); 73 | 74 | $code = explode( '"code": "', $code, 2 ); 75 | $code = trim( $code[1] ); 76 | $code = trim( rtrim( $code, '}' ) ); 77 | $code = trim( rtrim( $code, '"' ) ); 78 | 79 | // convert "\n" to newline 80 | $code = str_replace( '\n', "\n", $code ); 81 | } 82 | 83 | return $code; 84 | } 85 | 86 | public static function parse_result( string $json_python_result ) { 87 | $result = json_decode( $json_python_result ); 88 | 89 | // TODO: handle detection of errors better 90 | if( str_contains( $result->output, "Traceback" ) ) { 91 | return $result->output; 92 | } else { 93 | // TODO: Handle getting output of code better 94 | $lines = explode( ">>>", $result->output ); 95 | 96 | $last_output = ""; 97 | 98 | foreach( $lines as $line ) { 99 | $line = trim( $line ); 100 | 101 | if( ! empty( $line ) ) { 102 | $last_output = $line; 103 | } 104 | } 105 | return $last_output; 106 | } 107 | } 108 | 109 | public function is_windows(): bool { 110 | return stripos( PHP_OS, "win" ) === 0; 111 | } 112 | 113 | /** 114 | * Determines the Python command to run based on the 115 | * operating system or the settings 116 | * 117 | * @return string The python command 118 | */ 119 | public function get_python_command(): string { 120 | if( isset( $this->settings['python_command'] ) ) { 121 | return $this->settings['python_command']; 122 | } 123 | 124 | if( $this->is_windows() ) { 125 | return "python"; 126 | } 127 | 128 | return "python3"; 129 | } 130 | 131 | public function run_python_code( string $code ): PythonResult { 132 | $output = []; 133 | $result_code = NULL; 134 | 135 | $data_dir_full_path = __DIR__ . "/../" . $this->data_dir; 136 | $sandbox_settings = $this->settings['code_interpreter']['sandbox']; 137 | $code_file_path = $data_dir_full_path . "/code.py"; 138 | $code_file_path = str_replace( "/", DIRECTORY_SEPARATOR, $code_file_path ); 139 | 140 | if( file_put_contents( $code_file_path, $code ) === false ) { 141 | throw new \Exception( "Unable to write code file" ); 142 | } 143 | 144 | // TODO: create a way to run isolated Python code even 145 | // when the app is running inside a Docker container 146 | if( ($sandbox_settings['enabled'] ?? false) === true ) { 147 | if( ! isset( $sandbox_settings['container'] ) ) { 148 | throw new \Exception( "Container name missing from settings" ); 149 | } 150 | 151 | $container_name = $sandbox_settings["container"]; 152 | 153 | exec( "docker run -i --rm -v " . escapeshellarg( $data_dir_full_path ) . ":/usr/src/app/data " . escapeshellarg( $container_name ) . " bash run_code.sh 2>&1", $output, $result_code ); 154 | } else { 155 | $cmd_separator = $this->is_windows() ? "&" : ";"; 156 | 157 | exec( "cd " . escapeshellarg( $this->chat_dir ) . " ".$cmd_separator." " . $this->get_python_command() . " " . escapeshellarg( $code_file_path ) . " 2>&1", $output, $result_code ); 158 | } 159 | 160 | if( file_exists( $code_file_path ) ) { 161 | unlink( $code_file_path ); 162 | } 163 | 164 | return new PythonResult( 165 | output: implode( "\n", $output ), 166 | result_code: $result_code, 167 | ); 168 | } 169 | 170 | public function get_filename( string $filename ): string { 171 | if( strpos( $filename, "data/" ) !== 0 ) { 172 | $filename = "data/" . $filename; 173 | } 174 | 175 | return $this->chat_dir . "/" . $filename; 176 | } 177 | 178 | /** 179 | * Read the contents of a file 180 | * 181 | * @param string $filename The name of the file to read 182 | * @param int $line_count How many lines to read (-1 = all lines) 183 | */ 184 | public function read_file_contents( string $filename, ?int $line_count = null ) { 185 | $filename = $this->get_filename( $filename ); 186 | 187 | if( $line_count === -1 ) { 188 | $line_count = null; 189 | } 190 | 191 | $how_many = $line_count === null ? "ALL": $line_count; 192 | 193 | error_log( "INFO: Reading " . $how_many . " lines from file: " . $filename ); 194 | 195 | if( ! file_exists( $filename ) ) { 196 | return ""; 197 | } 198 | 199 | if( ! is_readable( $filename ) ) { 200 | return ""; 201 | } 202 | 203 | // TODO: read lines more efficiently 204 | $lines = file( $filename ); 205 | 206 | if( $lines === false ) { 207 | return ""; 208 | } 209 | 210 | $lines = array_slice( $lines, 0, $line_count ); 211 | 212 | $contents = implode( "\n", $lines ); 213 | 214 | if( trim( $contents ) == "" ) { 215 | return ""; 216 | } 217 | 218 | return $contents; 219 | } 220 | 221 | /** 222 | * Run python code 223 | * 224 | * @param string $code The code to run. 225 | */ 226 | public function python( string $code ): string { 227 | $code = trim( $code ); 228 | 229 | $code = self::fix_code_hallucinations( $code ); 230 | 231 | $lines = explode( "\n", $code ); 232 | $row_count = count( $lines ); 233 | 234 | if( ! str_contains( $lines[$row_count-1], "print(" ) ) { 235 | $lines[$row_count-1] = "print(" . $lines[$row_count-1] . ")"; 236 | } 237 | 238 | $code = implode( "\n", $lines ); 239 | 240 | $result = $this->run_python_code( $code ); 241 | 242 | return json_encode( [ 243 | "output" => $result->output, 244 | "result_code" => $result->result_code, 245 | ] ); 246 | } 247 | 248 | // alias for ChatGPT hallucinations 249 | public function pythoncode( string $code ): string { 250 | error_log( "NOTICE: ChatGPT ran hallucinated 'pythoncode' function" ); 251 | return $this->python( $code ); 252 | } 253 | 254 | /** 255 | * Imitate streaming since we can't stream function calls 256 | * 257 | * @param string $text The text to stream 258 | */ 259 | public function fake_stream( string $text ): void { 260 | $chunks = str_split( $text, 42 ); 261 | 262 | foreach( $chunks as $chunk ) { 263 | echo "data: " . json_encode( ["content" => $chunk ] ) ."\n\n"; 264 | flush(); 265 | usleep( 1000 * 50 ); 266 | } 267 | } 268 | } 269 | -------------------------------------------------------------------------------- /src/ConversationInterface.php: -------------------------------------------------------------------------------- 1 | 6 | */ 7 | public function get_chats(): array; 8 | 9 | public function find( string $chat_id ): self|null; 10 | 11 | /** 12 | * @return array 13 | */ 14 | public function get_messages(): array; 15 | 16 | public function add_message( Message $message ): bool; 17 | 18 | public function set_id( string $id ); 19 | 20 | public function set_title( string $title ); 21 | 22 | public function set_mode( string $mode ); 23 | 24 | public function set_model( string $model ); 25 | 26 | public function get_id(); 27 | 28 | public function get_title(); 29 | 30 | public function get_mode(); 31 | 32 | public function get_model(); 33 | 34 | public function save(): string; 35 | 36 | public function delete(): void; 37 | } 38 | -------------------------------------------------------------------------------- /src/Message.php: -------------------------------------------------------------------------------- 1 | id; 13 | } 14 | 15 | public function is_pending(): bool { 16 | return in_array( $this->status, [ 17 | "cancelling", 18 | "in_progress", 19 | "queued", 20 | ] ); 21 | } 22 | 23 | public function get_status(): string { 24 | return $this->status; 25 | } 26 | 27 | public function get_required_action() { 28 | return $this->required_action; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/SQLConversation.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | public function get_chats(): array { 17 | $stmt = $this->db->query( "SELECT id, title, mode, model FROM conversations ORDER BY created_time DESC, id DESC" ); 18 | $chats = $stmt->fetchAll( PDO::FETCH_ASSOC ); 19 | 20 | $list = []; 21 | 22 | foreach( $chats as $data ) { 23 | $conversation = new self( $this->db ); 24 | $conversation->set_id( $data['id'] ); 25 | $conversation->set_title( $data['title'] ); 26 | $conversation->set_mode( $data['mode'] ); 27 | $conversation->set_model( $data['model'] ); 28 | 29 | $list[] = $conversation; 30 | } 31 | 32 | return $list; 33 | } 34 | 35 | public function find( string $chat_id ): self|null { 36 | $stmt = $this->db->prepare( "SELECT * FROM conversations WHERE id = :chat_id" ); 37 | $stmt->execute( [ 38 | ":chat_id" => $chat_id, 39 | ] ); 40 | 41 | $data = $stmt->fetch( PDO::FETCH_ASSOC ); 42 | 43 | if( empty( $data ) ) { 44 | return null; 45 | } 46 | 47 | $conversation = new self( $this->db ); 48 | $conversation->set_id( $data['id'] ); 49 | $conversation->set_title( $data['title'] ); 50 | $conversation->set_mode( $data['mode'] ); 51 | $conversation->set_model( $data['model'] ); 52 | 53 | return $conversation; 54 | } 55 | 56 | /** 57 | * @return array 58 | */ 59 | public function get_messages(): array { 60 | if( ! isset( $this->chat_id ) ) { 61 | return []; 62 | } 63 | 64 | $stmt = $this->db->prepare( "SELECT * FROM messages WHERE `conversation` = :chat_id" ); 65 | $stmt->execute( [ 66 | ":chat_id" => $this->chat_id, 67 | ] ); 68 | 69 | $messages = $stmt->fetchAll( PDO::FETCH_ASSOC ); 70 | 71 | $message_list = []; 72 | 73 | foreach( $messages as $message ) { 74 | $message_list[] = new Message( 75 | role: $message["role"], 76 | content: $message["content"], 77 | function_name: $message["function_name"], 78 | function_arguments: $message["function_arguments"], 79 | ); 80 | } 81 | 82 | return $message_list; 83 | } 84 | 85 | public function add_message( Message $message ): bool { 86 | $stmt = $this->db->prepare( " 87 | INSERT INTO messages ( 88 | `role`, 89 | `content`, 90 | `function_name`, 91 | `function_arguments`, 92 | `conversation`, 93 | `timestamp` 94 | ) VALUES ( 95 | :the_role, 96 | :the_content, 97 | :the_function_name, 98 | :the_function_arguments, 99 | :the_conversation, 100 | :the_timestamp 101 | )" 102 | ); 103 | $stmt->execute( [ 104 | ":the_role" => $message->role, 105 | ":the_content" => $message->content ?? "", // TODO: update database to allow NULL 106 | ":the_function_name" => $message->function_name, 107 | ":the_function_arguments" => $message->function_arguments, 108 | ":the_conversation" => $this->chat_id, 109 | ":the_timestamp" => date( "Y-m-d H:i:s" ), 110 | ] ); 111 | 112 | return true; 113 | } 114 | 115 | public function set_id( string $id ) { 116 | $this->chat_id = $id; 117 | } 118 | 119 | public function set_title( string $title ) { 120 | $this->title = $title; 121 | } 122 | 123 | public function set_mode( string $mode ) { 124 | $this->mode = $mode; 125 | } 126 | 127 | public function set_model( string $model ) { 128 | $this->model = $model; 129 | } 130 | 131 | public function get_id() { 132 | return $this->chat_id; 133 | } 134 | 135 | public function get_title() { 136 | return $this->title; 137 | } 138 | 139 | public function get_mode() { 140 | return $this->mode; 141 | } 142 | 143 | public function get_model() { 144 | return $this->model; 145 | } 146 | 147 | public function save(): string { 148 | if( ! isset( $this->chat_id ) ) { 149 | $stmt = $this->db->prepare( " 150 | INSERT INTO conversations ( 151 | id, 152 | title, 153 | mode, 154 | model 155 | ) VALUES ( 156 | :id, 157 | :title, 158 | :mode, 159 | :model 160 | )" 161 | ); 162 | 163 | $this->chat_id = Uuid::new(); 164 | 165 | $stmt->execute( [ 166 | ":id" => $this->chat_id, 167 | ":title" => $this->title, 168 | ":mode" => $this->mode, 169 | ":model" => $this->model, 170 | ] ); 171 | } else { 172 | $stmt = $this->db->prepare( "UPDATE conversations SET title = :title, mode = :mode, model = :model WHERE id = :chat_id LIMIT 1" ); 173 | $stmt->execute( [ 174 | ":title" => $this->title, 175 | ":mode" => $this->mode, 176 | ":model" => $this->model, 177 | ":chat_id" => $this->chat_id, 178 | ] ); 179 | } 180 | 181 | return $this->chat_id; 182 | } 183 | 184 | public function delete(): void { 185 | $stmt = $this->db->prepare( "DELETE FROM messages WHERE conversation = :chat_id" ); 186 | $stmt->execute([ 187 | ":chat_id" => $this->chat_id, 188 | ]); 189 | 190 | $stmt = $this->db->prepare( "DELETE FROM conversations WHERE id = :chat_id LIMIT 1" ); 191 | $stmt->execute([ 192 | ":chat_id" => $this->chat_id, 193 | ]); 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /src/SessionConversation.php: -------------------------------------------------------------------------------- 1 | 25 | */ 26 | public function get_chats(): array { 27 | self::init_session(); 28 | $chats = $_SESSION['chats'] ?? []; 29 | $chats = array_reverse( $chats ); 30 | 31 | $list = []; 32 | 33 | foreach( $chats as $data ) { 34 | $conversation = new self(); 35 | $conversation->set_id( $data['id'] ); 36 | $conversation->set_title( $data['title'] ); 37 | $conversation->set_mode( $data['mode'] ); 38 | $conversation->set_model( $data['model'] ); 39 | 40 | $list[] = $conversation; 41 | } 42 | 43 | return $list; 44 | } 45 | 46 | public function find( string $chat_id ): self|null { 47 | self::init_session(); 48 | $data = $_SESSION['chats'][$chat_id] ?? []; 49 | 50 | if( empty( $data ) ) { 51 | return null; 52 | } 53 | 54 | $conversation = new self(); 55 | $conversation->set_id( $data['id'] ); 56 | $conversation->set_title( $data['title'] ); 57 | $conversation->set_mode( $data['mode'] ); 58 | $conversation->set_model( $data['model'] ); 59 | 60 | return $conversation; 61 | } 62 | 63 | /** 64 | * @return array 65 | */ 66 | public function get_messages(): array { 67 | if( ! isset( $this->chat_id ) ) { 68 | return []; 69 | } 70 | 71 | return $_SESSION['chats'][$this->chat_id]["messages"] ?? []; 72 | } 73 | 74 | public function add_message( Message $message ): bool { 75 | $_SESSION['chats'][$this->chat_id]["messages"][] = $message; 76 | return true; 77 | } 78 | 79 | public function set_id( string $id ) { 80 | $this->chat_id = $id; 81 | } 82 | 83 | public function set_title( string $title ) { 84 | $this->title = $title; 85 | } 86 | 87 | public function set_mode( string $mode ) { 88 | $this->mode = $mode; 89 | } 90 | 91 | public function set_model( string $model ) { 92 | $this->model = $model; 93 | } 94 | 95 | public function get_id() { 96 | return $this->chat_id; 97 | } 98 | 99 | public function get_title() { 100 | return $this->title; 101 | } 102 | 103 | public function get_mode() { 104 | return $this->mode; 105 | } 106 | 107 | public function get_model() { 108 | return $this->model; 109 | } 110 | 111 | public function save(): string { 112 | if( ! isset( $this->chat_id ) ) { 113 | $this->chat_id = Uuid::new(); 114 | $_SESSION['chats'][$this->chat_id] = [ 115 | "id" => $this->chat_id, 116 | "title" => $this->title, 117 | "mode" => $this->mode, 118 | "model" => $this->model, 119 | "messages" => [], 120 | ]; 121 | } else { 122 | $_SESSION['chats'][$this->chat_id] = [ 123 | "id" => $this->chat_id, 124 | "title" => $this->title, 125 | "mode" => $this->mode, 126 | "model" => $this->model, 127 | "messages" => $this->get_messages(), 128 | ]; 129 | } 130 | 131 | return $this->chat_id; 132 | } 133 | 134 | public function delete(): void { 135 | unset( $_SESSION['chats'][$this->chat_id] ); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/Thread.php: -------------------------------------------------------------------------------- 1 | id; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/Uuid.php: -------------------------------------------------------------------------------- 1 |