diff --git a/lib/Service/AssistantService.php b/lib/Service/AssistantService.php index b7a69fa4..bb188542 100644 --- a/lib/Service/AssistantService.php +++ b/lib/Service/AssistantService.php @@ -24,6 +24,7 @@ use OCP\Files\InvalidPathException; use OCP\Files\IRootFolder; use OCP\Files\NotPermittedException; +use OCP\IAppConfig; use OCP\IConfig; use OCP\IL10N; use OCP\ITempManager; @@ -91,6 +92,7 @@ public function __construct( private IL10N $l10n, private ITempManager $tempManager, private IConfig $config, + private IAppConfig $appConfig, private IShareManager $shareManager, private SystemTagService $systemTagService, ) { @@ -522,7 +524,19 @@ public function storeInputFile(string $userId, string $tempFileLocation, ?string public function getAssistantDataFolder(string $userId): Folder { $userFolder = $this->rootFolder->getUserFolder($userId); - $dataFolderName = $this->config->getUserValue($userId, Application::APP_ID, 'data_folder', Application::ASSISTANT_DATA_FOLDER_NAME) ?: Application::ASSISTANT_DATA_FOLDER_NAME; + $defaultFolderName = $this->appConfig->getValueString(Application::APP_ID, 'default_data_folder', Application::ASSISTANT_DATA_FOLDER_NAME, lazy: true); + if ($defaultFolderName === '') { + $defaultFolderName = Application::ASSISTANT_DATA_FOLDER_NAME; + } + $dataFolderName = $this->config->getUserValue($userId, Application::APP_ID, 'data_folder', ''); + if ($dataFolderName === '') { + // No folder stored for this user. If they already have one under the built-in + // name, from before an administrator set a default, keep using it: applying the + // default here would start a second folder and leave their existing output behind. + $dataFolderName = $userFolder->nodeExists(Application::ASSISTANT_DATA_FOLDER_NAME) + ? Application::ASSISTANT_DATA_FOLDER_NAME + : $defaultFolderName; + } if ($userFolder->nodeExists($dataFolderName)) { $dataFolderNode = $userFolder->get($dataFolderName); if ($dataFolderNode instanceof Folder && $dataFolderNode->isCreatable()) { @@ -530,7 +544,7 @@ public function getAssistantDataFolder(string $userId): Folder { } } // it does not exist or is not a folder or does not have write permissions: we create one - $dataFolder = $this->createAssistantDataFolder($userId); + $dataFolder = $this->createAssistantDataFolder($userId, $dataFolderName); $dataFolderName = $dataFolder->getName(); $this->config->setUserValue($userId, Application::APP_ID, 'data_folder', $dataFolderName); return $dataFolder; @@ -538,17 +552,18 @@ public function getAssistantDataFolder(string $userId): Folder { /** * @param string $userId + * @param string $baseName * @param int $try * @return Folder * @throws NoUserException * @throws NotPermittedException */ - private function createAssistantDataFolder(string $userId, int $try = 0): Folder { + private function createAssistantDataFolder(string $userId, string $baseName, int $try = 0): Folder { $userFolder = $this->rootFolder->getUserFolder($userId); if ($try === 0) { - $folderPath = Application::ASSISTANT_DATA_FOLDER_NAME; + $folderPath = $baseName; } else { - $folderPath = Application::ASSISTANT_DATA_FOLDER_NAME . ' ' . $try; + $folderPath = $baseName . ' ' . $try; } if ($userFolder->nodeExists($folderPath)) { @@ -556,7 +571,7 @@ private function createAssistantDataFolder(string $userId, int $try = 0): Folder // give up throw new RuntimeException('Could not create the assistant data folder'); } - return $this->createAssistantDataFolder($userId, $try + 1); + return $this->createAssistantDataFolder($userId, $baseName, $try + 1); } return $userFolder->newFolder($folderPath); diff --git a/lib/Settings/Admin.php b/lib/Settings/Admin.php index 052b135c..01647053 100644 --- a/lib/Settings/Admin.php +++ b/lib/Settings/Admin.php @@ -56,6 +56,10 @@ public function getForm(): TemplateResponse { $chattyLLMUserInstructions = $this->appConfig->getValueString(Application::APP_ID, 'chat_user_instructions', Application::CHAT_USER_INSTRUCTIONS, lazy: true) ?: Application::CHAT_USER_INSTRUCTIONS; $chattyLLMUserInstructionsTitle = $this->appConfig->getValueString(Application::APP_ID, 'chat_user_instructions_title', Application::CHAT_USER_INSTRUCTIONS_TITLE, lazy: true) ?: Application::CHAT_USER_INSTRUCTIONS_TITLE; $chattyLLMLastNMessages = (int)$this->appConfig->getValueString(Application::APP_ID, 'chat_last_n_messages', '10', lazy: true); + $defaultDataFolder = $this->appConfig->getValueString(Application::APP_ID, 'default_data_folder', Application::ASSISTANT_DATA_FOLDER_NAME, lazy: true); + if ($defaultDataFolder === '') { + $defaultDataFolder = Application::ASSISTANT_DATA_FOLDER_NAME; + } $globalSkillsConfig = $this->agentSkillsService->getGlobalSkillsConfig(); @@ -73,6 +77,7 @@ public function getForm(): TemplateResponse { 'chat_user_instructions' => $chattyLLMUserInstructions, 'chat_user_instructions_title' => $chattyLLMUserInstructionsTitle, 'chat_last_n_messages' => $chattyLLMLastNMessages, + 'default_data_folder' => $defaultDataFolder, 'context_agent_available' => $contextAgentAvailable, 'global_skills_admin_uid' => $globalSkillsConfig['admin_uid'], 'global_skills_path' => $globalSkillsConfig['path'], diff --git a/lib/Settings/Personal.php b/lib/Settings/Personal.php index 87e087cd..fc8f9f12 100644 --- a/lib/Settings/Personal.php +++ b/lib/Settings/Personal.php @@ -47,6 +47,11 @@ public function getForm(): TemplateResponse { $audioChatAvailable = (class_exists('OCP\\TaskProcessing\\TaskTypes\\AudioToAudioChat') && array_key_exists(\OCP\TaskProcessing\TaskTypes\AudioToAudioChat::ID, $availableTaskTypes)) || (class_exists('OCP\\TaskProcessing\\TaskTypes\\ContextAgentAudioInteraction') && array_key_exists(\OCP\TaskProcessing\TaskTypes\ContextAgentAudioInteraction::ID, $availableTaskTypes)); $autoplayAudioChat = $this->config->getUserValue($this->userId, Application::APP_ID, 'autoplay_audio_chat', '1') === '1'; + $dataFolder = $this->config->getUserValue($this->userId, Application::APP_ID, 'data_folder', ''); + $defaultDataFolder = $this->appConfig->getValueString(Application::APP_ID, 'default_data_folder', Application::ASSISTANT_DATA_FOLDER_NAME, lazy: true); + if ($defaultDataFolder === '') { + $defaultDataFolder = Application::ASSISTANT_DATA_FOLDER_NAME; + } $assistantAvailable = $taskProcessingAvailable && $this->appConfig->getValueString(Application::APP_ID, 'assistant_enabled', '1', lazy: true) === '1'; $assistantEnabled = $this->config->getUserValue($this->userId, Application::APP_ID, 'assistant_enabled', '1') === '1'; @@ -77,6 +82,8 @@ public function getForm(): TemplateResponse { 'speech_to_text_picker_enabled' => $speechToTextPickerEnabled, 'audio_chat_available' => $audioChatAvailable, 'autoplay_audio_chat' => $autoplayAudioChat, + 'data_folder' => $dataFolder, + 'default_data_folder' => $defaultDataFolder, ]; $this->initialStateService->provideInitialState('config', $userConfig); diff --git a/src/components/AdminSettings.vue b/src/components/AdminSettings.vue index 9e41d70d..dea8caa6 100644 --- a/src/components/AdminSettings.vue +++ b/src/components/AdminSettings.vue @@ -118,6 +118,19 @@ +
+

+ {{ t('assistant', 'Data folder') }} +

+ + {{ t('assistant', 'Name of the folder the assistant creates in the files of each user to store generated content. Users can override this in their personal settings. Changing it does not rename folders that already exist.') }} + + +

{{ t('assistant', 'Chat with AI') }} @@ -279,9 +292,13 @@ export default { this.saveOptions({ [key]: this.state[key] ? '1' : '0' }) }, delayedValueUpdate(newValue, key) { + // delay() shares one timer, so a second edit within the delay cancels the + // first callback: queue the value now rather than when the timer fires + this.optionsToSave[key] = newValue delay(() => { - this.optionsToSave[key] = newValue - this.saveOptions(this.optionsToSave) + const values = { ...this.optionsToSave } + this.optionsToSave = {} + this.saveOptions(values) }, 2000) }, async onPickGlobalSkillsFolder() { diff --git a/src/components/PersonalSettings.vue b/src/components/PersonalSettings.vue index be46d9ee..3f8e8815 100644 --- a/src/components/PersonalSettings.vue +++ b/src/components/PersonalSettings.vue @@ -58,6 +58,16 @@ {{ taskNames.join(', ') }}

+
+

{{ t('assistant', 'Data folder') }}

+

{{ t('assistant', 'Where the assistant stores content it generates for you. Leave empty to use the name your administrator has set. Changing it does not move files that are already there.') }}

+ +

{{ t('assistant', 'Remembered conversations') }}

{{ t('assistant', 'The following conversations are remembered by the Assistant Chat and will be taken into account for every new conversation:') }}

@@ -84,10 +94,12 @@ import NcFormBox from '@nextcloud/vue/components/NcFormBox' import NcFormBoxSwitch from '@nextcloud/vue/components/NcFormBoxSwitch' import NcFormBoxButton from '@nextcloud/vue/components/NcFormBoxButton' import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' +import NcTextField from '@nextcloud/vue/components/NcTextField' import MemoryIcon from 'vue-material-design-icons/Memory.vue' import { loadState } from '@nextcloud/initial-state' +import { delay } from '../utils.js' import { generateUrl } from '@nextcloud/router' import axios from '@nextcloud/axios' import { showSuccess, showError } from '@nextcloud/dialogs' @@ -102,6 +114,7 @@ export default { NcFormBoxSwitch, NcFormBoxButton, NcNoteCard, + NcTextField, MemoryIcon, }, @@ -112,6 +125,7 @@ export default { state: loadState('assistant', 'config'), providers: loadState('assistant', 'availableProviders'), rememberedConversations: loadState('assistant', 'rememberedSessions'), + optionsToSave: {}, } }, @@ -130,6 +144,16 @@ export default { }, methods: { + delayedValueUpdate(newValue, key) { + // delay() shares one timer, so a second edit within the delay cancels the + // first callback: queue the value now rather than when the timer fires + this.optionsToSave[key] = newValue + delay(() => { + const values = { ...this.optionsToSave } + this.optionsToSave = {} + this.saveOptions(values) + }, 2000) + }, onCheckboxChanged(newValue, key) { this.state[key] = newValue this.saveOptions({ [key]: this.state[key] ? '1' : '0' })