├── .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 |
237 |
238 |
239 | ChatWTF
240 |
241 |
242 |
243 |
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 ''. 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 |