Premier commit

This commit is contained in:
2024-09-09 10:22:45 +02:00
commit bcc2604080
74 changed files with 25819 additions and 0 deletions

43
modules/site/Module.php Normal file
View File

@@ -0,0 +1,43 @@
<?php
namespace app\modules\site;
use Piko;
use Piko\Module\Event\CreateControllerEvent;
use Piko\Controller\Event\BeforeActionEvent;
class Module extends \Piko\Module
{
public function bootstrap()
{
Piko::setAlias('@vite_web', '/dev');
// Instanciate once i18n to setup the language config
$this->application->getComponent('Piko\I18n');
$user = $this->application->getComponent('Piko\User');
assert($user instanceof \Piko\User);
// Pass some parameters to the View component
$view = $this->application->getComponent('Piko\View');
$view->params['user'] = $user;
$view->params['language'] = $this->application->language;
$view->attachBehavior('vite', 'app\lib\Vite::vite');
$userModule = $this->application->getModule('user');
assert ($userModule instanceof \app\modules\user\Module);
$userModule->on(CreateControllerEvent::class, function(CreateControllerEvent $event) {
$event->controller->on(BeforeActionEvent::class, function (BeforeActionEvent $event) {
$action = $event->actionId;
switch($action) {
case 'login':
$event->controller->layout = 'minimal';
break;
}
});
});
}
}

View File

