Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ Known providers:

More details on how to set this up in the [admin docs](https://docs.nextcloud.com/server/latest/admin_manual/ai/index.html)
]]> </description>
<version>4.0.0</version>
<version>4.1.0</version>
<licence>agpl</licence>
<author>Julien Veyssier</author>
<namespace>Assistant</namespace>
Expand Down
5 changes: 5 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@
['name' => 'agentSkillsApi#storeSkill', 'url' => '/api/{apiVersion}/skills', 'verb' => 'POST', 'requirements' => $requirements],
['name' => 'agentSkillsApi#loadSkill', 'url' => '/api/{apiVersion}/skills/{skillName}', 'verb' => 'GET', 'requirements' => $requirements],

['name' => 'agentsApi#listAgents', 'url' => '/api/{apiVersion}/agents', 'verb' => 'GET', 'requirements' => $requirements],
['name' => 'agentsApi#createAgent', 'url' => '/api/{apiVersion}/agents', 'verb' => 'POST', 'requirements' => $requirements],
['name' => 'agentsApi#updateAgent', 'url' => '/api/{apiVersion}/agents/{id}', 'verb' => 'PUT', 'requirements' => $requirements],
['name' => 'agentsApi#deleteAgent', 'url' => '/api/{apiVersion}/agents/{id}', 'verb' => 'DELETE', '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'],
Expand Down
9 changes: 9 additions & 0 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
use OCA\Assistant\Listener\NewFileMenuTaskFailedListener;
use OCA\Assistant\Listener\NewFileMenuTaskSuccessfulListener;
use OCA\Assistant\Listener\SpeechToText\SpeechToTextReferenceListener;
use OCA\Assistant\Listener\Talk\AgentMentionListener;
use OCA\Assistant\Listener\Talk\AgentReactionListener;
use OCA\Assistant\Listener\TaskFailedListener;
use OCA\Assistant\Listener\TaskOutputFileReferenceListener;
use OCA\Assistant\Listener\TaskSuccessfulListener;
Expand Down Expand Up @@ -108,6 +110,13 @@ public function register(IRegistrationContext $context): void {

$context->registerEventListener(UserDeletedEvent::class, UserDeletedListener::class);

// the Talk event classes are not necessarily autoloadable when this app
// file is registered, the listeners themselves guard for Talk being absent
/** @psalm-suppress UndefinedClass, InvalidArgument */
$context->registerEventListener(\OCA\Talk\Events\ChatMessageSentEvent::class, AgentMentionListener::class);
/** @psalm-suppress UndefinedClass, InvalidArgument */
$context->registerEventListener(\OCA\Talk\Events\ReactionAddedEvent::class, AgentReactionListener::class);

if (class_exists('OCP\\TaskProcessing\\TaskTypes\\AudioToAudioChat')) {
$context->registerTaskProcessingProvider(AudioToAudioChatProvider::class);
}
Expand Down
190 changes: 190 additions & 0 deletions lib/Controller/AgentsApiController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Assistant\Controller;

use OCA\Assistant\Db\Agent;
use OCA\Assistant\Service\AgentsService;
use OCA\Assistant\Service\AgentUserException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\OpenAPI;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCSController;
use OCP\IGroupManager;
use OCP\IL10N;
use OCP\IRequest;
use OCP\IUserManager;
use OCP\IUserSession;
use Throwable;

/**
* CRUD access to the agents of the "agent as a user" feature (admin only)
*/
class AgentsApiController extends OCSController {

public function __construct(
string $appName,
IRequest $request,
private IL10N $l10n,
private AgentsService $agentsService,
private IUserManager $userManager,
private IUserSession $userSession,
private IGroupManager $groupManager,
) {
parent::__construct($appName, $request);
}

/**
* @return list<array{id: int, user_id: string, display_name: string, personality: ?string, scope: ?string}>
*/
private function serializeAgents(): array {
$agents = $this->agentsService->getAllAgents();
$result = [];
foreach ($agents as $agent) {
$result[] = $this->serializeAgent($agent);
}
return $result;
}

private function serializeAgent(Agent $agent): array {
$user = $this->userManager->get($agent->getUserId());
return [
'id' => (int)$agent->getId(),
'user_id' => $agent->getUserId(),
'display_name' => $user?->getDisplayName() ?: $agent->getUserId(),
'personality' => $agent->getPersonality(),
'scope' => $agent->getScope(),
];
}

private function checkAdmin(): bool {
$user = $this->userSession->getUser();
if ($user === null) {
return false;
}
return $this->groupManager->isInGroup($user->getUID(), 'admin');
}

/**
* List all agents (admin only)
*
* @return DataResponse<Http::STATUS_OK, array{agents: list<array{id: int, user_id: string, display_name: string, personality: ?string, scope: ?string}>}, array{}>|DataResponse<Http::STATUS_FORBIDDEN, array{error: string}, array{}>
*
* 200: Agents returned
* 403: User is not an admin
*/
#[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['agents'])]
public function listAgents(): DataResponse {
if (!$this->checkAdmin()) {
return new DataResponse(['error' => $this->l10n->t('Only administrators can manage agents')], Http::STATUS_FORBIDDEN);
}
return new DataResponse(['agents' => $this->serializeAgents()]);
}

/**
* Create an agent (admin only)
*
* A dedicated Nextcloud user is created in the default database backend if it does not exist yet.
*
* @param string $name The name of the agent
* @param string $userId The user id of the agent's dedicated Nextcloud user
* @param string $personality The agent's system prompt
* @param string $scope Optional scope for later use
* @return DataResponse<Http::STATUS_CREATED, array{agent: array{id: int, user_id: string, display_name: string, personality: ?string, scope: ?string}}, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>|DataResponse<Http::STATUS_FORBIDDEN, array{error: string}, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR, array{error: string}, array{}>
*
* 201: Agent created
* 400: Invalid data
* 403: User is not an admin
* 500: Agent creation failed
*/
#[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['agents'])]
public function createAgent(string $name = '', string $userId = '', string $personality = '', string $scope = ''): DataResponse {
if (!$this->checkAdmin()) {
return new DataResponse(['error' => $this->l10n->t('Only administrators can manage agents')], Http::STATUS_FORBIDDEN);
}
$name = trim($name);
$userId = trim($userId);
if ($name === '' || $userId === '') {
return new DataResponse(['error' => $this->l10n->t('Name and user id are required')], Http::STATUS_BAD_REQUEST);
}
if (strlen($personality) > 64_000) {
return new DataResponse(['error' => $this->l10n->t('The agent personality is too long')], Http::STATUS_BAD_REQUEST);
}
try {
$agent = $this->agentsService->createAgent($name, $userId, $personality, $scope);
return new DataResponse(['agent' => $this->serializeAgent($agent)], Http::STATUS_CREATED);
} catch (AgentUserException $e) {
return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
} catch (\Exception|Throwable $e) {
return new DataResponse(['error' => $this->l10n->t('Failed to create the agent')], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}

/**
* Update an agent (admin only)
*
* @param int $id The agent id
* @param string|null $personality The new personality (system prompt)
* @param string|null $scope The new scope
* @return DataResponse<Http::STATUS_OK, array{agent: array{id: int, user_id: string, display_name: string, personality: ?string, scope: ?string}}, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>|DataResponse<Http::STATUS_FORBIDDEN, array{error: string}, array{}>|DataResponse<Http::STATUS_NOT_FOUND, array{error: string}, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR, array{error: string}, array{}>
*
* 200: Agent updated
* 400: Invalid data
* 403: User is not an admin
* 404: Agent not found
* 500: Agent update failed
*/
#[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['agents'])]
public function updateAgent(int $id, ?string $personality = null, ?string $scope = null): DataResponse {
if (!$this->checkAdmin()) {
return new DataResponse(['error' => $this->l10n->t('Only administrators can manage agents')], Http::STATUS_FORBIDDEN);
}
$agent = $this->agentsService->getAgentById($id);
if ($agent === null) {
return new DataResponse(['error' => $this->l10n->t('Agent not found')], Http::STATUS_NOT_FOUND);
}
try {
$agent = $this->agentsService->updateAgent($id, $personality, $scope);
return new DataResponse(['agent' => $this->serializeAgent($agent)]);
} catch (AgentUserException $e) {
return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
} catch (\Exception|Throwable $e) {
return new DataResponse(['error' => $this->l10n->t('Failed to update the agent')], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}

/**
* Delete an agent (admin only)
*
* @param int $id The agent id
* @param int $deleteUser Whether the related Nextcloud user should be deleted too
* @return DataResponse<Http::STATUS_OK, array{action: string}, array{}>|DataResponse<Http::STATUS_FORBIDDEN, array{error: string}, array{}>|DataResponse<Http::STATUS_NOT_FOUND, array{error: string}, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR, array{error: string}, array{}>
*
* 200: Agent deleted
* 403: User is not an admin
* 404: Agent not found
* 500: Agent deletion failed
*/
#[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['agents'])]
public function deleteAgent(int $id, int $deleteUser = 0): DataResponse {
if (!$this->checkAdmin()) {
return new DataResponse(['error' => $this->l10n->t('Only administrators can manage agents')], Http::STATUS_FORBIDDEN);
}
$agent = $this->agentsService->getAgentById($id);
if ($agent === null) {
return new DataResponse(['error' => $this->l10n->t('Agent not found')], Http::STATUS_NOT_FOUND);
}
try {
$this->agentsService->deleteAgent($id, $deleteUser === 1);
return new DataResponse(['action' => 'deleted']);
} catch (\Exception|Throwable $e) {
return new DataResponse(['error' => $this->l10n->t('Failed to delete the agent')], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
}
58 changes: 58 additions & 0 deletions lib/Db/Agent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Assistant\Db;

use OCP\AppFramework\Db\Entity;
use OCP\DB\Types;

/**
* @method string getUserId()
* @method void setUserId(string $userId)
* @method string|null getPersonality()
* @method void setPersonality(?string $personality)
* @method string|null getScope()
* @method void setScope(?string $scope)
*/
class Agent extends Entity implements \JsonSerializable {
protected $userId;
/** @var ?string */
protected $personality;
/** @var ?string */
protected $scope;

public static $columns = [
'id',
'user_id',
'personality',
'scope',
];
public static $fields = [
'id',
'userId',
'personality',
'scope',
];

public function __construct() {
$this->addType('userId', Types::STRING);
$this->addType('personality', Types::TEXT);
$this->addType('scope', Types::TEXT);
}

#[\ReturnTypeWillChange]
public function jsonSerialize() {
return [
'id' => $this->getId(),
'user_id' => $this->getUserId(),
'personality' => $this->getPersonality(),
'scope' => $this->getScope(),
];
}
}
104 changes: 104 additions & 0 deletions lib/Db/AgentMapper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Assistant\Db;

use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\MultipleObjectsReturnedException;
use OCP\AppFramework\Db\QBMapper;
use OCP\DB\Exception;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;

/**
* @extends QBMapper<Agent>
*/
class AgentMapper extends QBMapper {
public function __construct(IDBConnection $db) {
parent::__construct($db, 'assistant_agents', Agent::class);
}

/**
* @param int $id
* @return Agent
* @throws DoesNotExistException
* @throws MultipleObjectsReturnedException
* @throws Exception
*/
public function getById(int $id): Agent {
$qb = $this->db->getQueryBuilder();
$qb->select(Agent::$columns)
->from($this->getTableName())
->where($qb->expr()->eq('id', $qb->createPositionalParameter($id, IQueryBuilder::PARAM_INT)));
return $this->findEntity($qb);
}

/**
* @return list<Agent>
* @throws Exception
*/
public function getAllAgents(): array {
$qb = $this->db->getQueryBuilder();
$qb->select(Agent::$columns)
->from($this->getTableName())
->orderBy('id', 'ASC');
return $this->findEntities($qb);
}

/**
* @param string $userId
* @return Agent
* @throws DoesNotExistException
* @throws MultipleObjectsReturnedException
* @throws Exception
*/
public function getByUserId(string $userId): Agent {
$qb = $this->db->getQueryBuilder();
$qb->select(Agent::$columns)
->from($this->getTableName())
->where($qb->expr()->eq('user_id', $qb->createPositionalParameter($userId, IQueryBuilder::PARAM_STR)));
return $this->findEntity($qb);
}

/**
* Whether a list of user ids contains registered agents, keyed by user id
*
* @param list<string> $userIds
* @return array<string, Agent>
* @throws Exception
*/
public function getByUserIds(array $userIds): array {
if ($userIds === []) {
return [];
}
$qb = $this->db->getQueryBuilder();
$qb->select(Agent::$columns)
->from($this->getTableName())
->where($qb->expr()->in('user_id', $qb->createParameter('userIds')))
->setParameter('userIds', $userIds, IQueryBuilder::PARAM_STR_ARRAY);
$result = $qb->executeQuery();
$agents = [];
while (($row = $result->fetchAssociative()) !== false) {
$agent = $this->mapRowToEntity($row);
$agents[$agent->getUserId()] = $agent;
}
$result->closeCursor();
return $agents;
}

/**
* @param Agent $agent
* @return Agent
* @throws Exception
*/
public function deleteAgent(Agent $agent): Agent {
$this->delete($agent);
return $agent;
}
}
Loading
Loading