From 0d6c674b34492330ccdb5326b7764a8bbca675de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Baki=20Burak=20=C3=96=C4=9F=C3=BCn?= Date: Wed, 26 Aug 2026 14:29:47 +0300 Subject: [PATCH 1/5] feat: allow the data folder name to be set server-wide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The folder the assistant creates in a user's files is named from a class constant, so a server whose users do not work in English has no supported way to hand new accounts a localized name. The per-user setting that already exists does not fill the gap either: when the configured folder does not exist yet, createAssistantDataFolder() falls back to the constant and the caller then writes that name back over the preference, so setting it for a user who has not used the assistant silently reverts. Resolve the default from an app config value, keeping the constant as the last resort, and pass the resolved name down to the creation path so it is used for the folder that gets created. This mirrors how Talk resolves its attachment folder. Nothing changes for a server that sets no app config, and a user who already has a folder keeps it. Signed-off-by: Baki Burak Öğün --- lib/Service/AssistantService.php | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/lib/Service/AssistantService.php b/lib/Service/AssistantService.php index b7a69fa4..9bbef598 100644 --- a/lib/Service/AssistantService.php +++ b/lib/Service/AssistantService.php @@ -522,7 +522,8 @@ 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->config->getAppValue(Application::APP_ID, 'default_data_folder', Application::ASSISTANT_DATA_FOLDER_NAME) ?: Application::ASSISTANT_DATA_FOLDER_NAME; + $dataFolderName = $this->config->getUserValue($userId, Application::APP_ID, 'data_folder', $defaultFolderName) ?: $defaultFolderName; if ($userFolder->nodeExists($dataFolderName)) { $dataFolderNode = $userFolder->get($dataFolderName); if ($dataFolderNode instanceof Folder && $dataFolderNode->isCreatable()) { @@ -530,7 +531,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, $defaultFolderName); $dataFolderName = $dataFolder->getName(); $this->config->setUserValue($userId, Application::APP_ID, 'data_folder', $dataFolderName); return $dataFolder; @@ -538,17 +539,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 +558,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); From a8eebb1173d3ee9dc02f354b6d32bdc8393142b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Baki=20Burak=20=C3=96=C4=9F=C3=BCn?= Date: Wed, 26 Aug 2026 17:00:38 +0300 Subject: [PATCH 2/5] fix: use the resolved folder name when creating the data folder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The creation path was passed the server default rather than the name resolved for the user, so a user whose configured folder had been deleted got a folder named after the administrator's default, and their own setting was then overwritten with it. Reported by CodeRabbit on the pull request. Signed-off-by: Baki Burak Öğün --- lib/Service/AssistantService.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/Service/AssistantService.php b/lib/Service/AssistantService.php index 9bbef598..9090fd55 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,7 @@ public function storeInputFile(string $userId, string $tempFileLocation, ?string public function getAssistantDataFolder(string $userId): Folder { $userFolder = $this->rootFolder->getUserFolder($userId); - $defaultFolderName = $this->config->getAppValue(Application::APP_ID, 'default_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) ?: Application::ASSISTANT_DATA_FOLDER_NAME; $dataFolderName = $this->config->getUserValue($userId, Application::APP_ID, 'data_folder', $defaultFolderName) ?: $defaultFolderName; if ($userFolder->nodeExists($dataFolderName)) { $dataFolderNode = $userFolder->get($dataFolderName); @@ -531,7 +533,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, $defaultFolderName); + $dataFolder = $this->createAssistantDataFolder($userId, $dataFolderName); $dataFolderName = $dataFolder->getName(); $this->config->setUserValue($userId, Application::APP_ID, 'data_folder', $dataFolderName); return $dataFolder; From 0ae5cb2c4611ec92d233353dadd21d08aa9c3bbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Baki=20Burak=20=C3=96=C4=9F=C3=BCn?= Date: Wed, 26 Aug 2026 17:00:51 +0300 Subject: [PATCH 3/5] feat: let the data folder name be set from the settings pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the server-wide default to the admin settings and the per-user override to the personal settings, so neither has to be set with occ. The personal field shows the administrator's default as its placeholder and an empty value falls back to it, which keeps the existing precedence visible in the interface. Signed-off-by: Baki Burak Öğün --- lib/Settings/Admin.php | 2 ++ lib/Settings/Personal.php | 4 ++++ src/components/AdminSettings.vue | 13 +++++++++++++ src/components/PersonalSettings.vue | 20 ++++++++++++++++++++ 4 files changed, 39 insertions(+) diff --git a/lib/Settings/Admin.php b/lib/Settings/Admin.php index 052b135c..7cc0c226 100644 --- a/lib/Settings/Admin.php +++ b/lib/Settings/Admin.php @@ -56,6 +56,7 @@ 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) ?: Application::ASSISTANT_DATA_FOLDER_NAME; $globalSkillsConfig = $this->agentSkillsService->getGlobalSkillsConfig(); @@ -73,6 +74,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..c18ac2f7 100644 --- a/lib/Settings/Personal.php +++ b/lib/Settings/Personal.php @@ -47,6 +47,8 @@ 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) ?: 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 +79,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..6b332e94 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') }} diff --git a/src/components/PersonalSettings.vue b/src/components/PersonalSettings.vue index be46d9ee..25cb0d7f 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,12 @@ export default { }, methods: { + delayedValueUpdate(newValue, key) { + delay(() => { + this.optionsToSave[key] = newValue + this.saveOptions(this.optionsToSave) + }, 2000) + }, onCheckboxChanged(newValue, key) { this.state[key] = newValue this.saveOptions({ [key]: this.state[key] ? '1' : '0' }) From d336728529147f7db5abe54215bd9c954c5cae5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Baki=20Burak=20=C3=96=C4=9F=C3=BCn?= Date: Wed, 26 Aug 2026 17:58:58 +0300 Subject: [PATCH 4/5] fix: do not treat a folder name of "0" as unset, and queue delayed saves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues CodeRabbit raised on the pull request. PHP reads "0" as false, so `?: Application::ASSISTANT_DATA_FOLDER_NAME` silently discarded a folder actually named "0" in the service and in both settings pages. Check for an empty string explicitly instead. `delay()` keeps a single module-level timer, so editing a second field within the delay cancelled the first callback before it had queued its value, losing that edit. Queue the value when it changes rather than when the timer fires, and hand the pending set to saveOptions as a snapshot. This is pre-existing behaviour in the admin settings; it is fixed here because the new field is subject to it. Signed-off-by: Baki Burak Öğün --- lib/Service/AssistantService.php | 10 ++++++++-- lib/Settings/Admin.php | 5 ++++- lib/Settings/Personal.php | 5 ++++- src/components/AdminSettings.vue | 8 ++++++-- src/components/PersonalSettings.vue | 8 ++++++-- 5 files changed, 28 insertions(+), 8 deletions(-) diff --git a/lib/Service/AssistantService.php b/lib/Service/AssistantService.php index 9090fd55..50326d77 100644 --- a/lib/Service/AssistantService.php +++ b/lib/Service/AssistantService.php @@ -524,8 +524,14 @@ public function storeInputFile(string $userId, string $tempFileLocation, ?string public function getAssistantDataFolder(string $userId): Folder { $userFolder = $this->rootFolder->getUserFolder($userId); - $defaultFolderName = $this->appConfig->getValueString(Application::APP_ID, 'default_data_folder', Application::ASSISTANT_DATA_FOLDER_NAME, lazy: true) ?: Application::ASSISTANT_DATA_FOLDER_NAME; - $dataFolderName = $this->config->getUserValue($userId, Application::APP_ID, 'data_folder', $defaultFolderName) ?: $defaultFolderName; + $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', $defaultFolderName); + if ($dataFolderName === '') { + $dataFolderName = $defaultFolderName; + } if ($userFolder->nodeExists($dataFolderName)) { $dataFolderNode = $userFolder->get($dataFolderName); if ($dataFolderNode instanceof Folder && $dataFolderNode->isCreatable()) { diff --git a/lib/Settings/Admin.php b/lib/Settings/Admin.php index 7cc0c226..01647053 100644 --- a/lib/Settings/Admin.php +++ b/lib/Settings/Admin.php @@ -56,7 +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) ?: Application::ASSISTANT_DATA_FOLDER_NAME; + $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(); diff --git a/lib/Settings/Personal.php b/lib/Settings/Personal.php index c18ac2f7..fc8f9f12 100644 --- a/lib/Settings/Personal.php +++ b/lib/Settings/Personal.php @@ -48,7 +48,10 @@ public function getForm(): TemplateResponse { || (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) ?: Application::ASSISTANT_DATA_FOLDER_NAME; + $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'; diff --git a/src/components/AdminSettings.vue b/src/components/AdminSettings.vue index 6b332e94..dea8caa6 100644 --- a/src/components/AdminSettings.vue +++ b/src/components/AdminSettings.vue @@ -292,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 25cb0d7f..3f8e8815 100644 --- a/src/components/PersonalSettings.vue +++ b/src/components/PersonalSettings.vue @@ -145,9 +145,13 @@ 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(() => { - this.optionsToSave[key] = newValue - this.saveOptions(this.optionsToSave) + const values = { ...this.optionsToSave } + this.optionsToSave = {} + this.saveOptions(values) }, 2000) }, onCheckboxChanged(newValue, key) { From d3dcaa7520b8171128646c73bb5e036df0948426 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Baki=20Burak=20=C3=96=C4=9F=C3=BCn?= Date: Wed, 26 Aug 2026 21:20:12 +0300 Subject: [PATCH 5/5] fix: keep an existing data folder when no per-user value is stored MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A user who had used the assistant before an administrator set a default may have a folder under the built-in name without a stored data_folder preference. Applying the server default to them started a second folder and left their existing output behind it. Fall back to the built-in name when that folder already exists, so the server default reaches accounts that have nothing to strand. This is also what the admin settings text promises. Signed-off-by: Baki Burak Öğün --- lib/Service/AssistantService.php | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/lib/Service/AssistantService.php b/lib/Service/AssistantService.php index 50326d77..bb188542 100644 --- a/lib/Service/AssistantService.php +++ b/lib/Service/AssistantService.php @@ -528,9 +528,14 @@ public function getAssistantDataFolder(string $userId): Folder { if ($defaultFolderName === '') { $defaultFolderName = Application::ASSISTANT_DATA_FOLDER_NAME; } - $dataFolderName = $this->config->getUserValue($userId, Application::APP_ID, 'data_folder', $defaultFolderName); + $dataFolderName = $this->config->getUserValue($userId, Application::APP_ID, 'data_folder', ''); if ($dataFolderName === '') { - $dataFolderName = $defaultFolderName; + // 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);