@@ -0,0 +1,338 @@
<?php
namespace app\modules\site\controllers;
use Piko\User;
use Piko\HttpException;
use Monolog\Logger;
use GuzzleHttp\Client;
use GuzzleHttp\Psr7\Utils;
use GuzzleHttp\Exception\RequestException;
use app\modules\site\models\Assistant;
use PDO;
class AssistantController extends \Piko\Controller
{
protected PDO $db;
protected User $user;
/**
* @var Logger
*/
private Logger $log;
protected function init(): void
{
$app = $this->module->getApplication();
$this->log = $app->getComponent(Logger::class);
assert($this->log instanceof Logger);
$this->user = $app->getComponent('Piko\User');
assert($this->user instanceof User);
$this->db = $app->getComponent('PDO');
assert($this->db instanceof PDO);
}
protected function getUserApiKey()
{
$identity = $this->user->getIdentity();
$apiKey = $identity->profil['api_key'] ?? null;
if ($apiKey === null) {
$this->log->error('API key not defined for user ID :' . $this->user->getId());
throw new HttpException(500, 'Internal error');
}
return $apiKey;
}
private function proxyRequest(string $method, string $endPoint, array $params = []): array
{
$client = new Client([
'base_uri' => getenv('PROXY_BASE_URL'),
]);
/*
$identity = $this->user->getIdentity();
$apiKey = $identity->profil['api_key'] ?? null;
if ($apiKey === null) {
$this->log->error('API key not defined for user ID :' . $this->user->getId());
throw new HttpException(500, 'Internal error');
}
*/
$apiKey = getenv('PROXY_MASTER_KEY');
$headers = [
'Authorization' => 'Bearer ' . $apiKey,
'Content-Type' => 'application/json',
];
try {
$response = $client->request($method, $endPoint, [
'headers' => $headers,
'json' => $params
]);
} catch (RequestException $e) {
$response = $e->getResponse();
$responseBody = (string) $response->getBody();
$contentType = $response->getHeader('Content-Type');
$errorCode = 500;
if ($contentType[0] == 'application/json') {
$data = json_decode($responseBody);
if (isset($data->error)) {
$responseBody = $data->error->message;
$errorCode = $data->error->code;
}
}
$this->log->error('Chat request error', ['request_body' => (string) $e->getRequest()->getBody()]);
$this->log->error('Chat response error', ['response_body' => $responseBody]);
throw new HttpException($errorCode, $responseBody);
}
$body = $response->getBody();
return [
'headers' => $response->getHeaders(),
'body' => (string) $body
];
}
private function getModels()
{
if (!isset($_SESSION['models'])) {
$models = [];
$response = $this->proxyRequest('GET', '/models');
if (isset($response['body'])) {
$body = json_decode($response['body'], true);
$data = $body['data'] ?? [];
foreach ($data as $obj) {
if (isset($obj['id'])) {
$models[] = $obj['id'];
}
}
}
$_SESSION['models'] = $models;
}
return $_SESSION['models'];
}
/**
* Stream de la réponse
*
* Pour désactiver l'output buffering:
* php -d output_buffering=0 -S localhost:8080 -t web
*
* @return void
*/
public function responseAction()
{
$identity = $this->user->getIdentity();
$client = new Client([
'base_uri' => getenv('PROXY_BASE_URL'),
'stream' => true,
]);
$headers = [
'Authorization' => 'Bearer ' . getenv('PROXY_KEY'),
'Content-Type' => 'application/json',
];
$data = json_decode((string) $this->request->getBody());
// bdump($data);
try {
$response = $client->request('POST', '/chat/completions', [
'headers' => $headers,
'stream' => true,
'json' => [
'model' => $data->model,
'messages' => $data->messages,
'stream' => true,
'temperature' => $data->temperature ?? 0,
'top_p' => $config->top_p ?? 0,
'user' => $identity->email
]
]);
} catch (RequestException $e) {
header("Content-Type: text/event-stream;charset=UTF-8");
$responseBody = (string) $e->getResponse()->getBody();
echo 'data: ' . str_replace("\n", '', $responseBody) . "\n\n";
$this->log->error('Chat request error', ['request_body' => (string) $e->getRequest()->getBody()]);
$this->log->error('Chat response error', ['response_body' => $responseBody]);
exit;
}
$body = $response->getBody();
$contentType = $response->getHeader('Content-Type');
if (count($contentType)) {
header("Content-Type:{$contentType[0]}");
}
$content = '';
while (!$body->eof()) {
$line = Utils::readLine($body);
if ($line != '[DONE]') {
$data = preg_replace('/^data:/', '', $line);
$data = json_decode($data, true);
if (isset($data['choices'][0]['delta']['content'])) {
$content .= $data['choices'][0]['delta']['content'];
}
}
echo $line;
flush();
}
exit;
}
public function indexAction()
{
// $assistants = Assistant::find($this->db, $this->user->getId(), '`title` ASC');
// $assistant = array_pop($assistants);
// $apiKey = $this->getUserApiKey();
// $assistant = Assistant::getDefaultUserAssistant($this->db, $this->user->getId());
return $this->render('index', [
// 'assistant' => $assistant,
'models' => $this->getModels(),
// 'apiKey' => $apiKey,
]);
}
public function listAction()
{
$assistants = Assistant::find($this->db, $this->user->getId(), '`title` ASC');
return $this->jsonResponse($assistants);
}
public function assistantsAction()
{
return $this->render('assistants', [
'assistants' => Assistant::find($this->db, $this->user->getId(), '`title` ASC')
]);
}
public function editAction($id = 0)
{
$model = new Assistant($this->db);
if ($id) {
$model->load($id);
}
$response = [];
$post = $this->request->getParsedBody();
if (!empty($post)) {
$model->bind($post);
$model->user_id = $this->user->getId();
try {
if ($model->isValid() && $model->save()) {
$response['status'] = 'success';
} else {
$response['status'] = 'error';
$response['error'] = array_pop($model->getErrors());
}
} catch (\Exception $e) {
exit ($e->getMessage());
}
}
$response['model'] = $model->toArray();
return $this->jsonResponse($response);
}
public function saveAction()
{
$model = new Assistant($this->db);
$data = json_decode((string) $this->request->getBody(), true);
if (isset($data['id'])) {
$model->load($data['id']);
unset($data['id']);
}
$response = [];
$model->bind($data);
$model->user_id = $this->user->getId();
try {
if ($model->isValid() && $model->save()) {
$response['status'] = 'success';
} else {
$response['status'] = 'error';
$response['errors'] = $model->getErrors();
}
} catch (\Exception $e) {
exit ($e->getMessage());
}
$response['assistant'] = $model->toArray();
return $this->jsonResponse($response);
}
public function setAsDefaultAction($id = 0)
{
$model = new Assistant($this->db);
if ($id) {
$model->load($id);
$model->default = 1;
$model->save();
return $this->jsonResponse(true);
}
return $this->jsonResponse(false);
}
public function deleteAction($id = 0)
{
$model = new Assistant($this->db);
if ($id) {
$model->load($id);
if ($model->delete()) {
return $this->jsonResponse(true);
}
}
return $this->jsonResponse(false);
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace app\modules\site\controllers;
use Throwable;
class DefaultController extends \Piko\Controller
{
public function errorAction(Throwable $exception)
{
return $this->render('error', [
'exception' => $exception
]);
}
}

View File

@@ -0,0 +1,78 @@
<?php
/* @var $this \Piko\View */
/* @var $content string */
$user = $this->params['user'];
assert($user instanceof Piko\User);
if (!$this->title) $this->title = 'Openai';
?>
<!DOCTYPE html>
<html lang="<?= $this->params['language'] ?>">
<head>
<meta charset="<?= $this->charset ?>">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= $this->escape($this->title) ?></title>
<?= $this->head() ?>
<?= $this->vite('main.css') ?>
</head>
<body>
<nav class="offcanvas offcanvas-start" tabindex="-1" id="offcanvasNavbar" aria-labelledby="offcanvasNavbarLabel">
<div class="offcanvas-header">
<h5 class="offcanvas-title" id="offcanvasNavbarLabel">Openai</h5>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
</div>
<div class="offcanvas-body">
<ul class="navbar-nav justify-content-end flex-grow-1 pe-3">
<li class="nav-item"><a class="nav-link active" aria-current="page" href="/">Assistant</a></li>
<li class="nav-item"><a class="nav-link" href="<?= $this->getUrl('site/ia/assistants') ?>">Gérer les assistants</a></li>
<?php /*if ($user->can('access.completion')): ?>
<li class="nav-item">
<a class="nav-link" href="<?= $this->getUrl('openai/v1/completions') ?>">Completions</a>
</li>
<?php endif */ ?>
<?php /* if ($user->can('access.edit')): ?>
<li class="nav-item">
<a class="nav-link" href="<?= $this->getUrl('openai/v1/edits') ?>">Edits</a>
</li>
<?php endif */ ?>
<li class="nav-item">
<a class="nav-link" href="<?= $this->getUrl('user/default/edit') ?>">Compte</a>
</li>
<li class="nav-item">
<a class="nav-link" href="<?= $this->getUrl('user/default/logout') ?>">Déconnexion</a>
</li>
<?php if ($user->can('admin')): ?>
<li class="nav-item">
<a class="nav-link" href="<?= $this->getUrl('user/admin/users') ?>">Gestion utilisateurs</a>
</li>
<?php endif ?>
</ul>
</div>
</nav>
<button type="button" id="navBtn" class="hamburger is-closed" data-bs-toggle="offcanvas" data-bs-target="#offcanvasNavbar" aria-controls="offcanvasNavbar" >
<span class="hamb-top"></span>
<span class="hamb-middle"></span>
<span class="hamb-bottom"></span>
</button>
<?php if (isset($this->params['message']) && is_array($this->params['message'])): ?>
<div class="container alert alert-<?= $this->params['message']['type'] ?> alert-dismissible fade show" role="alert">
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
<?= $this->params['message']['content'] ?>
</div>
<?php endif ?>
<?= $content ?>
<?= $this->endBody() ?>
<?= $this->vite('main.js') ?>
</body>
</html>

View File

@@ -0,0 +1,23 @@
<?php
/* @var $this \Piko\View */
/* @var $content string */
if (!$this->title) $this->title = 'Openai';
?>
<!DOCTYPE html>
<html lang="<?= $this->params['language'] ?>">
<head>
<meta charset="<?= $this->charset ?>">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= $this->escape($this->title) ?></title>
<?= $this->head() ?>
<?= $this->vite('main.css') ?>
</head>
<body class="d-flex align-items-center py-4">
<?= $content ?>
<?= $this->endBody() ?>
<?= $this->vite('main.js') ?>
</body>
</html>

View File

@@ -0,0 +1,145 @@
<?php
namespace app\modules\site\models;
/**
* This is the model class for table "chat_assistant".
*
* @property integer $id
* @property integer $user_id
* @property string $title;
* @property string $model;
* @property string $system_prompt;
* @property float $temperature;
* @property float $top_p;
* @property integer $default
*
* @author Sylvain PHILIP <contact@sphilip.com>
*/
class Assistant extends \Piko\DbRecord
{
/**
* The table name
*
* @var string
*/
protected $tableName = 'chat_assistant';
/**
* The role permissions
*
* @var array
*/
public $permissions = [];
/**
* The table schema
*
* @var array
*/
protected $schema = [
'id' => self::TYPE_INT,
'user_id' => self::TYPE_INT,
'title' => self::TYPE_STRING,
'model' => self::TYPE_STRING,
'system_prompt' => self::TYPE_STRING,
'temperature' => self::TYPE_STRING,
'top_p' => self::TYPE_STRING,
'default' => self::TYPE_INT,
];
/**
* {@inheritDoc}
* @see \Piko\ModelTrait::validate()
*/
protected function validate(): void
{
if (empty($this->user_id)) {
$this->errors['user_id'] = 'L\'id de l\'utilisateur est obligatoire.';
}
if (empty($this->title)) {
$this->errors['title'] = 'Le titre doit être renseigné.';
}
if (empty($this->model)) {
$this->errors['model'] = 'Le modèle doit être renseigné.';
}
if (empty($this->system_prompt)) {
$this->errors['system_prompt'] = 'Le prompt système doit être renseigné.';
}
if (!empty($this->temperature) && $this->temperature < 0 || $this->temperature > 2) {
$this->errors['temperature'] = 'Le température doit être comprise entre 0 et 2.';
}
}
/**
* @inheritDoc
*/
protected function beforeSave(bool $insert): bool
{
if ($this->default == 1) {
$this->db->beginTransaction();
try {
// Reset default for all other assistants of the same user
$sth = $this->db->prepare('UPDATE chat_assistant SET `default` = 0 WHERE user_id = :user_id');
$sth->execute(['user_id' => $this->user_id]);
$this->db->commit();
} catch (\Exception $e) {
$this->db->rollBack();
throw $e;
}
}
return parent::beforeSave($insert);
}
/**
* Get assistants
*
* @param \PDO $db A pdo connexion
* @param string $order The order condition
* @param number $start The offset start
* @param number $limit The offset limit
*
* @return array An array of role rows
*/
public static function find(\PDO $db, $userId = 0, $order = '', $start = 0, $limit = 0)
{
$query = 'SELECT * FROM chat_assistant';
if ($userId) {
$query .= ' WHERE user_id = :user_id';
}
$query .= ' ORDER BY ' . (empty($order) ? '`id` DESC' : $order);
if (!empty($start)) {
$query .= ' OFFSET ' . (int) $start;
}
if (!empty($limit)) {
$query .= ' LIMIT ' . (int) $limit;
}
$sth = $db->prepare($query);
$sth->execute(['user_id' => $userId]);
return $sth->fetchAll(\PDO::FETCH_ASSOC);
}
public static function getDefaultUserAssistant(\PDO $db, int $userId): ?self
{
$query = 'SELECT * FROM chat_assistant WHERE user_id = :user_id AND `default` = 1 LIMIT 1';
$sth = $db->prepare($query);
$sth->execute(['user_id' => $userId]);
return $sth->fetchObject(self::class) ?: null;
}
}

View File

@@ -0,0 +1,28 @@
<?php
assert($this instanceof \Piko\View);
$this->title = 'Assistant chat';
$modelsJs = json_encode($models);
$responseUrl = $this->getUrl('site/assistant/response');
$script = <<<JS
document.addEventListener('DOMContentLoaded', () => {
new ChatApp({
target: document.getElementById('chat-app'),
props : {
proxyBaseUrl: '$responseUrl',
model_list: $modelsJs
}
});
})
JS;
$this->registerJs($script);
?>
<div id="chat-app"></div>

View File

@@ -0,0 +1,34 @@
<?php
/* @var $this \piko\View */
/* @var $exception \Exception */
$message = getenv('APP_ENV') === 'dev' ? $exception->getMessage() . ' (#' . $exception->getCode() . ')' : 'Not found';
$this->title = $message;
?>
<div class="site-error">
<h1><?= $this->escape($this->title) ?></h1>
<div class="alert alert-danger">
<?= nl2br($this->escape($message)) ?>
</div>
<p>
The above error occurred while the Web server was processing your request.
</p>
<p>
Please contact us if you think this is a server error. Thank you.
</p>
<?php if (getenv('APP_DEBUG')): ?>
<div class="card bg-light mb-3">
<div class="card-header">Trace:</div>
<div class="card-body">
<?= nl2br($exception->getTraceAsString()) ?>
</div>
</div>
<?php endif ?>
</div>