From 25b015812b67b716325b3dfc5c65911d4f7a1d6e Mon Sep 17 00:00:00 2001 From: kyteinsky Date: Tue, 30 Jun 2026 09:44:19 +0530 Subject: [PATCH 1/3] feat: add agent skills endpoints Signed-off-by: kyteinsky Assisted-by: Github Copilot: claude-sonnet-4-6 --- appinfo/routes.php | 4 + composer.json | 3 +- composer.lock | 232 +++++++++++++++- lib/Controller/AgentSkillsApiController.php | 141 ++++++++++ lib/Service/AgentSkillsService.php | 277 ++++++++++++++++++++ 5 files changed, 655 insertions(+), 2 deletions(-) create mode 100644 lib/Controller/AgentSkillsApiController.php create mode 100644 lib/Service/AgentSkillsService.php diff --git a/appinfo/routes.php b/appinfo/routes.php index 1022f74c3..4f6ca350a 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -36,6 +36,10 @@ ['name' => 'assistantApi#getOutputFile', 'url' => '/api/{apiVersion}/task/{ocpTaskId}/output-file/{fileId}/download', 'verb' => 'GET', 'requirements' => $requirements], ['name' => 'assistantApi#runFileAction', 'url' => '/api/{apiVersion}/file-action/{fileId}/{taskTypeId}', 'verb' => 'POST', 'requirements' => $requirements], + ['name' => 'agentSkillsApi#listSkills', 'url' => '/api/{apiVersion}/skills', 'verb' => 'GET', 'requirements' => $requirements], + ['name' => 'agentSkillsApi#storeSkill', 'url' => '/api/{apiVersion}/skills', 'verb' => 'POST', 'requirements' => $requirements], + ['name' => 'agentSkillsApi#loadSkill', 'url' => '/api/{apiVersion}/skills/{skillName}', 'verb' => 'GET', 'requirements' => $requirements], + ['name' => 'chattyLLM#newSession', 'url' => '/chat/sessions', 'verb' => 'POST', 'postfix' => 'restful'], ['name' => 'chattyLLM#updateChatSession', 'url' => '/chat/sessions/{sessionId}', 'verb' => 'PUT', 'postfix' => 'restful'], ['name' => 'chattyLLM#deleteSession', 'url' => '/chat/sessions/{sessionId}', 'verb' => 'DELETE', 'postfix' => 'restful'], diff --git a/composer.json b/composer.json index 6c9d816e1..633c4011f 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,8 @@ "phpoffice/phpword": "^1.2", "ralouphie/mimey": "^1.0", "simshaun/recurr": "^5.0", - "smalot/pdfparser": "^2.11" + "smalot/pdfparser": "^2.11", + "symfony/yaml": "^7.4" }, "scripts": { "lint": "find . -name \\*.php -not -path './vendor/*' -print0 | xargs -0 -n1 php -l", diff --git a/composer.lock b/composer.lock index 17bdbe874..3f5dc85f9 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "8b9a5743024889b768ae6fc5144429d8", + "content-hash": "3d7537fc14650c2a4505b517ea72a87e", "packages": [ { "name": "doctrine/collections", @@ -595,6 +595,160 @@ }, "time": "2026-04-17T11:37:58+00:00" }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, { "name": "symfony/polyfill-mbstring", "version": "v1.33.0", @@ -759,6 +913,82 @@ } ], "time": "2026-04-10T18:47:49+00:00" + }, + { + "name": "symfony/yaml", + "version": "v7.4.14", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "f8f328665ace2370d1e10645b807ba1646dc7dcc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/f8f328665ace2370d1e10645b807ba1646dc7dcc", + "reference": "f8f328665ace2370d1e10645b807ba1646dc7dcc", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0" + }, + "bin": [ + "Resources/bin/yaml-lint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Loads and dumps YAML files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/tree/v7.4.14" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-08T20:24:16+00:00" } ], "packages-dev": [ diff --git a/lib/Controller/AgentSkillsApiController.php b/lib/Controller/AgentSkillsApiController.php new file mode 100644 index 000000000..31b7cf251 --- /dev/null +++ b/lib/Controller/AgentSkillsApiController.php @@ -0,0 +1,141 @@ +}, array{}>|DataResponse|DataResponse|DataResponse + * + * 200: Skills listed successfully + * 401: User is not authenticated + * 404: Skills folder not found + * 500: Skills could not be listed + */ + #[NoAdminRequired] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['agent_skills'])] + public function listSkills(): DataResponse { + if ($this->userId === null) { + return new DataResponse(['error' => $this->l10n->t('Unknown user')], Http::STATUS_UNAUTHORIZED); + } + try { + $skills = $this->agentSkillsService->listSkills($this->userId); + return new DataResponse(['skills' => $skills]); + } catch (NoUserException $e) { + return new DataResponse(['error' => $this->l10n->t('Unknown user')], Http::STATUS_UNAUTHORIZED); + } catch (NotFoundException|NotPermittedException $e) { + $this->logger->debug('Skills folder not found', ['exception' => $e]); + return new DataResponse(['error' => 'Skills folder not found'], Http::STATUS_NOT_FOUND); + } catch (\Exception|Throwable $e) { + $this->logger->error('Failed to list skills', ['exception' => $e]); + return new DataResponse(['error' => 'Failed to list skills'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * Load a skill's full content + * + * Returns the full content of the SKILL.md file (frontmatter + body) for the given skill. + * + * @param string $skillName The skill's folder name + * @return DataResponse|DataResponse|DataResponse|DataResponse + * + * 200: Skill content returned + * 401: User is not authenticated + * 404: Skill not found + * 500: Skill could not be loaded + */ + #[NoAdminRequired] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['agent_skills'])] + public function loadSkill(string $skillName): DataResponse { + if ($this->userId === null) { + return new DataResponse(['error' => $this->l10n->t('Unknown user')], Http::STATUS_UNAUTHORIZED); + } + try { + $content = $this->agentSkillsService->loadSkill($this->userId, $skillName); + return new DataResponse(['content' => $content]); + } catch (NoUserException $e) { + return new DataResponse(['error' => $this->l10n->t('Unknown user')], Http::STATUS_UNAUTHORIZED); + } catch (NotFoundException|NotPermittedException $e) { + $this->logger->debug('Skill not found', ['exception' => $e]); + return new DataResponse(['error' => 'Skill not found'], Http::STATUS_NOT_FOUND); + } catch (\Exception|Throwable $e) { + $this->logger->error('Failed to load the skill', ['exception' => $e]); + return new DataResponse(['error' => 'Failed to load the skill'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * Store a skill + * + * Create or overwrite a skill in the user's storage. The `action` field of the response + * is 'created' if a new SKILL.md was written, or 'overwritten' if an existing one was replaced. + * + * @param string $skillName The skill's folder name (also used as the frontmatter `name`) + * @param string $description Short, agent-facing description of when to use the skill + * @param string $content Markdown body to write after the frontmatter + * @return DataResponse|DataResponse|DataResponse|DataResponse|DataResponse + * + * 200: Existing skill overwritten + * 201: New skill created + * 400: Invalid skill name + * 401: User is not authenticated + * 500: Skill could not be stored + */ + #[NoAdminRequired] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['agent_skills'])] + public function storeSkill(string $skillName, string $description, string $content): DataResponse { + if ($this->userId === null) { + return new DataResponse(['error' => $this->l10n->t('Unknown user')], Http::STATUS_UNAUTHORIZED); + } + try { + $action = $this->agentSkillsService->storeSkill($this->userId, $skillName, $description, $content); + } catch (\InvalidArgumentException $e) { + return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); + } catch (NoUserException $e) { + return new DataResponse(['error' => $this->l10n->t('Unknown user')], Http::STATUS_UNAUTHORIZED); + } catch (\Exception|Throwable $e) { + $this->logger->error('Failed to store the skill', ['exception' => $e]); + return new DataResponse(['error' => 'Failed to store the skill'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + $status = $action === 'created' ? Http::STATUS_CREATED : Http::STATUS_OK; + return new DataResponse(['action' => $action], $status); + } +} diff --git a/lib/Service/AgentSkillsService.php b/lib/Service/AgentSkillsService.php new file mode 100644 index 000000000..dec9a3bda --- /dev/null +++ b/lib/Service/AgentSkillsService.php @@ -0,0 +1,277 @@ +cache = $cacheFactory->createLocal(self::CACHE_PREFIX); + } + + /** + * List all available skills for a user, returning the parsed metadata (name, description) for each. + * + * @return list + * @throws NotFoundException if the skills folder cannot be resolved + * @throws NotPermittedException if the skills folder cannot be created or read + * @throws \OC\User\NoUserException if the user does not exist + * @throws \OCP\Files\GenericFileException if reading a SKILL.md file fails + * @throws \OCP\Lock\LockedException if a SKILL.md file is locked + */ + public function listSkills(string $userId): array { + $skillsFolder = $this->getSkillsFolder($userId); + $folderEtag = $skillsFolder->getEtag(); + $folderCacheKey = "folder:$userId"; + + $cached = $this->cache->get($folderCacheKey); + if (is_array($cached) && ($cached['etag'] ?? null) === $folderEtag && is_array($cached['skills'] ?? null)) { + return array_values($cached['skills']); + } + + $skills = []; + foreach ($skillsFolder->getDirectoryListing() as $node) { + if (!$node instanceof Folder) { + continue; + } + try { + $skillFile = $node->get(self::SKILL_FILE_NAME); + } catch (NotFoundException $e) { + $this->logger->debug('Skipping skill folder without ' . self::SKILL_FILE_NAME . ': ' . $node->getName()); + continue; + } + if (!$skillFile instanceof File) { + continue; + } + try { + $skills[] = $this->getSkillMetadata($userId, $node->getName(), $skillFile); + } catch (RuntimeException $e) { + $this->logger->warning('Failed to read skill metadata for ' . $node->getName() . ': ' . $e->getMessage()); + } + } + + $this->cache->set($folderCacheKey, ['etag' => $folderEtag, 'skills' => $skills], self::CACHE_TTL); + return $skills; + } + + /** + * Get a single skill's metadata (name, description), using the per-skill cache keyed on the file etag. + * + * @return array{name: string, description: string} + * @throws RuntimeException if the file has no valid frontmatter or is missing required fields + */ + private function getSkillMetadata(string $userId, string $skillName, File $skillFile): array { + $etag = $skillFile->getEtag(); + $cacheKey = 'skill:' . $userId . ':' . md5($skillName); + $cached = $this->cache->get($cacheKey); + if (is_array($cached) && ($cached['etag'] ?? null) === $etag && is_array($cached['metadata'] ?? null)) { + return $cached['metadata']; + } + + $frontmatter = $this->extractFrontmatter($skillFile); + $metadata = $this->parseMetadataFields($frontmatter, $skillFile->getPath()); + $this->cache->set($cacheKey, ['etag' => $etag, 'metadata' => $metadata], self::CACHE_TTL); + return $metadata; + } + + /** + * Parse the required metadata fields (name, description) from a YAML frontmatter string. + * + * @return array{name: string, description: string} + * @throws RuntimeException if the YAML is invalid or any required field is missing + */ + private function parseMetadataFields(string $frontmatter, string $filePath): array { + try { + $parsed = Yaml::parse($frontmatter); + } catch (ParseException $e) { + throw new RuntimeException('Invalid YAML frontmatter in skill file ' . $filePath . ': ' . $e->getMessage(), 0, $e); + } + if (!is_array($parsed)) { + throw new RuntimeException('Skill frontmatter is not a YAML mapping: ' . $filePath); + } + + $result = []; + foreach (self::FRONTMATTER_METADATA_FIELDS as $field) { + $value = $parsed[$field] ?? null; + if (!is_string($value) || $value === '') { + throw new RuntimeException('Skill file missing required metadata field "' . $field . '": ' . $filePath); + } + $result[$field] = $value; + } + return $result; + } + + /** + * Store (create or overwrite) a skill for a user. + * + * Creates the folder "Skills/$skillName/" and writes a SKILL.md file with a YAML frontmatter + * header containing the name and description, followed by the provided body content. + * + * @param string $userId + * @param string $skillName folder name for the skill (must be a valid filesystem path segment) + * @param string $description short, agent-facing description of when to use this skill + * @param string $content the body of SKILL.md (markdown after the frontmatter) + * @return 'created'|'overwritten' 'created' if a new SKILL.md was written, 'overwritten' if an + * existing one was replaced + * @throws \InvalidArgumentException if the skill name is empty or contains a slash + * @throws NotFoundException if the skills folder cannot be resolved + * @throws NotPermittedException if the skill folder or SKILL.md file cannot be written, or if a + * non-folder node already exists at the target skill path + * @throws \OC\User\NoUserException if the user does not exist + * @throws \OCP\Files\GenericFileException if writing the SKILL.md file fails + * @throws \OCP\Lock\LockedException if the SKILL.md file is locked + */ + public function storeSkill(string $userId, string $skillName, string $description, string $content): string { + if ($skillName === '' || str_contains($skillName, '/')) { + throw new \InvalidArgumentException('Invalid skill name: ' . $skillName); + } + + $skillsFolder = $this->getSkillsFolder($userId); + + $isOverwrite = false; + if ($skillsFolder->nodeExists($skillName)) { + $node = $skillsFolder->get($skillName); + if (!$node instanceof Folder) { + throw new NotPermittedException('A non-folder node already exists at skill path: ' . $skillName); + } + $skillFolder = $node; + $isOverwrite = $skillFolder->nodeExists(self::SKILL_FILE_NAME); + } else { + $skillFolder = $skillsFolder->newFolder($skillName); + } + + $frontmatter = Yaml::dump([ + 'name' => $skillName, + 'description' => $description, + ]); + $fileContent = self::FRONTMATTER_DELIMITER . "\n" + . $frontmatter + . self::FRONTMATTER_DELIMITER . "\n\n" + . $content; + + if ($isOverwrite) { + $skillFile = $skillFolder->get(self::SKILL_FILE_NAME); + if (!$skillFile instanceof File) { + throw new NotPermittedException('SKILL.md path is not a file: ' . $skillFolder->getPath()); + } + $skillFile->putContent($fileContent); + } else { + $skillFolder->newFile(self::SKILL_FILE_NAME, $fileContent); + } + + // invalidate caches so the next listSkills/getSkillMetadata call re-reads + $this->cache->remove("folder:$userId"); + $this->cache->remove('skill:' . $userId . ':' . md5($skillName)); + + return $isOverwrite ? 'overwritten' : 'created'; + } + + /** + * Load the full content of a skill by its folder name. + * + * @throws NotFoundException if the skill folder or its SKILL.md file does not exist + * @throws NotPermittedException if the skills folder cannot be created or read + * @throws \OC\User\NoUserException if the user does not exist + * @throws \OCP\Files\GenericFileException if reading the SKILL.md file fails + * @throws \OCP\Lock\LockedException if the SKILL.md file is locked + */ + public function loadSkill(string $userId, string $skillName): string { + $skillsFolder = $this->getSkillsFolder($userId); + $skillFolder = $skillsFolder->get($skillName); + if (!$skillFolder instanceof Folder) { + throw new NotFoundException('Skill "' . $skillName . '" not found'); + } + $skillFile = $skillFolder->get(self::SKILL_FILE_NAME); + if (!$skillFile instanceof File) { + throw new NotFoundException('Skill file for "' . $skillName . '" not found'); + } + return $skillFile->getContent(); + } + + /** + * Extract the YAML frontmatter (the text between the two `---` delimiters) from a SKILL.md file. + * + * @throws RuntimeException if the file has no valid frontmatter + * @throws NotPermittedException if the file cannot be read + * @throws \OCP\Files\GenericFileException if reading the file fails + * @throws \OCP\Lock\LockedException if the file is locked + */ + private function extractFrontmatter(File $file): string { + $content = $file->getContent(); + $delimiter = self::FRONTMATTER_DELIMITER; + + // must start with the opening delimiter followed by a newline + if (!str_starts_with($content, $delimiter . "\n") && !str_starts_with($content, $delimiter . "\r\n")) { + throw new RuntimeException('Skill file missing frontmatter opening delimiter: ' . $file->getPath()); + } + + $offset = strpos($content, "\n") + 1; + $closingPos = strpos($content, "\n" . $delimiter, $offset); + if ($closingPos === false) { + throw new RuntimeException('Skill file missing frontmatter closing delimiter: ' . $file->getPath()); + } + + return substr($content, $offset, $closingPos - $offset); + } + + /** + * Ensures the skills folder exists at "{Assistant}/Context Agent/Skills" in the user's storage. + * + * @return Folder + * @throws NotFoundException if the user folder or the assistant data folder cannot be resolved + * @throws NotPermittedException if the skills folder cannot be created + * @throws \OC\User\NoUserException if the user does not exist + */ + private function getSkillsFolder(string $userId): Folder { + $assistantFolder = $this->assistantService->getAssistantDataFolder($userId); + $skillsFolderPath = self::SKILLS_FOLDER_PATH; + + if ($assistantFolder->nodeExists($skillsFolderPath)) { + $node = $assistantFolder->get($skillsFolderPath); + if ($node instanceof Folder) { + return $node; + } + } + + // recursively create the skills folder + $parentPath = dirname($skillsFolderPath); + try { + $assistantFolder->newFolder($parentPath); + } catch (NotPermittedException $e) { + if (!$assistantFolder->nodeExists($parentPath)) { + throw $e; + } + } + return $assistantFolder->newFolder($skillsFolderPath); + } +} From 9f29860c1ce7b1b73913660fa26d6aeed6e50a0a Mon Sep 17 00:00:00 2001 From: kyteinsky Date: Tue, 30 Jun 2026 09:44:31 +0530 Subject: [PATCH 2/3] feat: allow admins to set global shared skills Signed-off-by: kyteinsky Assisted-by: Github Copilot: claude-opus-4-7 --- appinfo/routes.php | 1 + lib/Controller/ConfigController.php | 25 +++++ lib/Service/AgentSkillsService.php | 141 ++++++++++++++++++++++++++-- lib/Settings/Admin.php | 9 ++ src/components/AdminSettings.vue | 119 ++++++++++++++++++++++- 5 files changed, 281 insertions(+), 14 deletions(-) diff --git a/appinfo/routes.php b/appinfo/routes.php index 4f6ca350a..040932c79 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -14,6 +14,7 @@ ['name' => 'config#getConfigValue', 'url' => '/config', 'verb' => 'GET'], ['name' => 'config#setConfig', 'url' => '/config', 'verb' => 'PUT'], ['name' => 'config#setAdminConfig', 'url' => '/admin-config', 'verb' => 'PUT'], + ['name' => 'config#setGlobalSkillsFolder', 'url' => '/admin-config/global-skills', 'verb' => 'PUT'], ['name' => 'assistant#getAssistantTaskResultPage', 'url' => '/task/view/{taskId}', 'verb' => 'GET'], ['name' => 'assistant#getAssistantStandalonePage', 'url' => '/', 'verb' => 'GET'], diff --git a/lib/Controller/ConfigController.php b/lib/Controller/ConfigController.php index 7709bfd8e..78f164b6a 100644 --- a/lib/Controller/ConfigController.php +++ b/lib/Controller/ConfigController.php @@ -8,7 +8,9 @@ namespace OCA\Assistant\Controller; use OCA\Assistant\AppInfo\Application; +use OCA\Assistant\Service\AgentSkillsService; use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\Attribute\OpenAPI; use OCP\AppFramework\Http\DataResponse; @@ -26,6 +28,7 @@ public function __construct( IRequest $request, private IConfig $config, private IAppConfig $appConfig, + private AgentSkillsService $agentSkillsService, private ?string $userId, ) { parent::__construct($appName, $request); @@ -73,4 +76,26 @@ public function setAdminConfig(array $values): DataResponse { } return new DataResponse(1); } + + /** + * Set (or clear) the admin-configured global skills folder. + * + * The folder is resolved against the current admin user's filesystem. Pass an empty + * `path` to clear the configuration. + * + * @param string $path Folder path inside the current admin user's home, as returned by the file picker + * @return DataResponse + */ + public function setGlobalSkillsFolder(string $path): DataResponse { + if ($this->userId === null) { + return new DataResponse(['error' => 'Unauthorized'], Http::STATUS_UNAUTHORIZED); + } + $path = trim($path); + if ($path === '') { + $this->agentSkillsService->setGlobalSkillsFolder('', ''); + return new DataResponse(1); + } + $this->agentSkillsService->setGlobalSkillsFolder($this->userId, $path); + return new DataResponse(1); + } } diff --git a/lib/Service/AgentSkillsService.php b/lib/Service/AgentSkillsService.php index dec9a3bda..179fe010f 100644 --- a/lib/Service/AgentSkillsService.php +++ b/lib/Service/AgentSkillsService.php @@ -9,10 +9,13 @@ namespace OCA\Assistant\Service; +use OCA\Assistant\AppInfo\Application; use OCP\Files\File; use OCP\Files\Folder; +use OCP\Files\IRootFolder; use OCP\Files\NotFoundException; use OCP\Files\NotPermittedException; +use OCP\IAppConfig; use OCP\ICache; use OCP\ICacheFactory; use Psr\Log\LoggerInterface; @@ -22,17 +25,24 @@ class AgentSkillsService { + public const GLOBAL_SKILLS_ADMIN_UID_KEY = 'global_skills_admin_uid'; + public const GLOBAL_SKILLS_PATH_KEY = 'global_skills_path'; + private const SKILLS_FOLDER_PATH = 'Context Agent/Skills'; private const SKILL_FILE_NAME = 'SKILL.md'; private const FRONTMATTER_DELIMITER = '---'; private const CACHE_PREFIX = 'assistant_skills'; private const CACHE_TTL = 24 * 60 * 60; private const FRONTMATTER_METADATA_FIELDS = ['name', 'description']; + private const GLOBAL_CACHE_KEY = 'global_folder'; + private const GLOBAL_SKILL_CACHE_PREFIX = 'global_skill:'; private ICache $cache; public function __construct( private AssistantService $assistantService, + private IRootFolder $rootFolder, + private IAppConfig $appConfig, private LoggerInterface $logger, ICacheFactory $cacheFactory, ) { @@ -51,16 +61,44 @@ public function __construct( */ public function listSkills(string $userId): array { $skillsFolder = $this->getSkillsFolder($userId); - $folderEtag = $skillsFolder->getEtag(); - $folderCacheKey = "folder:$userId"; + $userSkills = $this->listSkillsFromFolder( + $skillsFolder, + "folder:$userId", + "skill:$userId:", + ); + + $globalFolder = $this->getGlobalSkillsFolder(); + if ($globalFolder === null) { + return array_values($userSkills); + } + $globalSkills = $this->listSkillsFromFolder( + $globalFolder, + self::GLOBAL_CACHE_KEY, + self::GLOBAL_SKILL_CACHE_PREFIX, + ); + + // user skills take precedence on name collision + $merged = $globalSkills; + foreach ($userSkills as $name => $metadata) { + $merged[$name] = $metadata; + } + return array_values($merged); + } + /** + * List metadata for all skills directly under the given folder. + * + * @return array indexed by folder/skill name + */ + private function listSkillsFromFolder(Folder $folder, string $folderCacheKey, string $skillCacheKeyPrefix): array { + $folderEtag = $folder->getEtag(); $cached = $this->cache->get($folderCacheKey); if (is_array($cached) && ($cached['etag'] ?? null) === $folderEtag && is_array($cached['skills'] ?? null)) { - return array_values($cached['skills']); + return $cached['skills']; } $skills = []; - foreach ($skillsFolder->getDirectoryListing() as $node) { + foreach ($folder->getDirectoryListing() as $node) { if (!$node instanceof Folder) { continue; } @@ -74,7 +112,10 @@ public function listSkills(string $userId): array { continue; } try { - $skills[] = $this->getSkillMetadata($userId, $node->getName(), $skillFile); + $skills[$node->getName()] = $this->getSkillMetadata( + $skillCacheKeyPrefix . md5($node->getName()), + $skillFile, + ); } catch (RuntimeException $e) { $this->logger->warning('Failed to read skill metadata for ' . $node->getName() . ': ' . $e->getMessage()); } @@ -90,9 +131,8 @@ public function listSkills(string $userId): array { * @return array{name: string, description: string} * @throws RuntimeException if the file has no valid frontmatter or is missing required fields */ - private function getSkillMetadata(string $userId, string $skillName, File $skillFile): array { + private function getSkillMetadata(string $cacheKey, File $skillFile): array { $etag = $skillFile->getEtag(); - $cacheKey = 'skill:' . $userId . ':' . md5($skillName); $cached = $this->cache->get($cacheKey); if (is_array($cached) && ($cached['etag'] ?? null) === $etag && is_array($cached['metadata'] ?? null)) { return $cached['metadata']; @@ -197,7 +237,7 @@ public function storeSkill(string $userId, string $skillName, string $descriptio } /** - * Load the full content of a skill by its folder name. + * Load the full content of a skill by its folder/skill name. * * @throws NotFoundException if the skill folder or its SKILL.md file does not exist * @throws NotPermittedException if the skills folder cannot be created or read @@ -206,8 +246,34 @@ public function storeSkill(string $userId, string $skillName, string $descriptio * @throws \OCP\Lock\LockedException if the SKILL.md file is locked */ public function loadSkill(string $userId, string $skillName): string { - $skillsFolder = $this->getSkillsFolder($userId); - $skillFolder = $skillsFolder->get($skillName); + $userFolder = $this->getSkillsFolder($userId); + $globalFolder = $this->getGlobalSkillsFolder(); + + // user skills take precedence + $folders = [$userFolder]; + if ($globalFolder !== null) { + $folders[] = $globalFolder; + } + + foreach ($folders as $folder) { + try { + return $this->loadSkillFromFolder($folder, $skillName); + } catch (NotFoundException $e) { + continue; + } + } + + throw new NotFoundException('Skill "' . $skillName . '" not found'); + } + + /** + * @throws NotFoundException if the skill or its SKILL.md is missing + */ + private function loadSkillFromFolder(Folder $folder, string $skillName): string { + if (!$folder->nodeExists($skillName)) { + throw new NotFoundException('Skill "' . $skillName . '" not found'); + } + $skillFolder = $folder->get($skillName); if (!$skillFolder instanceof Folder) { throw new NotFoundException('Skill "' . $skillName . '" not found'); } @@ -244,6 +310,61 @@ private function extractFrontmatter(File $file): string { return substr($content, $offset, $closingPos - $offset); } + /** + * Resolve the admin-configured global skills folder, if any. + * + * Returns null if no folder is configured, the admin user no longer exists, or the + * configured path no longer points to a folder. + */ + public function getGlobalSkillsFolder(): ?Folder { + $adminUid = $this->appConfig->getValueString(Application::APP_ID, self::GLOBAL_SKILLS_ADMIN_UID_KEY, '', lazy: true); + $path = $this->appConfig->getValueString(Application::APP_ID, self::GLOBAL_SKILLS_PATH_KEY, '', lazy: true); + if ($adminUid === '' || $path === '') { + return null; + } + try { + $userFolder = $this->rootFolder->getUserFolder($adminUid); + if (!$userFolder->nodeExists($path)) { + $this->logger->warning('Global skills folder does not exist: ' . $path . ' (admin: ' . $adminUid . ')'); + return null; + } + $node = $userFolder->get($path); + // todo + $folder = $node; + $this->logger->warning('global folder etag', ['etag' => $folder->getEtag(), 'fileId' => $folder->getId(), 'path' => $folder->getPath()]); + // todo + + if (!$node instanceof Folder) { + $this->logger->warning('Global skills path is not a folder: ' . $path . ' (admin: ' . $adminUid . ')'); + return null; + } + return $node; + } catch (\Throwable $e) { + $this->logger->warning('Failed to resolve global skills folder', ['exception' => $e]); + return null; + } + } + + /** + * Set or clear the admin-configured global skills folder. Pass empty strings to clear. + */ + public function setGlobalSkillsFolder(string $adminUid, string $path): void { + $this->appConfig->setValueString(Application::APP_ID, self::GLOBAL_SKILLS_ADMIN_UID_KEY, $adminUid, lazy: true); + $this->appConfig->setValueString(Application::APP_ID, self::GLOBAL_SKILLS_PATH_KEY, $path, lazy: true); + // invalidate the global folder cache so the next listSkills re-reads + $this->cache->remove(self::GLOBAL_CACHE_KEY); + } + + /** + * @return array{admin_uid: string, path: string} + */ + public function getGlobalSkillsConfig(): array { + return [ + 'admin_uid' => $this->appConfig->getValueString(Application::APP_ID, self::GLOBAL_SKILLS_ADMIN_UID_KEY, '', lazy: true), + 'path' => $this->appConfig->getValueString(Application::APP_ID, self::GLOBAL_SKILLS_PATH_KEY, '', lazy: true), + ]; + } + /** * Ensures the skills folder exists at "{Assistant}/Context Agent/Skills" in the user's storage. * diff --git a/lib/Settings/Admin.php b/lib/Settings/Admin.php index 9aa45291a..052b135c7 100644 --- a/lib/Settings/Admin.php +++ b/lib/Settings/Admin.php @@ -8,6 +8,7 @@ namespace OCA\Assistant\Settings; use OCA\Assistant\AppInfo\Application; +use OCA\Assistant\Service\AgentSkillsService; use OCA\Assistant\TaskProcessing\TextToStickerTaskType; use OCP\AppFramework\Http\TemplateResponse; use OCP\AppFramework\Services\IInitialState; @@ -24,6 +25,7 @@ public function __construct( private IAppConfig $appConfig, private IInitialState $initialStateService, private ITaskProcessingManager $taskProcessingManager, + private AgentSkillsService $agentSkillsService, ) { } @@ -39,6 +41,8 @@ public function getForm(): TemplateResponse { $speechToTextAvailable = array_key_exists(AudioToText::ID, $availableTaskTypes); $textToImageAvailable = array_key_exists(TextToImage::ID, $availableTaskTypes); $textToStickerAvailable = array_key_exists(TextToStickerTaskType::ID, $availableTaskTypes); + $contextAgentAvailable = class_exists('OCP\\TaskProcessing\\TaskTypes\\ContextAgentInteraction') + && array_key_exists(\OCP\TaskProcessing\TaskTypes\ContextAgentInteraction::ID, $availableTaskTypes); $assistantEnabled = $this->appConfig->getValueString(Application::APP_ID, 'assistant_enabled', '1', lazy: true) === '1'; @@ -53,6 +57,8 @@ public function getForm(): TemplateResponse { $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); + $globalSkillsConfig = $this->agentSkillsService->getGlobalSkillsConfig(); + $adminConfig = [ 'task_processing_available' => $taskProcessingAvailable, 'assistant_enabled' => $assistantEnabled, @@ -67,6 +73,9 @@ public function getForm(): TemplateResponse { 'chat_user_instructions' => $chattyLLMUserInstructions, 'chat_user_instructions_title' => $chattyLLMUserInstructionsTitle, 'chat_last_n_messages' => $chattyLLMLastNMessages, + 'context_agent_available' => $contextAgentAvailable, + 'global_skills_admin_uid' => $globalSkillsConfig['admin_uid'], + 'global_skills_path' => $globalSkillsConfig['path'], ]; $this->initialStateService->provideInitialState('admin-config', $adminConfig); diff --git a/src/components/AdminSettings.vue b/src/components/AdminSettings.vue index 26676331a..9e41d70d5 100644 --- a/src/components/AdminSettings.vue +++ b/src/components/AdminSettings.vue @@ -178,24 +178,64 @@ :title="t('assistant', 'Number of messages to consider for chat completions (excluding the user instructions, which is always considered)')" @update:model-value="delayedValueUpdate(state.chat_last_n_messages, 'chat_last_n_messages')" /> +
+

+ {{ t('assistant', 'Global Agent Skills') }} +

+ + {{ t('assistant', 'Select a folder containing skills (sub-folders, each holding a SKILL.md file). These will be available to all users in addition to their personal skills in "Chat with AI". The folder is resolved from the filesystem of the admin who set it.') }} + +
+ + + {{ t('assistant', 'Select global skills folder') }} + + + {{ t('assistant', 'Clear') }} + +
+
+ + + {{ t('assistant', 'Set by') }} + + + {{ state.global_skills_admin_uid }} +
+
+ {{ t('assistant', 'No global skills folder configured') }} +
+