From 52f84afe5db9499c7ec56b07e7a4ce71f1ff5636 Mon Sep 17 00:00:00 2001 From: Anupam Kumar Date: Wed, 8 May 2024 13:39:46 +0530 Subject: [PATCH 01/26] feat: chatty ui Signed-off-by: Anupam Kumar --- README.md | 1 + appinfo/info.xml | 2 +- appinfo/routes.php | 11 + docs/README.md | 3 +- docs/admin/README.md | 76 ++ lib/AppInfo/Application.php | 3 + lib/Controller/ChattyLLMController.php | 410 +++++++++++ lib/Db/ChattyLLM/Message.php | 83 +++ lib/Db/ChattyLLM/MessageMapper.php | 125 ++++ lib/Db/ChattyLLM/Session.php | 75 ++ lib/Db/ChattyLLM/SessionMapper.php | 102 +++ .../BeforeTemplateRenderedListener.php | 8 + .../Version010010Date20240430083738.php | 94 +++ lib/Service/AssistantService.php | 5 + lib/Settings/Admin.php | 8 + src/assistant.js | 5 + src/components/AdminSettings.vue | 116 ++- src/components/AssistantFormInputs.vue | 11 +- .../AssistantTextProcessingForm.vue | 21 +- .../ChattyLLM/ChattyLLMInputForm.vue | 665 ++++++++++++++++++ src/components/ChattyLLM/ConversationBox.vue | 120 ++++ src/components/ChattyLLM/InputArea.vue | 151 ++++ src/components/ChattyLLM/Message.vue | 146 ++++ src/components/ChattyLLM/MessageActions.vue | 89 +++ src/components/ChattyLLM/NoSession.vue | 41 ++ src/utils.js | 7 + 26 files changed, 2342 insertions(+), 36 deletions(-) create mode 100644 docs/admin/README.md create mode 100644 lib/Controller/ChattyLLMController.php create mode 100644 lib/Db/ChattyLLM/Message.php create mode 100644 lib/Db/ChattyLLM/MessageMapper.php create mode 100644 lib/Db/ChattyLLM/Session.php create mode 100644 lib/Db/ChattyLLM/SessionMapper.php create mode 100644 lib/Migration/Version010010Date20240430083738.php create mode 100644 src/components/ChattyLLM/ChattyLLMInputForm.vue create mode 100644 src/components/ChattyLLM/ConversationBox.vue create mode 100644 src/components/ChattyLLM/InputArea.vue create mode 100644 src/components/ChattyLLM/Message.vue create mode 100644 src/components/ChattyLLM/MessageActions.vue create mode 100644 src/components/ChattyLLM/NoSession.vue create mode 100644 src/utils.js diff --git a/README.md b/README.md index aacb44e7..3183149c 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ include text processing providers to: * Get an answer from a free prompt * Reformulate (OpenAi/LocalAi only) * Context writer: Generate text with a specified style. The style can be described or provided via an example text. +* Chat with AI ### Text to image (Image generation) diff --git a/appinfo/info.xml b/appinfo/info.xml index 9a302987..93249a4a 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -56,7 +56,7 @@ Known providers: * [OpenAi/LocalAI integration](https://apps.nextcloud.com/apps/integration_openai) * [Local Whisper Speech-To-Text](https://apps.nextcloud.com/apps/stt_whisper) ]]> - 1.0.9 + 1.1.0 agpl Julien Veyssier Assistant diff --git a/appinfo/routes.php b/appinfo/routes.php index 0c1c6936..6247c542 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -24,6 +24,17 @@ ['name' => 'SpeechToText#getResultPage', 'url' => '/stt/result-page/{metaTaskId}', 'verb' => 'GET'], ['name' => 'preview#getFileImage', 'url' => '/preview', 'verb' => 'GET'], + + ['name' => 'chattyLLM#newSession', 'url' => '/chat/new_session', 'verb' => 'PUT'], + ['name' => 'chattyLLM#updateSessionTitle', 'url' => '/chat/update_session', 'verb' => 'PATCH'], + ['name' => 'chattyLLM#deleteSession', 'url' => '/chat/delete_session', 'verb' => 'DELETE'], + ['name' => 'chattyLLM#getSessions', 'url' => '/chat/sessions', 'verb' => 'GET'], + ['name' => 'chattyLLM#newMessage', 'url' => '/chat/new_message', 'verb' => 'PUT'], + ['name' => 'chattyLLM#deleteMessage', 'url' => '/chat/delete_message', 'verb' => 'DELETE'], + ['name' => 'chattyLLM#getMessages', 'url' => '/chat/messages', 'verb' => 'GET'], + ['name' => 'chattyLLM#generateForSession', 'url' => '/chat/generate', 'verb' => 'GET'], + ['name' => 'chattyLLM#regenerateForSession', 'url' => '/chat/regenerate', 'verb' => 'GET'], + ['name' => 'chattyLLM#generateTitle', 'url' => '/chat/generate_title', 'verb' => 'GET'], ], 'ocs' => [ ['name' => 'assistantApi#getAvailableTaskTypes', 'url' => '/api/{apiVersion}/task-types', 'verb' => 'GET', 'requirements' => $requirements], diff --git a/docs/README.md b/docs/README.md index 504d7a7f..963ea5cf 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,4 +2,5 @@ * [User documentation](./user) * [Developer documentation](./developer) -* [AI admin doc](https://docs.nextcloud.com/server/latest/admin_manual/ai/index.html) +* [Admin documentation](./admin) +* [AI admin Nextcloud documentation](https://docs.nextcloud.com/server/latest/admin_manual/ai/index.html) diff --git a/docs/admin/README.md b/docs/admin/README.md new file mode 100644 index 00000000..10fd03f3 --- /dev/null +++ b/docs/admin/README.md @@ -0,0 +1,76 @@ +# Admin documentation + +## Admin settings + +The Assistant admin settings can be found under the "Artificial intelligence" section. +You can disable the assistant top menu entry there. You can also disable the AI-related smart pickers. +The commands to change the options are also listed in. + +## Assistant configuration + +1. Top-right Assistant + +``` +occ config:app:set assistant assistant_enabled --value=1 +``` + +To enable/disable the assistant button from the top-right corner for all the users. + +2. AI text generation smart picker + +``` +occ config:app:set assistant free_prompt_picker_enabled --value=1 +``` + +To enable/disable the AI text generation smart picker for all the users. + +3. Text-to-image smart picker + +``` +occ config:app:set assistant text_to_image_picker_enabled --value=1 +``` + +To enable/disable the text-to-image smart picker for all the users. + +4. Speech-to-text smart picker + +``` +occ config:app:set assistant speech_to_text_picker_enabled --value=1 +``` + +To enable/disable the speech-to-text smart picker for all the users. + +### Image storage + +Days until generated images are deleted if they are not viewed. + +``` +occ config:app:set assistant max_image_generation_idle_time --value=90 +``` + +### Chat with AI + +1. Chat User Instructions for Chat Completions + +``` +occ config:app:set assistant chat_user_instructions --value="hello world" +``` + +The user instructions that are prepended before the chat messages for the AI model to understand the context of the block of text. This is a good place not only to instruct the AI model to be polite and kind but also to for example answer all the queries in a particular language or better yet, follow the user's language. The sky is the limit. + +2. Chat User Instructions for Title Generation + +``` +occ config:app:set assistant chat_user_instructions_title --value="hello title" +``` + +This field is appended to the block of chat messages, i.e. attached after the messages. It is done this way to allow it to be used even with text completion models which could have the instructions as "The title for the above conversation could be \"". + +3. Last N messages to consider for chat completions + +``` +occ config:app:set assistant chat_last_n_messages --value=10 +``` + +The number of latest messages to consider for generating the next message. This does not include the user instructions, which is always considered in addition to this. This value should be adjusted in case you are hitting the token limit in your conversations too often. +The AI text generation provider should ideally handle the max token limit case. diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 45668a92..1bc86764 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -63,6 +63,9 @@ class Application extends App implements IBootstrap { public const TASK_CATEGORY_TEXT_TO_IMAGE = 1; public const TASK_CATEGORY_SPEECH_TO_TEXT = 2; + public const CHAT_USER_INSTRUCTIONS = 'This is a conversation between {user} and you, Nextcloud Assistant. You are a kind, polite and helpful AI that helps {user} to the best of its abilities. If you do not understand something, you will ask for clarification. Remember to verify the premise presented by {user}. In a rare case, it might be wrong.'; + public const CHAT_USER_INSTRUCTIONS_TITLE = 'Above is a chat session between {user} and you, Nextcloud Assistant. Generate a suitable title summarizing the conversation and output only that.'; + public function __construct(array $urlParams = []) { parent::__construct(self::APP_ID, $urlParams); } diff --git a/lib/Controller/ChattyLLMController.php b/lib/Controller/ChattyLLMController.php new file mode 100644 index 00000000..eeda387d --- /dev/null +++ b/lib/Controller/ChattyLLMController.php @@ -0,0 +1,410 @@ +userId === null) { + return new JSONResponse(['error' => $this->l10n->t('User not logged in')], Http::STATUS_UNAUTHORIZED); + } + + $user = $this->userManager->get($this->userId); + if ($user === null) { + return new JSONResponse(['error' => $this->l10n->t('User not found')], Http::STATUS_UNAUTHORIZED); + } + + $userInstructions = $this->config->getAppValue( + Application::APP_ID, + 'chat_user_instructions', + Application::CHAT_USER_INSTRUCTIONS, + ); + $userInstructions = str_replace('{user}', $user->getDisplayName(), $userInstructions); + + try { + $session = new Session(); + $session->setUserId($this->userId); + $session->setTitle($title); + $session->setTimestamp($timestamp); + $this->sessionMapper->insert($session); + + $systemMsg = new Message(); + $systemMsg->setSessionId($session->getId()); + $systemMsg->setRole('system'); + $systemMsg->setContent($userInstructions); + $systemMsg->setTimestamp($session->getTimestamp()); + $this->messageMapper->insert($systemMsg); + + $humanMsg = new Message(); + $humanMsg->setSessionId($session->getId()); + $humanMsg->setRole('human'); + $humanMsg->setContent($content); + $humanMsg->setTimestamp($session->getTimestamp()); + $this->messageMapper->insert($humanMsg); + + return new JSONResponse([ + 'session' => $session, + 'message' => $humanMsg, + ]); + } catch (\OCP\DB\Exception | \RuntimeException $e) { + $this->logger->warning('Failed to create a chat session', ['exception' => $e]); + return new JSONResponse(['error' => $this->l10n->t('Failed to create a chat session')], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * Update the title of the chat session + * + * @param integer $sessionId + * @param string $title + * @return JSONResponse + */ + #[NoAdminRequired] + public function updateSessionTitle(int $sessionId, string $title): JSONResponse { + if ($this->userId === null) { + return new JSONResponse(['error' => $this->l10n->t('User not logged in')], Http::STATUS_UNAUTHORIZED); + } + + try { + $this->sessionMapper->updateSessionTitle($sessionId, $title); + return new JSONResponse(); + } catch (\OCP\DB\Exception | \RuntimeException $e) { + $this->logger->warning('Failed to update the chat session', ['exception' => $e]); + return new JSONResponse(['error' => $this->l10n->t('Failed to update the chat session')], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * Delete a chat session by ID + * + * @param integer $sessionId + * @return JSONResponse + */ + #[NoAdminRequired] + public function deleteSession(int $sessionId): JSONResponse { + if ($this->userId === null) { + return new JSONResponse(['error' => $this->l10n->t('User not logged in')], Http::STATUS_UNAUTHORIZED); + } + + try { + $this->sessionMapper->deleteSession($sessionId); + $this->messageMapper->deleteMessagesBySession($sessionId); + return new JSONResponse(); + } catch (\OCP\DB\Exception | \RuntimeException $e) { + $this->logger->warning('Failed to delete the chat session', ['exception' => $e]); + return new JSONResponse(['error' => $this->l10n->t('Failed to delete the chat session')], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * Get all chat sessions for the user + * + * @return JSONResponse + */ + #[NoAdminRequired] + public function getSessions(): JSONResponse { + if ($this->userId === null) { + return new JSONResponse(['error' => $this->l10n->t('User not logged in')], Http::STATUS_UNAUTHORIZED); + } + + try { + $sessions = $this->sessionMapper->getUserSessions($this->userId); + return new JSONResponse($sessions); + } catch (\OCP\DB\Exception $e) { + $this->logger->warning('Failed to get chat sessions', ['exception' => $e]); + return new JSONResponse(['error' => $this->l10n->t('Failed to get chat sessions')], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * Add a new chat message to the session + * + * @param int $sessionId + * @param string $role + * @param string $content + * @param int $timestamp + * @return JSONResponse + */ + #[NoAdminRequired] + public function newMessage(int $sessionId, string $role, string $content, int $timestamp): JSONResponse { + if ($this->userId === null) { + return new JSONResponse(['error' => $this->l10n->t('User not logged in')], Http::STATUS_UNAUTHORIZED); + } + + try { + $sessionExists = $this->sessionMapper->exists($sessionId); + if (!$sessionExists) { + return new JSONResponse(['error' => $this->l10n->t('Session not found')], Http::STATUS_NOT_FOUND); + } + + $message = new Message(); + $message->setSessionId($sessionId); + $message->setRole($role); + $message->setContent($content); + $message->setTimestamp($timestamp); + $this->messageMapper->insert($message); + + return new JSONResponse($message); + } catch (\OCP\DB\Exception $e) { + $this->logger->warning('Failed to add a chat message', ['exception' => $e]); + return new JSONResponse(['error' => $this->l10n->t('Failed to add a chat message')], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * Get chat messages for the session without the system message + * + * @param int $sessionId + * @param int $limit + * @param int $cursor + * @return JSONResponse + */ + #[NoAdminRequired] + public function getMessages(int $sessionId, int $limit = 20, int $cursor = 0): JSONResponse { + if ($this->userId === null) { + return new JSONResponse(['error' => $this->l10n->t('User not logged in')], Http::STATUS_UNAUTHORIZED); + } + + try { + $sessionExists = $this->sessionMapper->exists($sessionId); + if (!$sessionExists) { + return new JSONResponse(['error' => $this->l10n->t('Session not found')], Http::STATUS_NOT_FOUND); + } + + $messages = $this->messageMapper->getMessages($sessionId, $cursor, $limit); + if ($messages[0]->getRole() === 'system') { + array_shift($messages); + } + + return new JSONResponse($messages); + } catch (\OCP\DB\Exception $e) { + $this->logger->warning('Failed to get chat messages', ['exception' => $e]); + return new JSONResponse(['error' => $this->l10n->t('Failed to get chat messages')], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * Delete a chat message by ID + * + * @param integer $messageId + * @return JSONResponse + */ + #[NoAdminRequired] + public function deleteMessage(int $messageId): JSONResponse { + if ($this->userId === null) { + return new JSONResponse(['error' => $this->l10n->t('User not logged in')], Http::STATUS_UNAUTHORIZED); + } + + try { + $this->messageMapper->deleteMessageById($messageId); + return new JSONResponse(); + } catch (\OCP\DB\Exception | \RuntimeException $e) { + $this->logger->warning('Failed to delete a chat message', ['exception' => $e]); + return new JSONResponse(['error' => $this->l10n->t('Failed to delete a chat message')], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * Generate a new message for the session + * + * @param integer $sessionId + * @return JSONResponse + */ + #[NoAdminRequired] + public function generateForSession(int $sessionId): JSONResponse { + if ($this->userId === null) { + return new JSONResponse(['error' => $this->l10n->t('User not logged in')], Http::STATUS_UNAUTHORIZED); + } + + try { + $sessionExists = $this->sessionMapper->exists($sessionId); + if (!$sessionExists) { + return new JSONResponse(['error' => $this->l10n->t('Session not found')], Http::STATUS_NOT_FOUND); + } + + $stichedPrompt = + $this->getStichedMessages($sessionId) + . PHP_EOL + . 'assistant: '; + + $result = $this->queryLLM($stichedPrompt); + + $message = new Message(); + $message->setSessionId($sessionId); + $message->setRole('assistant'); + $message->setContent($result); + $message->setTimestamp(time()); + $this->messageMapper->insert($message); + } catch (\OCP\DB\Exception $e) { + $this->logger->warning('Failed to add a chat message into DB', ['exception' => $e]); + return new JSONResponse(['error' => $this->l10n->t('Failed to add a chat message into DB')], Http::STATUS_INTERNAL_SERVER_ERROR); + } + + return new JSONResponse($message); + } + + /** + * Delete all messages since the given message ID and then + * generate a new message for the session + * + * @param integer $sessionId + * @param integer $messageId + * @return JSONResponse + */ + #[NoAdminRequired] + public function regenerateForSession(int $sessionId, int $messageId): JSONResponse { + if ($this->userId === null) { + return new JSONResponse(['error' => $this->l10n->t('User not logged in')], Http::STATUS_UNAUTHORIZED); + } + + try { + $sessionExists = $this->sessionMapper->exists($sessionId); + if (!$sessionExists) { + return new JSONResponse(['error' => $this->l10n->t('Session not found')], Http::STATUS_NOT_FOUND); + } + + $this->messageMapper->deleteMessagesSinceId($sessionId, $messageId); + return $this->generateForSession($sessionId); + } catch (\OCP\DB\Exception $e) { + $this->logger->warning('Failed to add a chat message into DB', ['exception' => $e]); + return new JSONResponse(['error' => $this->l10n->t('Failed to add a chat message into DB')], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * Generate a title for the chat session + * + * @param integer $sessionId + * @return JSONResponse + */ + #[NoAdminRequired] + public function generateTitle(int $sessionId): JSONResponse { + if ($this->userId === null) { + return new JSONResponse(['error' => $this->l10n->t('User not logged in')], Http::STATUS_UNAUTHORIZED); + } + + $user = $this->userManager->get($this->userId); + if ($user === null) { + return new JSONResponse(['error' => $this->l10n->t('User not found')], Http::STATUS_UNAUTHORIZED); + } + + try { + $sessionExists = $this->sessionMapper->exists($sessionId); + if (!$sessionExists) { + return new JSONResponse(['error' => $this->l10n->t('Session not found')], Http::STATUS_NOT_FOUND); + } + + $userInstructions = $this->config->getAppValue( + Application::APP_ID, + 'chat_user_instructions_title', + Application::CHAT_USER_INSTRUCTIONS_TITLE, + ); + $userInstructions = str_replace('{user}', $user->getDisplayName(), $userInstructions); + + $stichedPrompt = $this->getStichedMessages($sessionId) + . PHP_EOL . PHP_EOL + . $userInstructions; + + $result = $this->queryLLM($stichedPrompt); + $title = str_replace($userInstructions, '', $result); + $title = str_replace('"', '', $title); + $title = explode(PHP_EOL, $title)[0]; + $title = trim($title); + + $this->sessionMapper->updateSessionTitle($sessionId, $title); + + return new JSONResponse(['result' => $title]); + } catch (\OCP\DB\Exception $e) { + $this->logger->warning('Failed to generate a title for the chat session', ['exception' => $e]); + return new JSONResponse(['error' => $this->l10n->t('Failed to generate a title for the chat session')], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * Get the first message (user instructions) and the last N messages (assistant and user messages) + * and stich them together + * + * @param integer $sessionId + * @return string + * @throws \OCP\DB\Exception + * @throws \RuntimeException + * @throws \OCP\AppFramework\Db\DoesNotExistException + * @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException + */ + private function getStichedMessages(int $sessionId): string { + $stichedPrompt = ''; + + $firstMessage = $this->messageMapper->getFirstNMessages($sessionId, 1); + if ($firstMessage->getRole() === 'system') { + $stichedPrompt = $firstMessage->getContent() . PHP_EOL; + } + + $lastNMessages = intval($this->config->getAppValue(Application::APP_ID, 'chat_last_n_messages', '10')); + $messages = $this->messageMapper->getMessages($sessionId, 0, $lastNMessages); + + if ($messages[0]->getRole() === 'system') { + array_shift($messages); + } + $stichedPrompt .= implode(PHP_EOL, array_map(fn ($msg) => $msg->getContent(), $messages)); + + return $stichedPrompt; + } + + /** + * Synchrounous call to the LLM + * + * @param string $content + * @return string + */ + private function queryLLM(string $content): string { + $task = new TextProcessingTask(FreePromptTaskType::class, $content, Application::APP_ID, $this->userId); + return trim($this->textProcessingManager->runTask($task)); + } +} diff --git a/lib/Db/ChattyLLM/Message.php b/lib/Db/ChattyLLM/Message.php new file mode 100644 index 00000000..4469285f --- /dev/null +++ b/lib/Db/ChattyLLM/Message.php @@ -0,0 +1,83 @@ + + * + * @author Anupam Kumar + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace OCA\Assistant\Db\ChattyLLM; + +use OCP\AppFramework\Db\Entity; +use OCP\DB\Types; + +/** + * @method \int getSessionId() + * @method \void setSessionId(int $sessionId) + * @method \string getRole() + * @method \void setRole(string $role) + * @method \string getContent() + * @method \void setContent(string $content) + * @method \int getTimestamp() + * @method \void setTimestamp(int $timestamp) + */ +class Message extends Entity implements \JsonSerializable { + /** @var int */ + protected $sessionId; + /** @var string */ + protected $role; + /** @var string */ + protected $content; + /** @var int */ + protected $timestamp; + + public static $columns = [ + 'id', + 'session_id', + 'role', + 'content', + 'timestamp', + ]; + public static $fields = [ + 'id', + 'sessionId', + 'role', + 'content', + 'timestamp', + ]; + + public function __construct() { + $this->addType('session_id', Types::INTEGER); + $this->addType('role', Types::STRING); + $this->addType('content', Types::STRING); + $this->addType('timestamp', Types::INTEGER); + } + + #[\ReturnTypeWillChange] + public function jsonSerialize() { + return [ + 'id' => $this->id, + 'session_id' => $this->sessionId, + 'role' => $this->role, + 'content' => $this->content, + 'timestamp' => $this->timestamp, + ]; + } +} diff --git a/lib/Db/ChattyLLM/MessageMapper.php b/lib/Db/ChattyLLM/MessageMapper.php new file mode 100644 index 00000000..860df08e --- /dev/null +++ b/lib/Db/ChattyLLM/MessageMapper.php @@ -0,0 +1,125 @@ + + * + * @author Anupam Kumar + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace OCA\Assistant\Db\ChattyLLM; + +use OCP\AppFramework\Db\QBMapper; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; + +/** + * @extends QBMapper + */ +class MessageMapper extends QBMapper { + public function __construct(IDBConnection $db) { + parent::__construct($db, 'assistant_chat_msgs', Message::class); + } + + /** + * @param integer $sessionId + * @param integer $n + * @return Message + * @throws \OCP\DB\Exception + * @throws \RuntimeException + * @throws \OCP\AppFramework\Db\DoesNotExistException + * @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException + */ + public function getFirstNMessages(int $sessionId, int $n = 1): Message { + $qb = $this->db->getQueryBuilder(); + $qb->select(Message::$columns) + ->from($this->getTableName()) + ->where($qb->expr()->eq('session_id', $qb->createPositionalParameter($sessionId, IQueryBuilder::PARAM_INT))) + ->setMaxResults($n); + + return $this->findEntity($qb); + } + + /** + * @param int $sessionId + * @param int $cursor + * @param int $limit + * @return array + * @throws \OCP\DB\Exception + */ + public function getMessages(int $sessionId, int $cursor, int $limit): array { + $qb = $this->db->getQueryBuilder(); + $qb->select(Message::$columns) + ->from($this->getTableName()) + ->where($qb->expr()->eq('session_id', $qb->createPositionalParameter($sessionId, IQueryBuilder::PARAM_INT))) + ->orderBy('id', 'DESC') + ->setFirstResult($cursor); + + if ($limit > 0) { + $qb->setMaxResults($limit); + } + + $messages = $this->findEntities($qb); + return array_reverse($messages); + } + + /** + * @param int $sessionId + * @throws \OCP\DB\Exception + * @throws \RuntimeException + * @return void + */ + public function deleteMessagesBySession(int $sessionId): void { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->getTableName()) + ->where($qb->expr()->eq('session_id', $qb->createPositionalParameter($sessionId, IQueryBuilder::PARAM_INT))); + + $qb->executeStatement(); + } + + /** + * @param integer $messageId + * @throws \OCP\DB\Exception + * @throws \RuntimeException + * @return void + */ + public function deleteMessageById(int $messageId): void { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->getTableName()) + ->where($qb->expr()->eq('id', $qb->createPositionalParameter($messageId, IQueryBuilder::PARAM_INT))); + + $qb->executeStatement(); + } + + /** + * @param integer $sessionId + * @param integer $messageId + * @throws \OCP\DB\Exception + * @throws \RuntimeException + * @return void + */ + public function deleteMessagesSinceId(int $sessionId, int $messageId): void { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->getTableName()) + ->where($qb->expr()->eq('session_id', $qb->createPositionalParameter($sessionId, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->gte('id', $qb->createPositionalParameter($messageId, IQueryBuilder::PARAM_INT))); + + $qb->executeStatement(); + } +} diff --git a/lib/Db/ChattyLLM/Session.php b/lib/Db/ChattyLLM/Session.php new file mode 100644 index 00000000..a6c19a1a --- /dev/null +++ b/lib/Db/ChattyLLM/Session.php @@ -0,0 +1,75 @@ + + * + * @author Anupam Kumar + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace OCA\Assistant\Db\ChattyLLM; + +use OCP\AppFramework\Db\Entity; +use OCP\DB\Types; + +/** + * @method \string getUserId() + * @method \void setUserId(string $userId) + * @method \?string getTitle() + * @method \void setTitle(?string $title) + * @method \int|null getTimestamp() + * @method \void setTimestamp(?int $timestamp) + */ +class Session extends Entity implements \JsonSerializable { + /** @var string */ + protected $userId; + /** @var string */ + protected $title; + /** @var int */ + protected $timestamp; + + public static $columns = [ + 'id', + 'user_id', + 'title', + 'timestamp', + ]; + public static $fields = [ + 'id', + 'userId', + 'title', + 'timestamp', + ]; + + public function __construct() { + $this->addType('user_id', Types::STRING); + $this->addType('title', Types::STRING); + $this->addType('timestamp', Types::INTEGER); + } + + #[\ReturnTypeWillChange] + public function jsonSerialize() { + return [ + 'id' => $this->id, + 'user_id' => $this->userId, + 'title' => $this->title, + 'timestamp' => $this->timestamp, + ]; + } +} diff --git a/lib/Db/ChattyLLM/SessionMapper.php b/lib/Db/ChattyLLM/SessionMapper.php new file mode 100644 index 00000000..c86b02df --- /dev/null +++ b/lib/Db/ChattyLLM/SessionMapper.php @@ -0,0 +1,102 @@ + + * + * @author Anupam Kumar + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace OCA\Assistant\Db\ChattyLLM; + +use OCP\AppFramework\Db\QBMapper; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; + +/** + * @extends QBMapper + */ +class SessionMapper extends QBMapper { + public function __construct(IDBConnection $db) { + parent::__construct($db, 'assistant_chat_sns', Session::class); + } + + /** + * @param integer $sessionId + * @return boolean + * @throws \OCP\DB\Exception + */ + public function exists(int $sessionId): bool { + $qb = $this->db->getQueryBuilder(); + $qb->select('id') + ->from($this->getTableName()) + ->where($qb->expr()->eq('id', $qb->createPositionalParameter($sessionId, IQueryBuilder::PARAM_INT))); + + try { + return $this->findEntity($qb) !== null; + } catch (\OCP\AppFramework\Db\DoesNotExistException $e) { + return false; + } catch (\OCP\AppFramework\Db\MultipleObjectsReturnedException $e) { + return true; + } + } + + /** + * @param string $userId + * @return array + * @throws \OCP\DB\Exception + */ + public function getUserSessions(string $userId): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('id', 'title', 'timestamp') + ->from($this->getTableName()) + ->where($qb->expr()->eq('user_id', $qb->createPositionalParameter($userId, IQueryBuilder::PARAM_STR))) + ->orderBy('timestamp', 'DESC'); + + return $this->findEntities($qb); + } + + /** + * @param integer $sessionId + * @param string $title + * @throws \OCP\DB\Exception + * @throws \RuntimeException + */ + public function updateSessionTitle(int $sessionId, string $title) { + $qb = $this->db->getQueryBuilder(); + $qb->update($this->getTableName()) + ->set('title', $qb->createPositionalParameter($title, IQueryBuilder::PARAM_STR)) + ->where($qb->expr()->eq('id', $qb->createPositionalParameter($sessionId, IQueryBuilder::PARAM_INT))); + + $qb->executeStatement(); + } + + /** + * @param integer $sessionId + * @throws \OCP\DB\Exception + * @throws \RuntimeException + */ + public function deleteSession(int $sessionId) { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->getTableName()) + ->where($qb->expr()->eq('id', $qb->createPositionalParameter($sessionId, IQueryBuilder::PARAM_INT))); + + $qb->executeStatement(); + } +} diff --git a/lib/Listener/BeforeTemplateRenderedListener.php b/lib/Listener/BeforeTemplateRenderedListener.php index b06de898..a62c2aff 100644 --- a/lib/Listener/BeforeTemplateRenderedListener.php +++ b/lib/Listener/BeforeTemplateRenderedListener.php @@ -8,7 +8,9 @@ use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent; use OCP\AppFramework\Http\TemplateResponse; use OCP\AppFramework\Services\IInitialState; +use OCP\Collaboration\Reference\RenderReferenceEvent; use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventDispatcher; use OCP\EventDispatcher\IEventListener; use OCP\IConfig; use OCP\IUser; @@ -24,6 +26,7 @@ public function __construct( private IUserSession $userSession, private IConfig $config, private IInitialState $initialStateService, + private IEventDispatcher $eventDispatcher, private ?string $userId, ) { } @@ -42,6 +45,11 @@ public function handle(Event $event): void { return; } + $templateName = $event->getResponse()->getTemplateName(); + if ($templateName === 'index' || $templateName === 'assistantPage') { + $this->eventDispatcher->dispatchTyped(new RenderReferenceEvent()); + } + $adminAssistantEnabled = $this->config->getAppValue(Application::APP_ID, 'assistant_enabled', '1') === '1'; $userAssistantEnabled = $this->config->getUserValue($this->userId, Application::APP_ID, 'assistant_enabled', '1') === '1'; $assistantEnabled = $adminAssistantEnabled && $userAssistantEnabled; diff --git a/lib/Migration/Version010010Date20240430083738.php b/lib/Migration/Version010010Date20240430083738.php new file mode 100644 index 00000000..b508dd3a --- /dev/null +++ b/lib/Migration/Version010010Date20240430083738.php @@ -0,0 +1,94 @@ + + * + * @author Anupam Kumar + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace OCA\Assistant\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +class Version010010Date20240430083738 extends SimpleMigrationStep { + + /** + * @param IOutput $output + * @param Closure(): ISchemaWrapper $schemaClosure + * @param array $options + * @return null|ISchemaWrapper + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + $schemaChanged = false; + + if (!$schema->hasTable('assistant_chat_sns')) { + $schemaChanged = true; + $table = $schema->createTable('assistant_chat_sns'); + $table->addColumn('id', Types::INTEGER, [ + 'autoincrement' => true, + ]); + $table->addColumn('user_id', 'string', [ + 'notnull' => true, + 'length' => 256, + ]); + $table->addColumn('title', 'string', [ + 'notnull' => false, + 'length' => 256, + ]); + $table->addColumn('timestamp', Types::BIGINT, [ + 'notnull' => true, + 'unsigned' => true, + ]); + $table->setPrimaryKey(['id']); + $table->addIndex(['user_id'], 'assistant_chat_ss_uid'); + } + + if (!$schema->hasTable('assistant_chat_msgs')) { + $schemaChanged = true; + $table = $schema->createTable('assistant_chat_msgs'); + $table->addColumn('id', Types::INTEGER, [ + 'autoincrement' => true, + ]); + $table->addColumn('session_id', Types::INTEGER, [ + 'notnull' => true, + ]); + $table->addColumn('role', Types::STRING, [ + 'length' => 256, + ]); + $table->addColumn('content', Types::TEXT, [ + 'notnull' => true, + ]); + $table->addColumn('timestamp', Types::BIGINT, [ + 'notnull' => true, + 'unsigned' => true, + ]); + $table->setPrimaryKey(['id']); + $table->addIndex(['session_id'], 'assistant_chat_ms_sid'); + } + + return $schemaChanged ? $schema : null; + } +} diff --git a/lib/Service/AssistantService.php b/lib/Service/AssistantService.php index 965a1773..a92472c6 100644 --- a/lib/Service/AssistantService.php +++ b/lib/Service/AssistantService.php @@ -84,6 +84,11 @@ public function getAvailableTaskTypes(): array { 'name' => $this->l10n->t('Context write'), 'description' => $this->l10n->t('Writes text in a given style based on the provided source material.'), ]; + $types[] = [ + 'id' => 'chatty-llm', + 'name' => $this->l10n->t('Chat with AI'), + 'description' => $this->l10n->t('Chat with an AI model.'), + ]; } else { $types[] = [ 'id' => $typeClass, diff --git a/lib/Settings/Admin.php b/lib/Settings/Admin.php index 69c72e18..d252661f 100644 --- a/lib/Settings/Admin.php +++ b/lib/Settings/Admin.php @@ -6,6 +6,7 @@ use OCP\AppFramework\Http\TemplateResponse; use OCP\AppFramework\Services\IInitialState; use OCP\IConfig; +use OCP\IL10N; use OCP\Settings\ISettings; use OCP\SpeechToText\ISpeechToTextManager; use OCP\TextProcessing\FreePromptTaskType; @@ -21,6 +22,7 @@ public function __construct( private ITextToImageManager $textToImageManager, private ITextProcessingManager $textProcessingManager, private ISpeechToTextManager $speechToTextManager, + private IL10N $l10n, ) { } @@ -37,6 +39,9 @@ public function getForm(): TemplateResponse { $freePromptPickerEnabled = $this->config->getAppValue(Application::APP_ID, 'free_prompt_picker_enabled', '1') === '1'; $speechToTextAvailable = $this->speechToTextManager->hasProviders(); $speechToTextEnabled = $this->config->getAppValue(Application::APP_ID, 'speech_to_text_picker_enabled', '1') === '1'; + $chattyLLMUserInstructions = $this->config->getAppValue(Application::APP_ID, 'chat_user_instructions', $this->l10n->t(Application::CHAT_USER_INSTRUCTIONS)); + $chattyLLMUserInstructionsTitle = $this->config->getAppValue(Application::APP_ID, 'chat_user_instructions_title', $this->l10n->t(Application::CHAT_USER_INSTRUCTIONS_TITLE)); + $chattyLLMLastNMessages = (int) $this->config->getAppValue(Application::APP_ID, 'chat_last_n_messages', '10'); $adminConfig = [ 'text_processing_available' => $textProcessingAvailable, @@ -48,6 +53,9 @@ public function getForm(): TemplateResponse { 'free_prompt_picker_enabled' => $freePromptPickerEnabled, 'speech_to_text_picker_available' => $speechToTextAvailable, 'speech_to_text_picker_enabled' => $speechToTextEnabled, + 'chat_user_instructions' => $chattyLLMUserInstructions, + 'chat_user_instructions_title' => $chattyLLMUserInstructionsTitle, + 'chat_last_n_messages' => $chattyLLMLastNMessages, ]; $this->initialStateService->provideInitialState('admin-config', $adminConfig); diff --git a/src/assistant.js b/src/assistant.js index 3b7d2f65..10de48a4 100644 --- a/src/assistant.js +++ b/src/assistant.js @@ -366,6 +366,11 @@ async function showAssistantTaskResult(taskId) { console.debug('showing results for task', response.data?.ocs?.data?.task) openAssistantTask(response.data?.ocs?.data?.task) }).catch(error => { + if (error.response?.status === 401) { + showError(t('assistant', 'Please log in to view the task result')) + return + } + console.error(error) showError(t('assistant', 'This task does not exist or has been cleaned up')) }) diff --git a/src/components/AdminSettings.vue b/src/components/AdminSettings.vue index a99887a8..951ce79f 100644 --- a/src/components/AdminSettings.vue +++ b/src/components/AdminSettings.vue @@ -82,22 +82,84 @@ @update:value="onUnsignedIntFieldChanged(state.max_image_generation_idle_time, 'max_image_generation_idle_time')" /> +
+

