diff --git a/module/ai/config.php b/module/ai/config.php index b8f1f1af96..a0e49248ec 100644 --- a/module/ai/config.php +++ b/module/ai/config.php @@ -15,8 +15,8 @@ $config->ai->openai->api->openai->version = 'v1'; / $config->ai->openai->api->openai->format = 'https://api.openai.com/%s/%s'; // OpenAI API format, args: API version, API name. $config->ai->openai->api->openai->authFormat = 'Authorization: Bearer %s'; // OpenAI API auth header format. $config->ai->openai->api->azure = new stdclass(); -$config->ai->openai->api->azure->resource = 'openai-at-zentao'; // your Azure OpenAI resource name, required. -$config->ai->openai->api->azure->deployment = 'the-first-gpt'; // your Azure OpenAI deployment name, required. +$config->ai->openai->api->azure->resource = ''; // Azure OpenAI resource name, required. +$config->ai->openai->api->azure->deployment = ''; // Azure OpenAI deployment name, required. $config->ai->openai->api->azure->apiVersion = '2023-07-01-preview'; // Azure OpenAI API version, required. $config->ai->openai->api->azure->format = 'https://%s.openai.azure.com/openai/deployments/%s/%s?api-version=%s'; // Azure API format, args: resource name, deployment name, API name, API version. $config->ai->openai->api->azure->authFormat = 'api-key: %s'; // Azure API auth header format. @@ -57,9 +57,9 @@ $config->ai->ernie->params = new stdclass(); $config->ai->ernie->params->chat = new stdclass(); $config->ai->ernie->params->function = new stdclass(); $config->ai->ernie->params->chat->required = array('messages'); -$config->ai->ernie->params->chat->optional = array('temperature', 'top_p', 'penalty_score', 'stream', 'user_id'); +$config->ai->ernie->params->chat->optional = array('temperature', 'top_p', 'penalty_score', 'stream', 'system', 'user_id'); $config->ai->ernie->params->function->required = array('messages', 'functions'); -$config->ai->ernie->params->function->optional = array('temperature', 'top_p', 'penalty_score', 'stream', 'user_id'); +$config->ai->ernie->params->function->optional = array('temperature', 'top_p', 'penalty_score', 'stream', 'system', 'user_id'); $config->ai->ernie->model = new stdclass(); $config->ai->ernie->model->chat = 'ernie-bot-turbo'; diff --git a/module/ai/control.php b/module/ai/control.php index 8fccd5a9f2..859a72b9ff 100644 --- a/module/ai/control.php +++ b/module/ai/control.php @@ -694,4 +694,41 @@ class ai extends control $this->view->roleTemplates = $this->ai->getRoleTemplates(); $this->display(); } + + /** + * Chat with LLMs. + * + * @access public + * @return void + */ + public function chat() + { + $messages = array(); + + if(!empty($_POST)) + { + $history = $this->post->history; + $message = $this->post->message; + $isRetry = $this->post->retry == 'true'; + + $messages = json_decode($history); + if(empty($messages)) $messages[] = (object)array('role' => 'system', 'content' => $this->lang->ai->chatSystemMessage); + + if(!$isRetry) $messages[] = (object)array('role' => 'user', 'content' => $message); + + $response = $this->ai->converse($messages); + if(empty($response)) + { + $this->view->error = $this->lang->ai->chatNoResponse; + } + else + { + $messages[] = (object)array('role' => 'assistant', 'content' => is_array($response) ? current($response) : $response); + } + } + + $this->view->title = $this->lang->ai->chat; + $this->view->messages = $messages; + $this->display(); + } } diff --git a/module/ai/css/chat.css b/module/ai/css/chat.css new file mode 100644 index 0000000000..18cf44c8d2 --- /dev/null +++ b/module/ai/css/chat.css @@ -0,0 +1,33 @@ +body {background-color: #fff; background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='164' height='164' style='opacity: 0.15; filter: grayscale(0.05);'%3e%3cdefs%3e%3clinearGradient id='b' x1='100%25' x2='25%25' y1='100%25' y2='25%25'%3e%3cstop offset='0%25' stop-color='%23FF2070'/%3e%3cstop offset='50%25' stop-color='%239670FF'/%3e%3cstop offset='100%25' stop-color='%23258DFF' stop-opacity='0'/%3e%3c/linearGradient%3e%3clinearGradient id='c' x1='0%25' x2='75%25' y1='0%25' y2='75%25'%3e%3cstop offset='0%25' stop-color='%23258DFF'/%3e%3cstop offset='50%25' stop-color='%239670FF'/%3e%3cstop offset='100%25' stop-color='%23FF2070' stop-opacity='0'/%3e%3c/linearGradient%3e%3cpath id='a' fill='none' fill-rule='evenodd' stroke-linejoin='round' d='M41 .5c11.178 0 21.303 4.536 28.633 11.867C76.964 19.697 81.5 29.822 81.5 41s-4.536 21.303-11.867 28.633C62.303 76.964 52.178 81.5 41 81.5c-11.184 0-21.314-4.528-28.646-11.86C5.031 62.317.5 52.197.5 41c0-11.178 4.536-21.303 11.867-28.633C19.697 5.036 29.822.5 41 .5ZM30.628 10.897c-7.712 0-13.615 2.316-17.681 6.995-3.396 3.906-5.508 9.44-6.359 16.585 1.426-4.143 3.089-7.394 4.98-9.76 2.603-3.256 5.642-4.859 9.098-4.859 1.857 0 3.141.58 4.002 1.44 1.052 1.052 1.48 2.55 1.48 4.04 0 2.068-1.527 3.379-3.514 4.952-2.81 2.223-6.448 5.088-6.448 12.507 0 2.572.7 4.106 1.347 5.532.61 1.347 1.169 2.605 1.169 4.43 0 1.248-.605 2.502-1.62 3.421a5.443 5.443 0 0 1-3.66 1.407c-1.598 0-3.422-.682-5.191-2.496 1.758 4.658 5.443 8.964 10.246 12.229 5.475 3.722 12.408 6.094 19.648 6.094 7.125 0 13.102-2.01 17.364-5.21 4.462-3.348 7.04-8.001 7.04-12.98 0-4.66-1.687-8.478-4.387-11.177-2.892-2.892-6.938-4.498-11.288-4.498-4.077 0-7.316 1.11-9.57 2.95-2.374 1.938-3.64 4.685-3.64 7.744 0 2.25.686 3.991 1.604 5.281 1.009 1.418 2.3 2.307 3.216 2.704-1.299-3.083-1.11-5.497-.13-7.262.658-1.186 1.682-2.09 2.903-2.706 1.296-.654 2.814-.982 4.333-.982 1.997 0 3.274.898 4.095 2.132 1.206 1.816 1.377 4.418 1.386 5.862 0 .832-.527 4.904-3.333 7.71-1.568 1.57-3.84 2.754-7.129 2.754-6.08 0-10.244-1.92-13.025-4.702-3.67-3.67-4.933-8.87-4.933-13.256 0-3.992 1.74-8.001 4.67-10.933 2.719-2.718 6.457-4.51 10.772-4.51 6.504 0 11.005 2.325 15.212 4.599l.744.402c3.579 1.935 7.012 3.729 11.464 3.729 2.687 0 4.747-.579 6.292-1.576 1.728-1.116 2.8-2.753 3.397-4.64 1.233-3.893.486-8.835-.823-12.676.27 2.515.067 4.706-.515 6.452-.496 1.488-1.267 2.661-2.252 3.464-.991.807-2.2 1.247-3.583 1.247-6.809 0-10.378-4.46-14.326-9.507-4.744-6.063-10.084-12.932-23.055-12.932Z'/%3e%3c/defs%3e%3cuse x='82' y='82' stroke='url(%23b)' href='%23a'/%3e%3cuse stroke='url(%23c)' href='%23a'/%3e%3c/svg%3e"); background-position: -20px -20px; background-size: 140px 140px; background-repeat: repeat;} + +.chat {width: calc(100% - 8px); height: calc(100vh - 8px); display: flex; flex-direction: column; margin: 4px;} +.messages {height: 100%; padding: 8px 8px 0 8px; display: flex; flex-direction: column-reverse; overflow: auto;} + +.message-container-user, .message-container-assistant, .message-container-error {display: flex; margin: 8px 0;} +.message-container-user {justify-content: flex-end;} +.message-container-assistant {justify-content: flex-start;} +.message-container-error {justify-content: center;} +.message-content {white-space: pre-wrap;} +.message-container-user .message-content {background: #d5ecff; padding: 8px; border-radius: 8px 8px 0 8px;} +.message-container-assistant .message-content {background: #f1f1f1; padding: 8px; border-radius: 8px 8px 8px 0;} +.message-container-error .message-content {background: #ffdddd; padding: 2px 8px; border-radius: 8px;} + +.chat-input {display: flex; justify-content: space-between; align-items: flex-end; bottom: 0; width: calc(100% - 16px); margin: 8px;} +.chat-input textarea {flex: 1; margin-right: 4px; line-height: 1.5; height: 40px; min-height: 40px; padding: 9px 36px 9px 12px; border: 1px solid transparent; border-radius: 8px; background: linear-gradient(#fff, #fff) padding-box, linear-gradient(225deg, #ff2070, #9670ff, #258dff) border-box; outline: none; -webkit-appearance: none; -moz-appearance: none; appearance: none; resize: none;} +.chat-input input[type=submit] {position: absolute; left: 236px; bottom: 12px; width: 40px; height: 40px; padding: 0; border: none; border-radius: 4px; outline: none; background: linear-gradient(225deg, #ff2070, #9670ff, #258dff); mask-image: url('data:image/svg+xml;utf8,'); -webkit-mask-image: url('data:image/svg+xml;utf8,'); mask-position: center; -webkit-mask-position: center; mask-origin: padding-box; -webkit-mask-origin: padding-box; background-repeat: no-repeat; background-position: center; background-size: 16px 15px; mask-size: 16px 15px; -webkit-mask-size: 16px 15px; cursor: pointer;} +.chat-input input[type=submit].disabled {cursor: unset; opacity: 0.5;} +.chat-input input[type=submit]:not(.disabled):hover {transform: scale(1.1);} +.chat-input textarea.has-scrollbar + input[type=submit] {left: 228px; width: 36px; mask-position: 20px 12.5px; -webkit-mask-position: 20px 12.5px; background-position: 20px 12.5px;} +.chat-input textarea.busy, .chat-input input[type=submit].busy {cursor: wait; opacity: 0.5;} +#reset {height: 40px; border: none; background: none; color: #9ea3b0; border-radius: 4px;} +#reset.disabled {cursor: unset; opacity: 0.5;} +#reset:not(.disabled):hover {color: #737a8c;} +#reset.busy, #retry.busy {cursor: wait; opacity: 0.5;} + +::-webkit-scrollbar-thumb {border-radius: 5px;} +::-webkit-scrollbar {width: 10px; height: 10px;} +::-webkit-scrollbar-thumb:vertical {box-shadow: inset 1px 1px 0 rgb(0 0 0 / 10%), inset 0 -1px 0 rgb(0 0 0 / 7%); background-color: rgba(0, 0, 0, 0.2); border-radius: 10px; opacity: 0; transition: opacity 0.1s;} +::-webkit-scrollbar, ::-webkit-scrollbar-button, ::-webkit-scrollbar-track, ::-webkit-scrollbar-thumb {visibility: hidden; opacity: 0;} +:hover ::-webkit-scrollbar, :hover ::-webkit-scrollbar-button, :hover ::-webkit-scrollbar-track, :hover ::-webkit-scrollbar-thumb {visibility: visible; opacity: 1;} +.messages::-webkit-scrollbar-track {margin: -30px 0;} +.chat-input textarea::-webkit-scrollbar {width: 8px;} diff --git a/module/ai/js/chat.js b/module/ai/js/chat.js new file mode 100644 index 0000000000..9882c8aac7 --- /dev/null +++ b/module/ai/js/chat.js @@ -0,0 +1,115 @@ +$(function() +{ + function markBusy() + { + $('input[type=submit]').attr('readonly', true).addClass('busy'); + $('textarea[name=message]').attr('readonly', true).addClass('busy'); + $('#retry').addClass('busy'); + $('#reset').addClass('busy'); + } + + function reset(e) + { + if($(e.target).hasClass('busy') || $(e.target).hasClass('disabled')) return; + markBusy(); + $.ajax({ + url: createLink('ai', 'chat'), + dataType: 'html', + success: function(response) + { + $('.chat').html($($.parseHTML(response)).filter('.chat').html()); + init(); + } + }); + } + + function retry(e) + { + if($(e.target).hasClass('busy')) return; + markBusy(); + $.ajax({ + url: createLink('ai', 'chat'), + type: 'POST', + dataType: 'html', + data: {retry: 'true', history: $('input[name=history]').val()}, + success: function(response) + { + $('.chat').html($($.parseHTML(response)).filter('.chat').html()); + init(); + } + }); + } + + function init() + { + $('textarea[name=message]').keydown(function(e) + { + /* Send message on enter, allowing linebreak inputs. */ + if((e.keyCode == 13 || e.keyCode == 10)) + { + if($(this).hasClass('busy')) return; + + if(e.ctrlKey || e.metaKey) + { + $('textarea[name=message]').val($('textarea[name=message]').val() + '\n'); + $('textarea[name=message]').trigger('input'); + } + else + { + if($('textarea[name=message]').val().trim().length == 0) + { + e.preventDefault(); + return; + } + $('form').submit(); + } + } + }); + $('form').on('submit', function(e) + { + /* Disable form and add message. */ + markBusy(); + const message = $('textarea[name=message]').val().trim(); + $('.messages').prepend("
'); + + /* Send message to server and re-render the chat. */ + e.preventDefault(); + $.ajax({ + url: createLink('ai', 'chat'), + type: 'POST', + data: {message, history: $('input[name=history]').val()}, + dataType: 'html', + success: function(response) + { + $('.chat').html($($.parseHTML(response)).filter('.chat').html()); + init(); + } + }); + }); + $('#reset').click(reset); + $('#retry').click(retry); + $('textarea[name=message]').focus(); + $('textarea[name=message]').on('input', function() + { + /* Toggle send button. */ + const hasMessage = $(this).val().trim().length > 0; + $('input[type=submit]').attr('disabled', !hasMessage).toggleClass('disabled', !hasMessage); + + /* Resize textarea to fit content. */ + const clientHeight = +$(this).prop('clientHeight'); + const scrollHeight = +$(this).prop('scrollHeight'); + if(clientHeight < scrollHeight) + { + if(clientHeight < 150) + { + $(this).css('height', (scrollHeight + 2) + 'px'); + } + else + { + $(this).addClass('has-scrollbar'); + } + } + }); + } + init(); +}); diff --git a/module/ai/lang/de.php b/module/ai/lang/de.php index 79e258181d..0e424ff31b 100644 --- a/module/ai/lang/de.php +++ b/module/ai/lang/de.php @@ -49,6 +49,14 @@ $lang->ai->promptView = 'View Prompt'; $lang->ai->promptExecute = 'Execute Prompt'; $lang->ai->promptExecutionReset = 'Reset Execute Prompt'; $lang->ai->roleTemplates = 'Manage Role Templates'; +$lang->ai->chat = 'Chat'; + +$lang->ai->chatPlaceholderMessage = 'Hi, I\'m Adao, your AI assistant at ZenTao!'; +$lang->ai->chatPlaceholderInput = 'type here...'; +$lang->ai->chatSystemMessage = 'You\'re Adao, the AI assistant and mascot of ZenTao. You can answer questions and chat with users. You\'re currently within the ZenTao project management software.'; +$lang->ai->chatSend = 'Send'; +$lang->ai->chatReset = 'Reset'; +$lang->ai->chatNoResponse = 'Something went wrong, click here to retry.'; $lang->ai->nextStep = 'Next'; $lang->ai->goTesting = 'Go testing'; diff --git a/module/ai/lang/en.php b/module/ai/lang/en.php index 79e258181d..0e424ff31b 100644 --- a/module/ai/lang/en.php +++ b/module/ai/lang/en.php @@ -49,6 +49,14 @@ $lang->ai->promptView = 'View Prompt'; $lang->ai->promptExecute = 'Execute Prompt'; $lang->ai->promptExecutionReset = 'Reset Execute Prompt'; $lang->ai->roleTemplates = 'Manage Role Templates'; +$lang->ai->chat = 'Chat'; + +$lang->ai->chatPlaceholderMessage = 'Hi, I\'m Adao, your AI assistant at ZenTao!'; +$lang->ai->chatPlaceholderInput = 'type here...'; +$lang->ai->chatSystemMessage = 'You\'re Adao, the AI assistant and mascot of ZenTao. You can answer questions and chat with users. You\'re currently within the ZenTao project management software.'; +$lang->ai->chatSend = 'Send'; +$lang->ai->chatReset = 'Reset'; +$lang->ai->chatNoResponse = 'Something went wrong, click here to retry.'; $lang->ai->nextStep = 'Next'; $lang->ai->goTesting = 'Go testing'; diff --git a/module/ai/lang/fr.php b/module/ai/lang/fr.php index 79e258181d..0e424ff31b 100644 --- a/module/ai/lang/fr.php +++ b/module/ai/lang/fr.php @@ -49,6 +49,14 @@ $lang->ai->promptView = 'View Prompt'; $lang->ai->promptExecute = 'Execute Prompt'; $lang->ai->promptExecutionReset = 'Reset Execute Prompt'; $lang->ai->roleTemplates = 'Manage Role Templates'; +$lang->ai->chat = 'Chat'; + +$lang->ai->chatPlaceholderMessage = 'Hi, I\'m Adao, your AI assistant at ZenTao!'; +$lang->ai->chatPlaceholderInput = 'type here...'; +$lang->ai->chatSystemMessage = 'You\'re Adao, the AI assistant and mascot of ZenTao. You can answer questions and chat with users. You\'re currently within the ZenTao project management software.'; +$lang->ai->chatSend = 'Send'; +$lang->ai->chatReset = 'Reset'; +$lang->ai->chatNoResponse = 'Something went wrong, click here to retry.'; $lang->ai->nextStep = 'Next'; $lang->ai->goTesting = 'Go testing'; diff --git a/module/ai/lang/zh-cn.php b/module/ai/lang/zh-cn.php index b7eefa07e5..95c69903ad 100644 --- a/module/ai/lang/zh-cn.php +++ b/module/ai/lang/zh-cn.php @@ -49,6 +49,14 @@ $lang->ai->promptView = '查看提词详情'; $lang->ai->promptExecute = '执行提词'; $lang->ai->promptExecutionReset = '重置调试'; $lang->ai->roleTemplates = '管理角色模板'; +$lang->ai->chat = '聊天'; + +$lang->ai->chatPlaceholderMessage = 'Hi,我是 AI 助手阿道,您可以问我任何问题。'; +$lang->ai->chatPlaceholderInput = '问问阿道…'; +$lang->ai->chatSystemMessage = '你叫阿道,是禅道的 AI 助手兼吉祥物,你可以回答用户的问题和与用户聊天。你当前所处的环境是禅道项目管理软件。'; +$lang->ai->chatSend = '发送'; +$lang->ai->chatReset = '清空'; +$lang->ai->chatNoResponse = '会话发生了错误,点击这里重试。'; $lang->ai->nextStep = '下一步'; $lang->ai->goTesting = '去调试'; diff --git a/module/ai/model.php b/module/ai/model.php index 4d605d7281..9bf5f87ae1 100644 --- a/module/ai/model.php +++ b/module/ai/model.php @@ -65,7 +65,7 @@ class aiModel extends model $modelConfig = new stdclass(); $storedModelConfig = $this->loadModel('setting')->getItems('owner=system&module=ai'); foreach($storedModelConfig as $item) $modelConfig->{$item->key} = $item->value; - return !empty($modelConfig->key); + return $modelConfig->status == 'on' && !empty($modelConfig->key); } /** @@ -96,7 +96,7 @@ class aiModel extends model { if($modelVendor == 'azure') { - $url = sprintf($this->config->ai->openai->api->azure->format, $this->config->ai->openai->api->azure->resource, $this->config->ai->openai->api->azure->deployment, $this->config->ai->openai->api->methods[$type], $this->config->ai->openai->api->azure->apiVersion); + $url = sprintf($this->config->ai->openai->api->azure->format, $this->modelConfig->resource, $this->modelConfig->deployment, $this->config->ai->openai->api->methods[$type], $this->config->ai->openai->api->azure->apiVersion); } else { @@ -469,6 +469,15 @@ class aiModel extends model */ public function converse($messages, $options = array()) { + /* Filter system message out for ERNIE. */ + if($this->config->ai->models[$this->modelConfig->type] == 'ernie') + { + $systemMessage = current(array_filter($messages, function($message) { return $message->role == 'system'; })); + if(!empty($systemMessage)) $options['system'] = $systemMessage->content; + + $messages = array_values(array_filter($messages, function($message) { return $message->role == 'assistant' || $message->role == 'user'; })); + } + $data = compact('messages'); if(!empty($options)) diff --git a/module/ai/view/chat.html.php b/module/ai/view/chat.html.php new file mode 100644 index 0000000000..96ae45d64a --- /dev/null +++ b/module/ai/view/chat.html.php @@ -0,0 +1,43 @@ + + * @package ai + * @link https://www.zentao.net + */ +?> + +