Files
nc-talk-ai/lib/Controller/ToolsController.php
Pascal Kienast 0739d3da6a Initial open-source release of Talk AI
Talk AI is a multi-bot AI assistant manager for Nextcloud Talk:
per-bot prompts and models, agentic tool calling (MCP + built-in
tools), RAG over Nextcloud files, room-document search, vision and
speech-to-text attachments, persistent bot wikis, approval workflows,
rate limiting, and multi-provider LLM support (any OpenAI-compatible
endpoint).

Developed within EDUC - the European Digital UniverCity
(https://educalliance.eu), where it runs as the 'EDUC AI' assistant on
the alliance-wide Nextcloud portal. This public repository is the
upstream point of truth; deployment-specific tools plug in via the
tool-provider extension point (docs/TOOL_PROVIDERS.md).

License: AGPL-3.0-or-later.
2026-07-08 21:13:13 +02:00

394 lines
13 KiB
PHP

<?php
declare(strict_types=1);
namespace OCA\EducAI\Controller;
use Exception;
use OCA\EducAI\Db\Tool;
use OCA\EducAI\Db\ToolMapper;
use OCA\EducAI\Service\BuiltInToolProvider;
use OCA\EducAI\ToolProvider\ToolProviderRegistry;
use OCA\EducAI\Service\CredentialService;
use OCA\EducAI\Service\DoclingClient;
use OCA\EducAI\Service\McpClient;
use OCA\EducAI\Service\SpeechToTextClient;
use OCA\EducAI\Service\ToolRegistry;
use OCA\EducAI\Service\VisionClient;
use OCA\EducAI\Service\WikiLocationService;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\DataResponse;
use OCP\IRequest;
use Psr\Log\LoggerInterface;
class ToolsController extends Controller {
private ToolMapper $toolMapper;
private ToolRegistry $toolRegistry;
private McpClient $mcpClient;
private DoclingClient $doclingClient;
private VisionClient $visionClient;
private SpeechToTextClient $speechToTextClient;
private ToolProviderRegistry $toolProviderRegistry;
private CredentialService $credentialService;
private WikiLocationService $wikiLocationService;
private ?string $userId;
private LoggerInterface $logger;
public function __construct(
string $appName,
IRequest $request,
ToolMapper $toolMapper,
ToolRegistry $toolRegistry,
McpClient $mcpClient,
DoclingClient $doclingClient,
VisionClient $visionClient,
SpeechToTextClient $speechToTextClient,
ToolProviderRegistry $toolProviderRegistry,
CredentialService $credentialService,
WikiLocationService $wikiLocationService,
?string $userId,
LoggerInterface $logger
) {
parent::__construct($appName, $request);
$this->toolMapper = $toolMapper;
$this->toolRegistry = $toolRegistry;
$this->mcpClient = $mcpClient;
$this->doclingClient = $doclingClient;
$this->visionClient = $visionClient;
$this->speechToTextClient = $speechToTextClient;
$this->toolProviderRegistry = $toolProviderRegistry;
$this->credentialService = $credentialService;
$this->wikiLocationService = $wikiLocationService;
$this->userId = $userId;
$this->logger = $logger;
}
/**
* @NoAdminRequired
*
* Get all available tools (MCP tools + built-in tools) for selection
*/
public function available(): DataResponse {
try {
// Get enabled MCP tools
$mcpTools = $this->toolRegistry->getEnabledTools();
// Convert MCP tools to a consistent format
$tools = [];
foreach ($mcpTools as $tool) {
$tools[] = [
'id' => $tool->getId(),
'name' => $tool->getName(),
'description' => $tool->getDescription(),
'is_builtin' => false,
'builtin_name' => null,
];
}
// Get available built-in tools
$builtInTools = $this->toolProviderRegistry->getAvailableTools();
foreach ($builtInTools as $builtIn) {
$label = isset($builtIn['label']) && is_string($builtIn['label']) && $builtIn['label'] !== ''
? $builtIn['label']
: $this->formatBuiltInToolName($builtIn['name']);
$tools[] = [
'id' => null, // Built-in tools don't have DB IDs
'name' => $label,
'description' => $builtIn['description'],
'is_builtin' => true,
'builtin_name' => $builtIn['name'],
];
}
return new DataResponse(['tools' => $tools]);
} catch (Exception $e) {
$this->logger->error('Failed to list available tools', [
'exception' => $e,
]);
return new DataResponse(['error' => $e->getMessage()], 500);
}
}
/**
* @NoAdminRequired
*/
public function wikiLocations(): DataResponse {
return new DataResponse([
'collectives' => $this->wikiLocationService->listEditableCollectives($this->userId),
]);
}
/**
* Format built-in tool name for display
*/
private function formatBuiltInToolName(string $name): string {
$mapping = [
BuiltInToolProvider::TOOL_ROOM_SEARCH => 'Room Document Search',
BuiltInToolProvider::TOOL_ROOM_IMAGE_SEARCH => 'Room Image Search',
BuiltInToolProvider::TOOL_ATTACHMENT_IMAGE => 'Image Attachment Analysis',
BuiltInToolProvider::TOOL_ATTACHMENT_AUDIO => 'Audio Attachment Transcription',
BuiltInToolProvider::TOOL_RAG_SEARCH => 'Document Search (RAG)',
BuiltInToolProvider::TOOL_WIKI_SEARCH => 'Wiki Search',
BuiltInToolProvider::TOOL_WIKI_READ_PAGE => 'Wiki Read Page',
BuiltInToolProvider::TOOL_WIKI_WRITE_PAGE => 'Wiki Write Page',
BuiltInToolProvider::TOOL_WIKI_LOG_EVENT => 'Wiki Log Event',
];
if (isset($mapping[$name])) {
return $mapping[$name];
}
return ucwords(str_replace('_', ' ', $name));
}
/**
* @AdminRequired
*/
public function index(): DataResponse {
$tools = $this->toolMapper->findAllTools();
return new DataResponse(['tools' => $tools]);
}
/**
* @AdminRequired
*/
public function show(int $id): DataResponse {
try {
$tool = $this->toolMapper->findById($id);
return new DataResponse($tool);
} catch (Exception $e) {
return new DataResponse(['error' => $e->getMessage()], 404);
}
}
/**
* @AdminRequired
*/
public function create(
string $name,
string $mcpEndpointUrl,
?string $description = null,
?array $authentication = null,
?array $capabilities = null,
bool $enabled = false
): DataResponse {
try {
$tool = new Tool();
$tool->setName($name);
$tool->setMcpEndpointUrl($mcpEndpointUrl);
$tool->setDescription($description);
// Encrypt authentication JSON before storing
if ($authentication !== null) {
$authJson = json_encode($authentication);
if ($authJson !== false && $authJson !== '{}' && $authJson !== 'null') {
$tool->setAuthentication($this->credentialService->encrypt($authJson));
} else {
$tool->setAuthentication(null);
}
} else {
$tool->setAuthentication(null);
}
$tool->setCapabilities($capabilities !== null ? json_encode($capabilities) ?: null : null);
$tool->setEnabled($enabled);
$tool->setCreatedAt(time());
$tool->setUpdatedAt(time());
$tool = $this->toolMapper->insert($tool);
$this->toolRegistry->refresh();
return new DataResponse($tool, 201);
} catch (Exception $e) {
$this->logger->error('Failed to create tool', [
'name' => $name,
'exception' => $e,
]);
return new DataResponse(['error' => $e->getMessage()], 400);
}
}
/**
* @AdminRequired
*/
public function update(
int $id,
?string $name = null,
?string $mcpEndpointUrl = null,
?string $description = null,
?array $authentication = null,
?array $capabilities = null,
?bool $enabled = null
): DataResponse {
try {
$tool = $this->toolMapper->findById($id);
if ($name !== null) {
$tool->setName($name);
}
if ($mcpEndpointUrl !== null) {
$tool->setMcpEndpointUrl($mcpEndpointUrl);
}
if ($description !== null) {
$tool->setDescription($description);
}
if ($authentication !== null) {
// Encrypt authentication JSON before storing
$authJson = json_encode($authentication);
if ($authJson !== false && $authJson !== '{}' && $authJson !== 'null') {
$tool->setAuthentication($this->credentialService->encrypt($authJson));
} else {
$tool->setAuthentication(null);
}
}
if ($capabilities !== null) {
$tool->setCapabilities(json_encode($capabilities) ?: null);
}
if ($enabled !== null) {
$tool->setEnabled($enabled);
}
$tool->setUpdatedAt(time());
$tool = $this->toolMapper->update($tool);
$this->toolRegistry->refresh();
return new DataResponse($tool);
} catch (Exception $e) {
$this->logger->error('Failed to update tool', [
'tool_id' => $id,
'exception' => $e,
]);
return new DataResponse(['error' => $e->getMessage()], 400);
}
}
/**
* @AdminRequired
*/
public function destroy(int $id): DataResponse {
try {
$tool = $this->toolMapper->findById($id);
$this->toolMapper->delete($tool);
$this->toolRegistry->refresh();
return new DataResponse(['success' => true]);
} catch (Exception $e) {
return new DataResponse(['error' => $e->getMessage()], 400);
}
}
/**
* @AdminRequired
*/
public function test(
?string $mcpEndpointUrl = null,
?array $authentication = null
): DataResponse {
try {
$this->logger->debug('Testing tool connection - raw params', [
'mcpEndpointUrl_param' => $mcpEndpointUrl,
'mcpEndpointUrl_type' => gettype($mcpEndpointUrl),
'has_auth' => $authentication !== null,
]);
if (empty($mcpEndpointUrl)) {
return new DataResponse(['error' => 'MCP Endpoint URL is required'], 400);
}
$tool = new Tool();
$tool->setId(0);
$tool->setName('test');
$tool->setMcpEndpointUrl($mcpEndpointUrl);
$tool->setAuthentication($authentication !== null ? json_encode($authentication) ?: null : null);
$tool->setEnabled(true);
$tool->setCreatedAt(time());
$tool->setUpdatedAt(time());
$this->logger->debug('Tool object created', [
'url_from_tool' => $tool->getMcpEndpointUrl(),
]);
$tools = $this->mcpClient->listTools($tool);
return new DataResponse(['tools' => $tools]);
} catch (Exception $e) {
$this->logger->error('Tool connection test failed', [
'endpoint' => $mcpEndpointUrl ?? 'null',
'exception' => $e,
'message' => $e->getMessage(),
]);
return new DataResponse(['error' => $e->getMessage()], 400);
}
}
/**
* @AdminRequired
* Test the connection to the Docling document conversion API
*/
public function testDocling(
?string $doclingApiEndpoint = null,
?string $doclingApiKey = null
): DataResponse {
return $this->runConnectionTest(
'Docling',
$doclingApiEndpoint,
fn(): array => $this->doclingClient->testConnection($doclingApiEndpoint, $doclingApiKey)
);
}
/**
* @AdminRequired
*/
public function testVision(
?string $visionApiEndpoint = null,
?string $visionApiKey = null,
?string $visionModel = null
): DataResponse {
return $this->runConnectionTest(
'Vision',
$visionApiEndpoint,
fn(): array => $this->visionClient->testConnection($visionApiEndpoint, $visionApiKey, $visionModel)
);
}
/**
* @AdminRequired
*/
public function testSpeech(
?string $speechApiEndpoint = null,
?string $speechApiKey = null,
?string $speechModel = null
): DataResponse {
return $this->runConnectionTest(
'Speech',
$speechApiEndpoint,
fn(): array => $this->speechToTextClient->testConnection($speechApiEndpoint, $speechApiKey, $speechModel)
);
}
/**
* @param callable():array{success:bool,error?:string} $testConnection
*/
private function runConnectionTest(string $serviceName, ?string $endpoint, callable $testConnection): DataResponse {
try {
$result = $testConnection();
if ($result['success']) {
return new DataResponse([
'success' => true,
'error' => null,
]);
}
return new DataResponse([
'success' => false,
'error' => $result['error'] ?? 'Connection test failed',
], 400);
} catch (Exception $e) {
$this->logger->error($serviceName . ' connection test failed', [
'endpoint' => $endpoint ?? 'null',
'exception' => $e,
]);
return new DataResponse([
'success' => false,
'error' => $e->getMessage(),
], 400);
}
}
}