+ {{ t('assistant', 'Chat with AI') }} +

+
+ +
+ +

{{ t('assistant', 'It is passed on to the LLM for it to better understand the context.') }}

+

{{ t('assistant', '"{user}" is a placeholder for the user\'s display name.') }}

+
+ +
+ +
+ +

{{ t('assistant', 'It is passed on to the LLMs to let it know what to do') }}

+

{{ t('assistant', '"{user}" is a placeholder for the user\'s display name here as well.') }}

+
+ +
+ +
+ +

{{ t('assistant', ' This includes the user instructions and the LLM\'s messages') }}

+
+ +
+ + diff --git a/src/components/ChattyLLM/ConversationBox.vue b/src/components/ChattyLLM/ConversationBox.vue new file mode 100644 index 00000000..109ce652 --- /dev/null +++ b/src/components/ChattyLLM/ConversationBox.vue @@ -0,0 +1,120 @@ + + + + + diff --git a/src/components/ChattyLLM/InputArea.vue b/src/components/ChattyLLM/InputArea.vue new file mode 100644 index 00000000..4580aeac --- /dev/null +++ b/src/components/ChattyLLM/InputArea.vue @@ -0,0 +1,151 @@ + + + + + diff --git a/src/components/ChattyLLM/Message.vue b/src/components/ChattyLLM/Message.vue new file mode 100644 index 00000000..4e93e5a8 --- /dev/null +++ b/src/components/ChattyLLM/Message.vue @@ -0,0 +1,146 @@ + + + + + diff --git a/src/components/ChattyLLM/MessageActions.vue b/src/components/ChattyLLM/MessageActions.vue new file mode 100644 index 00000000..52905d8e --- /dev/null +++ b/src/components/ChattyLLM/MessageActions.vue @@ -0,0 +1,89 @@ + + + + + diff --git a/src/components/ChattyLLM/NoSession.vue b/src/components/ChattyLLM/NoSession.vue new file mode 100644 index 00000000..48c0fd9d --- /dev/null +++ b/src/components/ChattyLLM/NoSession.vue @@ -0,0 +1,41 @@ + + + + + diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 00000000..222a6494 --- /dev/null +++ b/src/utils.js @@ -0,0 +1,7 @@ +'use strict' + +let mytimer = 0 +export function delay(callback, ms = 0) { + clearTimeout(mytimer) + mytimer = setTimeout(callback, ms) +} From c277ef2e2c976d83909bcfda419bf021f5c3d270 Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Tue, 28 May 2024 17:52:08 +0200 Subject: [PATCH 02/26] always dispatch RenderReferenceEvent, fix NcRichtext missing prop Signed-off-by: Julien Veyssier --- lib/Listener/BeforeTemplateRenderedListener.php | 5 +---- src/components/ChattyLLM/Message.vue | 1 + 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/Listener/BeforeTemplateRenderedListener.php b/lib/Listener/BeforeTemplateRenderedListener.php index a62c2aff..ba3c872c 100644 --- a/lib/Listener/BeforeTemplateRenderedListener.php +++ b/lib/Listener/BeforeTemplateRenderedListener.php @@ -45,10 +45,7 @@ public function handle(Event $event): void { return; } - $templateName = $event->getResponse()->getTemplateName(); - if ($templateName === 'index' || $templateName === 'assistantPage') { - $this->eventDispatcher->dispatchTyped(new RenderReferenceEvent()); - } + $this->eventDispatcher->dispatchTyped(new RenderReferenceEvent()); $adminAssistantEnabled = $this->config->getAppValue(Application::APP_ID, 'assistant_enabled', '1') === '1'; $userAssistantEnabled = $this->config->getUserValue($this->userId, Application::APP_ID, 'assistant_enabled', '1') === '1'; diff --git a/src/components/ChattyLLM/Message.vue b/src/components/ChattyLLM/Message.vue index 4e93e5a8..15a20fc0 100644 --- a/src/components/ChattyLLM/Message.vue +++ b/src/components/ChattyLLM/Message.vue @@ -33,6 +33,7 @@ From 35da734de4a2dc533676701ebcde887851faf47c Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Tue, 28 May 2024 19:32:15 +0200 Subject: [PATCH 03/26] fix scroll for the chattyUI, navigation still does not take full height if no session is selected Signed-off-by: Julien Veyssier --- src/components/AssistantFormInputs.vue | 7 ++++--- src/components/AssistantTextProcessingForm.vue | 5 ++++- src/components/AssistantTextProcessingModal.vue | 16 +++++++--------- src/components/ChattyLLM/ChattyLLMInputForm.vue | 4 ++-- src/components/ChattyLLM/NoSession.vue | 2 +- 5 files changed, 18 insertions(+), 16 deletions(-) diff --git a/src/components/AssistantFormInputs.vue b/src/components/AssistantFormInputs.vue index 35958b33..b4329f7b 100644 --- a/src/components/AssistantFormInputs.vue +++ b/src/components/AssistantFormInputs.vue @@ -56,9 +56,7 @@ @update:value="onUpdateCopywriter" /> -
- -
+
@@ -318,6 +316,9 @@ export default { @@ -178,13 +174,14 @@ export default { } .assistant-modal--wrapper { - //width: 100%; - padding: 16px; + width: 100%; + display: flex; overflow-y: auto; } .assistant-modal--content { width: 100%; + padding: 16px; display: flex; flex-direction: column; align-items: center; @@ -201,6 +198,7 @@ export default { .form { width: 100%; + height: 100%; } } diff --git a/src/components/ChattyLLM/ChattyLLMInputForm.vue b/src/components/ChattyLLM/ChattyLLMInputForm.vue index 4a4aab99..c87b51bd 100644 --- a/src/components/ChattyLLM/ChattyLLMInputForm.vue +++ b/src/components/ChattyLLM/ChattyLLMInputForm.vue @@ -517,9 +517,9 @@ export default { From 2ec8ed380e9461a4aef5e4bc23571569e278312c Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Wed, 29 May 2024 13:14:18 +0200 Subject: [PATCH 05/26] fix: make height of chattyUI constant (max), center emptyContent, fix assistant standalone page style Signed-off-by: Julien Veyssier --- src/components/AssistantFormInputs.vue | 1 + src/components/ChattyLLM/ChattyLLMInputForm.vue | 2 ++ src/components/ChattyLLM/NoSession.vue | 1 - src/views/AssistantPage.vue | 1 + 4 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/AssistantFormInputs.vue b/src/components/AssistantFormInputs.vue index b4329f7b..ce496dfd 100644 --- a/src/components/AssistantFormInputs.vue +++ b/src/components/AssistantFormInputs.vue @@ -318,6 +318,7 @@ export default { + From 70cb6244be48859ed59910f9bb043e0ea228fb42 Mon Sep 17 00:00:00 2001 From: Anupam Kumar Date: Wed, 12 Jun 2024 16:00:14 +0530 Subject: [PATCH 15/26] no translation of the user prompts Signed-off-by: Anupam Kumar --- lib/Settings/Admin.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/Settings/Admin.php b/lib/Settings/Admin.php index d252661f..69183ac9 100644 --- a/lib/Settings/Admin.php +++ b/lib/Settings/Admin.php @@ -39,8 +39,8 @@ public function getForm(): TemplateResponse { $freePromptPickerEnabled = $this->config->getAppValue(Application::APP_ID, 'free_prompt_picker_enabled', '1') === '1'; $speechToTextAvailable = $this->speechToTextManager->hasProviders(); $speechToTextEnabled = $this->config->getAppValue(Application::APP_ID, 'speech_to_text_picker_enabled', '1') === '1'; - $chattyLLMUserInstructions = $this->config->getAppValue(Application::APP_ID, 'chat_user_instructions', $this->l10n->t(Application::CHAT_USER_INSTRUCTIONS)); - $chattyLLMUserInstructionsTitle = $this->config->getAppValue(Application::APP_ID, 'chat_user_instructions_title', $this->l10n->t(Application::CHAT_USER_INSTRUCTIONS_TITLE)); + $chattyLLMUserInstructions = $this->config->getAppValue(Application::APP_ID, 'chat_user_instructions', Application::CHAT_USER_INSTRUCTIONS); + $chattyLLMUserInstructionsTitle = $this->config->getAppValue(Application::APP_ID, 'chat_user_instructions_title', Application::CHAT_USER_INSTRUCTIONS_TITLE); $chattyLLMLastNMessages = (int) $this->config->getAppValue(Application::APP_ID, 'chat_last_n_messages', '10'); $adminConfig = [ From 47d49a011b849a86149741af985b5ad54611efe7 Mon Sep 17 00:00:00 2001 From: Anupam Kumar Date: Wed, 12 Jun 2024 16:36:11 +0530 Subject: [PATCH 16/26] move admin docs to nextcloud documentation Signed-off-by: Anupam Kumar --- docs/README.md | 1 - docs/admin/README.md | 76 -------------------------------------------- 2 files changed, 77 deletions(-) delete mode 100644 docs/admin/README.md diff --git a/docs/README.md b/docs/README.md index 963ea5cf..cb629128 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,5 +2,4 @@ * [User documentation](./user) * [Developer documentation](./developer) -* [Admin documentation](./admin) * [AI admin Nextcloud documentation](https://docs.nextcloud.com/server/latest/admin_manual/ai/index.html) diff --git a/docs/admin/README.md b/docs/admin/README.md deleted file mode 100644 index 10fd03f3..00000000 --- a/docs/admin/README.md +++ /dev/null @@ -1,76 +0,0 @@ -# Admin documentation - -## Admin settings - -The Assistant admin settings can be found under the "Artificial intelligence" section. -You can disable the assistant top menu entry there. You can also disable the AI-related smart pickers. -The commands to change the options are also listed in. - -## Assistant configuration - -1. Top-right Assistant - -``` -occ config:app:set assistant assistant_enabled --value=1 -``` - -To enable/disable the assistant button from the top-right corner for all the users. - -2. AI text generation smart picker - -``` -occ config:app:set assistant free_prompt_picker_enabled --value=1 -``` - -To enable/disable the AI text generation smart picker for all the users. - -3. Text-to-image smart picker - -``` -occ config:app:set assistant text_to_image_picker_enabled --value=1 -``` - -To enable/disable the text-to-image smart picker for all the users. - -4. Speech-to-text smart picker - -``` -occ config:app:set assistant speech_to_text_picker_enabled --value=1 -``` - -To enable/disable the speech-to-text smart picker for all the users. - -### Image storage - -Days until generated images are deleted if they are not viewed. - -``` -occ config:app:set assistant max_image_generation_idle_time --value=90 -``` - -### Chat with AI - -1. Chat User Instructions for Chat Completions - -``` -occ config:app:set assistant chat_user_instructions --value="hello world" -``` - -The user instructions that are prepended before the chat messages for the AI model to understand the context of the block of text. This is a good place not only to instruct the AI model to be polite and kind but also to for example answer all the queries in a particular language or better yet, follow the user's language. The sky is the limit. - -2. Chat User Instructions for Title Generation - -``` -occ config:app:set assistant chat_user_instructions_title --value="hello title" -``` - -This field is appended to the block of chat messages, i.e. attached after the messages. It is done this way to allow it to be used even with text completion models which could have the instructions as "The title for the above conversation could be \"". - -3. Last N messages to consider for chat completions - -``` -occ config:app:set assistant chat_last_n_messages --value=10 -``` - -The number of latest messages to consider for generating the next message. This does not include the user instructions, which is always considered in addition to this. This value should be adjusted in case you are hitting the token limit in your conversations too often. -The AI text generation provider should ideally handle the max token limit case. From 8c26c138bffa8ec54160f5fa8c595deb53637151 Mon Sep 17 00:00:00 2001 From: Anupam Kumar Date: Wed, 12 Jun 2024 17:50:09 +0530 Subject: [PATCH 17/26] more style fixes 1. remove border from sidebar collapse button 2. new conversation button horizontal align with collapse btn and title 3. collapse btn align with avatars 4. left align in assistant admin settings Signed-off-by: Anupam Kumar --- src/components/AdminSettings.vue | 3 --- src/components/ChattyLLM/ChattyLLMInputForm.vue | 12 +++++++++--- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/components/AdminSettings.vue b/src/components/AdminSettings.vue index 951ce79f..e0b3eded 100644 --- a/src/components/AdminSettings.vue +++ b/src/components/AdminSettings.vue @@ -246,9 +246,6 @@ export default { diff --git a/src/utils.js b/src/utils.js index 222a6494..7fe4b49f 100644 --- a/src/utils.js +++ b/src/utils.js @@ -5,3 +5,19 @@ export function delay(callback, ms = 0) { clearTimeout(mytimer) mytimer = setTimeout(callback, ms) } + +/** + * Parse special symbols in text like & < > § + * FIXME upstream: https://github.com/nextcloud-libraries/nextcloud-vue/issues/4492 + * + * @param {string} text The text to parse + */ +export function parseSpecialSymbols(text) { + const temp = document.createElement('textarea') + temp.innerHTML = text.replace(/&/gmi, '&') + text = temp.value.replace(/&/gmi, '&').replace(/</gmi, '<') + .replace(/>/gmi, '>').replace(/§/gmi, 'ยง') + .replace(/^\s+|\s+$/g, '') // remove trailing and leading whitespaces + .replace(/\r\n|\n|\r/gm, '\n') // remove line breaks + return text +} From 8a15a5bf9e70df39b6319662fc1e0c7a0ed21ac7 Mon Sep 17 00:00:00 2001 From: Anupam Kumar Date: Tue, 18 Jun 2024 14:57:44 +0530 Subject: [PATCH 25/26] remove countdown and limit title to 100 chars Signed-off-by: Anupam Kumar --- .../ChattyLLM/ChattyLLMInputForm.vue | 4 +- src/components/ChattyLLM/ConversationBox.vue | 1 + .../ChattyLLM/EditableTextField.vue | 64 ++----------------- 3 files changed, 9 insertions(+), 60 deletions(-) diff --git a/src/components/ChattyLLM/ChattyLLMInputForm.vue b/src/components/ChattyLLM/ChattyLLMInputForm.vue index d6bd206b..3dd0ee54 100644 --- a/src/components/ChattyLLM/ChattyLLMInputForm.vue +++ b/src/components/ChattyLLM/ChattyLLMInputForm.vue @@ -47,7 +47,7 @@ :editing.sync="editingTitle" :placeholder="t('assistant', 'Conversation title')" :loading="loading.updateTitle" - :max-length="140" + :max-length="100" @submit-text="onEditSessionTitle" />
@@ -293,7 +293,7 @@ export default { } if (session.title?.trim()) { - return session.title.length > 140 ? session.title.trim().slice(0, 140) + '...' : session.title.trim() + return session.title.length > 100 ? session.title.trim().slice(0, 100) + '...' : session.title.trim() } return session.timestamp ? (' ' + moment(session.timestamp * 1000).format('LLL')) : t('assistant', 'Untitled conversation') diff --git a/src/components/ChattyLLM/ConversationBox.vue b/src/components/ChattyLLM/ConversationBox.vue index 4e137f0d..a7966ba2 100644 --- a/src/components/ChattyLLM/ConversationBox.vue +++ b/src/components/ChattyLLM/ConversationBox.vue @@ -113,6 +113,7 @@ export default { display: flex; flex-direction: column; gap: 0.5em; + height: 100%; &__message--dim { opacity: 0.5; diff --git a/src/components/ChattyLLM/EditableTextField.vue b/src/components/ChattyLLM/EditableTextField.vue index 8e678e45..0f254a7d 100644 --- a/src/components/ChattyLLM/EditableTextField.vue +++ b/src/components/ChattyLLM/EditableTextField.vue @@ -13,6 +13,7 @@ :use-extended-markdown="true" /> -
- {{ charactersCountDown }} -
@@ -126,31 +120,6 @@ export default { } }, - computed: { - canSubmit() { - return this.charactersCount <= this.maxLength && this.text !== this.initialText - }, - - charactersCount() { - return this.text.length - }, - - charactersCountDown() { - return this.maxLength - this.charactersCount - }, - - showCountDown() { - return this.charactersCount >= this.maxLength - 20 - }, - - countDownWarningText() { - return t('assistant', 'The text must be less than or equal to {maxLength} characters long. Your current text is {charactersCount} characters long.', { - maxLength: this.maxLength, - charactersCount: this.charactersCount, - }) - }, - }, - watch: { // Each time the prop changes, reflect the changes in the value stored in this component initialText(newValue) { @@ -169,8 +138,12 @@ export default { }, methods: { + canSubmit() { + return this.text.length <= this.maxLength && this.text !== this.initialText + }, + handleSubmitText() { - if (!this.canSubmit) { + if (!this.canSubmit()) { return } @@ -191,9 +164,6 @@ export default { From 89c93c18f25fe982ba335ed990188501ceceae40 Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Tue, 18 Jun 2024 18:28:51 +0200 Subject: [PATCH 26/26] fix: force --default-clickable-area value for message action (server one is smaller than the button Signed-off-by: Julien Veyssier --- src/components/ChattyLLM/MessageActions.vue | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/ChattyLLM/MessageActions.vue b/src/components/ChattyLLM/MessageActions.vue index 52905d8e..5f933cde 100644 --- a/src/components/ChattyLLM/MessageActions.vue +++ b/src/components/ChattyLLM/MessageActions.vue @@ -75,6 +75,7 @@ export default {