forked from NuQloud/nc-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.
780 lines
26 KiB
PHP
780 lines
26 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace OCA\EducAI\Service;
|
|
|
|
use OCA\EducAI\Db\Settings;
|
|
use OCA\EducAI\Db\SettingsMapper;
|
|
use OCP\AppFramework\Db\DoesNotExistException;
|
|
use Psr\Log\LoggerInterface;
|
|
|
|
class SettingsService {
|
|
public const DEFAULT_TEMPERATURE = 0.2;
|
|
public const DEFAULT_LLM_CHAT_TIMEOUT = 90;
|
|
public const DEFAULT_LLM_STREAM_TIMEOUT = 240;
|
|
public const DEFAULT_LLM_MODELS_TIMEOUT = 20;
|
|
public const APP_ICON_MODE_DEFAULT = 'default';
|
|
public const APP_ICON_MODE_CUSTOM = 'custom';
|
|
public const APP_ICON_UPLOAD_PREFIX = 'educai-upload://';
|
|
|
|
private SettingsMapper $mapper;
|
|
private CredentialService $credentialService;
|
|
private TalkBotRegistrationService $talkBotRegistrationService;
|
|
private LoggerInterface $logger;
|
|
|
|
public function __construct(
|
|
SettingsMapper $mapper,
|
|
CredentialService $credentialService,
|
|
TalkBotRegistrationService $talkBotRegistrationService,
|
|
LoggerInterface $logger
|
|
) {
|
|
$this->mapper = $mapper;
|
|
$this->credentialService = $credentialService;
|
|
$this->talkBotRegistrationService = $talkBotRegistrationService;
|
|
$this->logger = $logger;
|
|
}
|
|
|
|
/**
|
|
* Get the global settings.
|
|
* Note: This returns the Settings entity as-is. Sensitive fields are
|
|
* encrypted in the database and will be masked in JSON serialization.
|
|
*
|
|
* @return Settings
|
|
*/
|
|
public function getSettings(): Settings {
|
|
$settings = $this->mapper->getSettings();
|
|
|
|
// Check for and migrate any unencrypted credentials
|
|
$this->migrateCredentialsIfNeeded($settings);
|
|
|
|
return $settings;
|
|
}
|
|
|
|
/**
|
|
* Migrate plaintext credentials to encrypted storage.
|
|
* This is called on every getSettings() to transparently migrate
|
|
* any existing plaintext credentials.
|
|
*/
|
|
private function migrateCredentialsIfNeeded(Settings $settings): void {
|
|
$needsUpdate = false;
|
|
|
|
// Check API key
|
|
$apiKey = $settings->getApiKey();
|
|
if ($this->credentialService->needsMigration($apiKey)) {
|
|
$this->logger->info('EducAI: Migrating API key to encrypted storage');
|
|
$settings->setApiKey($this->credentialService->encrypt($apiKey));
|
|
$needsUpdate = true;
|
|
}
|
|
|
|
// Check webhook secret
|
|
$webhookSecret = $settings->getWebhookSecret();
|
|
if ($this->credentialService->needsMigration($webhookSecret)) {
|
|
$this->logger->info('EducAI: Migrating webhook secret to encrypted storage');
|
|
$settings->setWebhookSecret($this->credentialService->encrypt($webhookSecret));
|
|
$needsUpdate = true;
|
|
}
|
|
|
|
// Check embedding API key
|
|
$embeddingApiKey = $settings->getEmbeddingApiKey();
|
|
if ($this->credentialService->needsMigration($embeddingApiKey)) {
|
|
$this->logger->info('EducAI: Migrating embedding API key to encrypted storage');
|
|
$settings->setEmbeddingApiKey($this->credentialService->encrypt($embeddingApiKey));
|
|
$needsUpdate = true;
|
|
}
|
|
|
|
$visionApiKey = $settings->getVisionApiKey();
|
|
if ($this->credentialService->needsMigration($visionApiKey)) {
|
|
$this->logger->info('EducAI: Migrating vision API key to encrypted storage');
|
|
$settings->setVisionApiKey($this->credentialService->encrypt($visionApiKey));
|
|
$needsUpdate = true;
|
|
}
|
|
|
|
$speechApiKey = $settings->getSpeechApiKey();
|
|
if ($this->credentialService->needsMigration($speechApiKey)) {
|
|
$this->logger->info('EducAI: Migrating speech API key to encrypted storage');
|
|
$settings->setSpeechApiKey($this->credentialService->encrypt($speechApiKey));
|
|
$needsUpdate = true;
|
|
}
|
|
|
|
$doclingApiKey = $settings->getDoclingApiKey();
|
|
if ($this->credentialService->needsMigration($doclingApiKey)) {
|
|
$this->logger->info('EducAI: Migrating Docling API key to encrypted storage');
|
|
$settings->setDoclingApiKey($this->credentialService->encrypt($doclingApiKey));
|
|
$needsUpdate = true;
|
|
}
|
|
|
|
$secondaryApiKey = $settings->getSecondaryApiKey();
|
|
if ($this->credentialService->needsMigration($secondaryApiKey)) {
|
|
$this->logger->info('EducAI: Migrating secondary API key to encrypted storage');
|
|
$settings->setSecondaryApiKey($this->credentialService->encrypt($secondaryApiKey));
|
|
$needsUpdate = true;
|
|
}
|
|
|
|
if ($needsUpdate) {
|
|
$settings->setUpdatedAt(time());
|
|
$this->mapper->update($settings);
|
|
$this->logger->info('EducAI: Credential migration completed');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update settings
|
|
*
|
|
* @param string $apiProvider
|
|
* @param string $apiKey
|
|
* @param string $apiEndpoint
|
|
* @param string $defaultModel
|
|
* @param mixed $defaultTemperature
|
|
* @param string|null $webhookSecret
|
|
* @param bool|null $allowMultipleModels
|
|
* @param array<int,string>|null $allowedModels
|
|
* @param string|null $embeddingApiEndpoint
|
|
* @param string|null $embeddingApiKey
|
|
* @param string|null $embeddingModel
|
|
* @param int|null $ragChunkSize
|
|
* @param int|null $ragChunkOverlap
|
|
* @param bool|null $ragEnabled
|
|
* @param bool|null $catalogueEnabled
|
|
* @param string|null $catalogueApiEndpoint
|
|
* @param int|null $catalogueReindexHours
|
|
* @param bool|null $doclingEnabled
|
|
* @param string|null $doclingApiEndpoint
|
|
* @param string|null $doclingApiKey
|
|
* @param string|null $visionApiEndpoint
|
|
* @param string|null $visionApiKey
|
|
* @param string|null $visionModel
|
|
* @param string|null $speechApiEndpoint
|
|
* @param string|null $speechApiKey
|
|
* @param string|null $speechModel
|
|
* @param bool|null $rateLimitEnabled
|
|
* @param int|null $rateLimitSecond
|
|
* @param int|null $rateLimitMinute
|
|
* @param int|null $rateLimitHour
|
|
* @param int|null $rateLimitDay
|
|
* @param string|null $rateLimitQueueMessage
|
|
* @param int|null $conversationContextTokens
|
|
* @param string|null $embeddingRateLimitMode
|
|
* @param int|null $embeddingRateLimitSecond
|
|
* @param int|null $embeddingRateLimitMinute
|
|
* @param int|null $embeddingRateLimitHour
|
|
* @param int|null $embeddingRateLimitDay
|
|
* @param string|null $appIconUrl
|
|
* @param string|null $appIconMode
|
|
* @param string|null $appIconBlackUrl
|
|
* @param string|null $appIconWhiteUrl
|
|
* @return Settings
|
|
*/
|
|
public function updateSettings(
|
|
string $apiProvider,
|
|
string $apiKey,
|
|
string $apiEndpoint,
|
|
string $defaultModel,
|
|
$defaultTemperature = self::DEFAULT_TEMPERATURE,
|
|
?string $webhookSecret = null,
|
|
?bool $allowMultipleModels = null,
|
|
?array $allowedModels = null,
|
|
?string $embeddingApiEndpoint = null,
|
|
?string $embeddingApiKey = null,
|
|
?string $embeddingModel = null,
|
|
?int $ragChunkSize = null,
|
|
?int $ragChunkOverlap = null,
|
|
?bool $ragEnabled = null,
|
|
?bool $catalogueEnabled = null,
|
|
?string $catalogueApiEndpoint = null,
|
|
?int $catalogueReindexHours = null,
|
|
?bool $doclingEnabled = null,
|
|
?string $doclingApiEndpoint = null,
|
|
?string $doclingApiKey = null,
|
|
?string $visionApiEndpoint = null,
|
|
?string $visionApiKey = null,
|
|
?string $visionModel = null,
|
|
?string $speechApiEndpoint = null,
|
|
?string $speechApiKey = null,
|
|
?string $speechModel = null,
|
|
?bool $rateLimitEnabled = null,
|
|
?int $rateLimitSecond = null,
|
|
?int $rateLimitMinute = null,
|
|
?int $rateLimitHour = null,
|
|
?int $rateLimitDay = null,
|
|
?string $rateLimitQueueMessage = null,
|
|
?int $conversationContextTokens = null,
|
|
?string $embeddingRateLimitMode = null,
|
|
?int $embeddingRateLimitSecond = null,
|
|
?int $embeddingRateLimitMinute = null,
|
|
?int $embeddingRateLimitHour = null,
|
|
?int $embeddingRateLimitDay = null,
|
|
?string $secondaryApiEndpoint = null,
|
|
?string $secondaryApiKey = null,
|
|
?string $fallbackModel = null,
|
|
?int $llmChatTimeout = null,
|
|
?int $llmStreamTimeout = null,
|
|
?int $llmModelsTimeout = null,
|
|
?string $appIconUrl = null,
|
|
?string $appIconMode = null,
|
|
?string $appIconBlackUrl = null,
|
|
?string $appIconWhiteUrl = null
|
|
): Settings {
|
|
$settings = $this->mapper->getSettings();
|
|
$shouldSyncTalkBot = false;
|
|
$talkBotSecret = '';
|
|
|
|
$settings->setApiProvider($apiProvider);
|
|
if (!empty($apiKey)) {
|
|
// Encrypt API key before storing
|
|
$settings->setApiKey($this->credentialService->encrypt($apiKey));
|
|
}
|
|
$settings->setApiEndpoint($apiEndpoint);
|
|
$settings->setDefaultModel($defaultModel);
|
|
if ($secondaryApiEndpoint !== null) {
|
|
$settings->setSecondaryApiEndpoint($secondaryApiEndpoint !== '' ? $secondaryApiEndpoint : null);
|
|
}
|
|
if ($secondaryApiKey !== null && $secondaryApiKey !== '') {
|
|
$settings->setSecondaryApiKey($this->credentialService->encrypt($secondaryApiKey));
|
|
}
|
|
if ($fallbackModel !== null) {
|
|
$settings->setFallbackModel($fallbackModel !== '' ? $fallbackModel : null);
|
|
}
|
|
if ($defaultTemperature !== null) {
|
|
$settings->setDefaultTemperature($this->normalizeTemperatureValue($defaultTemperature, false, 'default temperature'));
|
|
} elseif ($settings->getDefaultTemperature() === null) {
|
|
$settings->setDefaultTemperature(self::DEFAULT_TEMPERATURE);
|
|
}
|
|
// Only update the webhook secret when a non-empty value is provided.
|
|
// This prevents accidentally clearing the stored secret when the UI leaves
|
|
// the field blank (it is intentionally not prefilled for security).
|
|
if ($webhookSecret !== null) {
|
|
$normalizedSecret = trim((string)$webhookSecret);
|
|
if ($normalizedSecret !== '') {
|
|
// Encrypt webhook secret before storing
|
|
$settings->setWebhookSecret($this->credentialService->encrypt($normalizedSecret));
|
|
$shouldSyncTalkBot = true;
|
|
$talkBotSecret = $normalizedSecret;
|
|
}
|
|
}
|
|
if ($allowMultipleModels !== null) {
|
|
$settings->setAllowMultipleModels($allowMultipleModels);
|
|
}
|
|
if ($allowedModels !== null) {
|
|
// store as JSON array of strings
|
|
$encoded = json_encode(array_values(array_map('strval', $allowedModels)));
|
|
$settings->setAllowedModels($encoded === false ? '[]' : $encoded);
|
|
}
|
|
if ($embeddingApiEndpoint !== null) {
|
|
$settings->setEmbeddingApiEndpoint($embeddingApiEndpoint !== '' ? $embeddingApiEndpoint : null);
|
|
}
|
|
if ($embeddingApiKey !== null && $embeddingApiKey !== '') {
|
|
// Encrypt embedding API key before storing
|
|
$settings->setEmbeddingApiKey($this->credentialService->encrypt($embeddingApiKey));
|
|
}
|
|
if ($embeddingModel !== null) {
|
|
$settings->setEmbeddingModel($embeddingModel !== '' ? $embeddingModel : null);
|
|
}
|
|
if ($embeddingRateLimitMode !== null) {
|
|
$settings->setEmbeddingRateLimitMode($this->normalizeEmbeddingRateLimitMode($embeddingRateLimitMode));
|
|
}
|
|
if (
|
|
$embeddingRateLimitMode !== null
|
|
|| $embeddingRateLimitSecond !== null
|
|
|| $embeddingRateLimitMinute !== null
|
|
|| $embeddingRateLimitHour !== null
|
|
|| $embeddingRateLimitDay !== null
|
|
) {
|
|
$settings->setEmbeddingRateLimitSecond($embeddingRateLimitSecond !== null && $embeddingRateLimitSecond > 0 ? $embeddingRateLimitSecond : null);
|
|
$settings->setEmbeddingRateLimitMinute($embeddingRateLimitMinute !== null && $embeddingRateLimitMinute > 0 ? $embeddingRateLimitMinute : 100);
|
|
$settings->setEmbeddingRateLimitHour($embeddingRateLimitHour !== null && $embeddingRateLimitHour > 0 ? $embeddingRateLimitHour : 2000);
|
|
$settings->setEmbeddingRateLimitDay($embeddingRateLimitDay !== null && $embeddingRateLimitDay > 0 ? $embeddingRateLimitDay : 4000);
|
|
}
|
|
if ($ragChunkSize !== null) {
|
|
$settings->setRagChunkSize($ragChunkSize);
|
|
}
|
|
if ($ragChunkOverlap !== null) {
|
|
$settings->setRagChunkOverlap($ragChunkOverlap);
|
|
}
|
|
if ($ragEnabled !== null) {
|
|
$settings->setRagEnabled($ragEnabled);
|
|
}
|
|
if ($catalogueEnabled !== null) {
|
|
$settings->setCatalogueEnabled($catalogueEnabled);
|
|
}
|
|
if ($catalogueApiEndpoint !== null) {
|
|
$settings->setCatalogueApiEndpoint($catalogueApiEndpoint !== '' ? $catalogueApiEndpoint : null);
|
|
}
|
|
if ($catalogueReindexHours !== null) {
|
|
$settings->setCatalogueReindexHours($catalogueReindexHours > 0 ? $catalogueReindexHours : 24);
|
|
}
|
|
if ($doclingEnabled !== null) {
|
|
$settings->setDoclingEnabled($doclingEnabled);
|
|
}
|
|
if ($doclingApiEndpoint !== null) {
|
|
$settings->setDoclingApiEndpoint($doclingApiEndpoint !== '' ? $doclingApiEndpoint : null);
|
|
}
|
|
if ($doclingApiKey !== null && $doclingApiKey !== '') {
|
|
$settings->setDoclingApiKey($this->credentialService->encrypt($doclingApiKey));
|
|
}
|
|
if ($visionApiEndpoint !== null) {
|
|
$settings->setVisionApiEndpoint($visionApiEndpoint !== '' ? $visionApiEndpoint : null);
|
|
}
|
|
if ($visionApiKey !== null && $visionApiKey !== '') {
|
|
$settings->setVisionApiKey($this->credentialService->encrypt($visionApiKey));
|
|
}
|
|
if ($visionModel !== null) {
|
|
$settings->setVisionModel($visionModel !== '' ? $visionModel : null);
|
|
}
|
|
if ($speechApiEndpoint !== null) {
|
|
$settings->setSpeechApiEndpoint($speechApiEndpoint !== '' ? $speechApiEndpoint : null);
|
|
}
|
|
if ($speechApiKey !== null && $speechApiKey !== '') {
|
|
$settings->setSpeechApiKey($this->credentialService->encrypt($speechApiKey));
|
|
}
|
|
if ($speechModel !== null) {
|
|
$settings->setSpeechModel($speechModel !== '' ? $speechModel : null);
|
|
}
|
|
if ($rateLimitEnabled !== null) {
|
|
$settings->setRateLimitEnabled($rateLimitEnabled);
|
|
}
|
|
if ($rateLimitEnabled !== null || $rateLimitSecond !== null || $rateLimitMinute !== null || $rateLimitHour !== null || $rateLimitDay !== null) {
|
|
$settings->setRateLimitSecond($rateLimitSecond !== null && $rateLimitSecond > 0 ? $rateLimitSecond : null);
|
|
$settings->setRateLimitMinute($rateLimitMinute !== null && $rateLimitMinute > 0 ? $rateLimitMinute : 30);
|
|
$settings->setRateLimitHour($rateLimitHour !== null && $rateLimitHour > 0 ? $rateLimitHour : 200);
|
|
$settings->setRateLimitDay($rateLimitDay !== null && $rateLimitDay > 0 ? $rateLimitDay : 1000);
|
|
}
|
|
if ($rateLimitQueueMessage !== null) {
|
|
$settings->setRateLimitQueueMessage($rateLimitQueueMessage !== '' ? $rateLimitQueueMessage : null);
|
|
}
|
|
if ($conversationContextTokens !== null) {
|
|
$settings->setConversationContextTokens($conversationContextTokens > 0 ? $conversationContextTokens : 8000);
|
|
}
|
|
if ($llmChatTimeout !== null) {
|
|
$settings->setLlmChatTimeout($this->normalizePositiveInteger($llmChatTimeout, self::DEFAULT_LLM_CHAT_TIMEOUT));
|
|
}
|
|
if ($llmStreamTimeout !== null) {
|
|
$settings->setLlmStreamTimeout($this->normalizePositiveInteger($llmStreamTimeout, self::DEFAULT_LLM_STREAM_TIMEOUT));
|
|
}
|
|
if ($llmModelsTimeout !== null) {
|
|
$settings->setLlmModelsTimeout($this->normalizePositiveInteger($llmModelsTimeout, self::DEFAULT_LLM_MODELS_TIMEOUT));
|
|
}
|
|
if ($appIconMode !== null || $appIconBlackUrl !== null || $appIconWhiteUrl !== null) {
|
|
$this->updateAppIconConfig(
|
|
$settings,
|
|
$appIconMode,
|
|
$appIconBlackUrl,
|
|
$appIconWhiteUrl
|
|
);
|
|
} elseif ($appIconUrl !== null) {
|
|
$this->updateLegacyAppIconUrl($settings, $appIconUrl);
|
|
}
|
|
$settings->setUpdatedAt(time());
|
|
|
|
$updatedSettings = $this->mapper->update($settings);
|
|
|
|
if ($shouldSyncTalkBot) {
|
|
$this->talkBotRegistrationService->syncRegistration($talkBotSecret);
|
|
}
|
|
|
|
return $updatedSettings;
|
|
}
|
|
|
|
public function getDefaultTemperature(): float {
|
|
return $this->sanitizeTemperatureForRuntime(
|
|
$this->mapper->getSettings()->getDefaultTemperature(),
|
|
self::DEFAULT_TEMPERATURE
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param mixed $value
|
|
*/
|
|
public function normalizeTemperatureValue($value, bool $allowNull = true, string $fieldName = 'temperature'): ?float {
|
|
if ($value === null || $value === '') {
|
|
if ($allowNull) {
|
|
return null;
|
|
}
|
|
|
|
throw new \InvalidArgumentException(ucfirst($fieldName) . ' is required');
|
|
}
|
|
|
|
if (is_bool($value)) {
|
|
throw new \InvalidArgumentException('Invalid ' . $fieldName . ' value');
|
|
}
|
|
|
|
if (is_string($value)) {
|
|
$value = trim($value);
|
|
if ($value === '') {
|
|
if ($allowNull) {
|
|
return null;
|
|
}
|
|
throw new \InvalidArgumentException(ucfirst($fieldName) . ' is required');
|
|
}
|
|
}
|
|
|
|
if (!is_numeric($value)) {
|
|
throw new \InvalidArgumentException('Invalid ' . $fieldName . ' value');
|
|
}
|
|
|
|
$temperature = (float)$value;
|
|
if (!is_finite($temperature) || $temperature < 0.0 || $temperature > 1.0) {
|
|
throw new \InvalidArgumentException(ucfirst($fieldName) . ' must be between 0.0 and 1.0');
|
|
}
|
|
|
|
return round($temperature, 2);
|
|
}
|
|
|
|
public function sanitizeTemperatureForRuntime(?float $value, float $fallback = self::DEFAULT_TEMPERATURE): float {
|
|
if ($value === null || !is_finite($value) || $value < 0.0 || $value > 1.0) {
|
|
return $fallback;
|
|
}
|
|
|
|
return round($value, 2);
|
|
}
|
|
|
|
public function normalizePositiveInteger(?int $value, int $fallback): int {
|
|
return $value !== null && $value > 0 ? $value : $fallback;
|
|
}
|
|
|
|
public function normalizeAppIconUrl(?string $value): ?string {
|
|
$url = trim((string)$value);
|
|
if ($url === '') {
|
|
return null;
|
|
}
|
|
|
|
if (strlen($url) > 1024) {
|
|
throw new \InvalidArgumentException('App icon URL must be 1024 characters or shorter');
|
|
}
|
|
|
|
if (str_starts_with($url, self::APP_ICON_UPLOAD_PREFIX)) {
|
|
if (!$this->isValidUploadedIconReference($url)) {
|
|
throw new \InvalidArgumentException('App icon upload reference is invalid');
|
|
}
|
|
|
|
return $url;
|
|
}
|
|
|
|
if (preg_match('/^https?:\/\//i', $url) === 1) {
|
|
if (filter_var($url, FILTER_VALIDATE_URL) === false) {
|
|
throw new \InvalidArgumentException('App icon URL must be a valid http(s) URL');
|
|
}
|
|
|
|
return $url;
|
|
}
|
|
|
|
if (str_starts_with($url, '/') && !str_starts_with($url, '//')) {
|
|
return $url;
|
|
}
|
|
|
|
throw new \InvalidArgumentException('App icon URL must be an http(s) URL, an absolute Nextcloud path, or an uploaded SVG reference');
|
|
}
|
|
|
|
private function isValidUploadedIconReference(string $value): bool {
|
|
$parts = parse_url($value);
|
|
if (!is_array($parts)) {
|
|
return false;
|
|
}
|
|
|
|
return ($parts['scheme'] ?? '') === 'educai-upload'
|
|
&& in_array((string)($parts['host'] ?? ''), ['black', 'white'], true);
|
|
}
|
|
|
|
public function normalizeAppIconMode(?string $value): string {
|
|
$mode = trim((string)($value ?? self::APP_ICON_MODE_DEFAULT));
|
|
if ($mode === '') {
|
|
$mode = self::APP_ICON_MODE_DEFAULT;
|
|
}
|
|
|
|
if (!in_array($mode, [
|
|
self::APP_ICON_MODE_DEFAULT,
|
|
self::APP_ICON_MODE_CUSTOM,
|
|
], true)) {
|
|
throw new \InvalidArgumentException('App icon mode must be default or custom');
|
|
}
|
|
|
|
return $mode;
|
|
}
|
|
|
|
private function updateLegacyAppIconUrl(Settings $settings, ?string $appIconUrl): void {
|
|
$normalized = $this->normalizeAppIconUrl($appIconUrl);
|
|
$settings->setAppIconUrl($normalized);
|
|
if ($normalized === null) {
|
|
$settings->setAppIconMode(self::APP_ICON_MODE_DEFAULT);
|
|
$settings->setAppIconPreset(null);
|
|
$settings->setAppIconBlackUrl(null);
|
|
$settings->setAppIconWhiteUrl(null);
|
|
return;
|
|
}
|
|
|
|
$settings->setAppIconMode(self::APP_ICON_MODE_CUSTOM);
|
|
$settings->setAppIconPreset(null);
|
|
$settings->setAppIconBlackUrl($normalized);
|
|
$settings->setAppIconWhiteUrl($normalized);
|
|
}
|
|
|
|
private function updateAppIconConfig(
|
|
Settings $settings,
|
|
?string $appIconMode,
|
|
?string $appIconBlackUrl,
|
|
?string $appIconWhiteUrl
|
|
): void {
|
|
$mode = $this->normalizeAppIconMode($appIconMode);
|
|
if ($mode === self::APP_ICON_MODE_DEFAULT) {
|
|
$settings->setAppIconMode(self::APP_ICON_MODE_DEFAULT);
|
|
$settings->setAppIconPreset(null);
|
|
$settings->setAppIconBlackUrl(null);
|
|
$settings->setAppIconWhiteUrl(null);
|
|
$settings->setAppIconUrl(null);
|
|
return;
|
|
}
|
|
|
|
if ($mode === self::APP_ICON_MODE_CUSTOM) {
|
|
$blackUrl = $this->normalizeAppIconUrl($appIconBlackUrl);
|
|
$whiteUrl = $this->normalizeAppIconUrl($appIconWhiteUrl);
|
|
if ($blackUrl === null || $whiteUrl === null) {
|
|
throw new \InvalidArgumentException('Custom app icon mode requires both black and white icon URLs');
|
|
}
|
|
|
|
$settings->setAppIconMode(self::APP_ICON_MODE_CUSTOM);
|
|
$settings->setAppIconPreset(null);
|
|
$settings->setAppIconBlackUrl($blackUrl);
|
|
$settings->setAppIconWhiteUrl($whiteUrl);
|
|
$settings->setAppIconUrl($blackUrl);
|
|
return;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get API key (for internal use only).
|
|
* Returns the decrypted API key.
|
|
*
|
|
* @return string
|
|
*/
|
|
public function getApiKey(): string {
|
|
$encryptedKey = $this->mapper->getSettings()->getApiKey();
|
|
return $this->credentialService->decrypt($encryptedKey ?? '');
|
|
}
|
|
|
|
public function getSecondaryApiKey(): ?string {
|
|
return $this->decryptOptionalCredential($this->mapper->getSettings()->getSecondaryApiKey());
|
|
}
|
|
|
|
/**
|
|
* Get webhook secret (for internal use only).
|
|
* Returns the decrypted webhook secret.
|
|
*
|
|
* @return string
|
|
*/
|
|
public function getWebhookSecret(): string {
|
|
$encryptedSecret = $this->mapper->getSettings()->getWebhookSecret();
|
|
return $this->credentialService->decrypt($encryptedSecret ?? '');
|
|
}
|
|
|
|
/**
|
|
* Get embedding API key (for internal use only).
|
|
* Returns the decrypted embedding API key.
|
|
*
|
|
* @return string|null
|
|
*/
|
|
public function getEmbeddingApiKey(): ?string {
|
|
$encryptedKey = $this->mapper->getSettings()->getEmbeddingApiKey();
|
|
if ($encryptedKey === null || $encryptedKey === '') {
|
|
return null;
|
|
}
|
|
$decrypted = $this->credentialService->decrypt($encryptedKey);
|
|
return $decrypted !== '' ? $decrypted : null;
|
|
}
|
|
|
|
public function getVisionApiKey(): ?string {
|
|
return $this->decryptOptionalCredential($this->mapper->getSettings()->getVisionApiKey());
|
|
}
|
|
|
|
public function getSpeechApiKey(): ?string {
|
|
return $this->decryptOptionalCredential($this->mapper->getSettings()->getSpeechApiKey());
|
|
}
|
|
|
|
public function getDoclingApiKey(): ?string {
|
|
return $this->decryptOptionalCredential($this->mapper->getSettings()->getDoclingApiKey());
|
|
}
|
|
|
|
public function getEmbeddingEndpoint(): ?string {
|
|
return $this->mapper->getSettings()->getEmbeddingApiEndpoint();
|
|
}
|
|
|
|
public function getEmbeddingModel(): ?string {
|
|
return $this->mapper->getSettings()->getEmbeddingModel();
|
|
}
|
|
|
|
/**
|
|
* @return array{
|
|
* rag_enabled: bool,
|
|
* embedding_api_endpoint: ?string,
|
|
* embedding_api_key: ?string,
|
|
* embedding_model: ?string,
|
|
* embedding_rate_limit_mode: string,
|
|
* embedding_rate_limit_second: ?int,
|
|
* embedding_rate_limit_minute: int,
|
|
* embedding_rate_limit_hour: int,
|
|
* embedding_rate_limit_day: int,
|
|
* rag_chunk_size: ?int,
|
|
* rag_chunk_overlap: ?int
|
|
* }
|
|
*/
|
|
public function getRagConfig(): array {
|
|
$settings = $this->mapper->getSettings();
|
|
|
|
// Decrypt the embedding API key
|
|
$embeddingApiKey = $settings->getEmbeddingApiKey();
|
|
$decryptedEmbeddingKey = null;
|
|
if ($embeddingApiKey !== null && $embeddingApiKey !== '') {
|
|
$decryptedEmbeddingKey = $this->credentialService->decrypt($embeddingApiKey);
|
|
if ($decryptedEmbeddingKey === '') {
|
|
$decryptedEmbeddingKey = null;
|
|
}
|
|
}
|
|
|
|
return [
|
|
'rag_enabled' => (bool)$settings->getRagEnabled(),
|
|
'embedding_api_endpoint' => $settings->getEmbeddingApiEndpoint(),
|
|
'embedding_api_key' => $decryptedEmbeddingKey,
|
|
'embedding_model' => $settings->getEmbeddingModel(),
|
|
'embedding_rate_limit_mode' => $this->normalizeEmbeddingRateLimitMode($settings->getEmbeddingRateLimitMode()),
|
|
'embedding_rate_limit_second' => $settings->getEmbeddingRateLimitSecond(),
|
|
'embedding_rate_limit_minute' => $settings->getEmbeddingRateLimitMinute() ?? 100,
|
|
'embedding_rate_limit_hour' => $settings->getEmbeddingRateLimitHour() ?? 2000,
|
|
'embedding_rate_limit_day' => $settings->getEmbeddingRateLimitDay() ?? 4000,
|
|
'rag_chunk_size' => $settings->getRagChunkSize(),
|
|
'rag_chunk_overlap' => $settings->getRagChunkOverlap(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array{
|
|
* catalogue_enabled: bool,
|
|
* catalogue_api_endpoint: ?string,
|
|
* catalogue_reindex_hours: int,
|
|
* catalogue_last_indexed: ?int,
|
|
* catalogue_course_count: int
|
|
* }
|
|
*/
|
|
public function getCatalogueConfig(): array {
|
|
$settings = $this->mapper->getSettings();
|
|
return [
|
|
'catalogue_enabled' => (bool)$settings->getCatalogueEnabled(),
|
|
'catalogue_api_endpoint' => $settings->getCatalogueApiEndpoint(),
|
|
'catalogue_reindex_hours' => $settings->getCatalogueReindexHours() ?? 24,
|
|
'catalogue_last_indexed' => $settings->getCatalogueLastIndexed(),
|
|
'catalogue_course_count' => $settings->getCatalogueCourseCount() ?? 0,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Update catalogue index statistics
|
|
*/
|
|
public function updateCatalogueIndexStats(int $courseCount): void {
|
|
$settings = $this->mapper->getSettings();
|
|
$settings->setCatalogueLastIndexed(time());
|
|
$settings->setCatalogueCourseCount($courseCount);
|
|
$settings->setUpdatedAt(time());
|
|
$this->mapper->update($settings);
|
|
}
|
|
|
|
/**
|
|
* @return array{
|
|
* docling_enabled: bool,
|
|
* docling_api_endpoint: ?string,
|
|
* api_key: ?string
|
|
* }
|
|
*/
|
|
public function getDoclingConfig(): array {
|
|
$settings = $this->mapper->getSettings();
|
|
|
|
return [
|
|
'docling_enabled' => (bool)$settings->getDoclingEnabled(),
|
|
'docling_api_endpoint' => $settings->getDoclingApiEndpoint(),
|
|
'api_key' => $this->decryptOptionalCredential($settings->getDoclingApiKey())
|
|
?? $this->credentialService->decrypt($settings->getApiKey() ?? ''),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array{
|
|
* enabled: bool,
|
|
* endpoint: ?string,
|
|
* api_key: ?string,
|
|
* model: ?string
|
|
* }
|
|
*/
|
|
public function getVisionConfig(): array {
|
|
$settings = $this->mapper->getSettings();
|
|
$model = $settings->getVisionModel();
|
|
$endpoint = $settings->getVisionApiEndpoint();
|
|
|
|
return [
|
|
'enabled' => $model !== null && $model !== '',
|
|
'endpoint' => $endpoint,
|
|
'api_key' => $this->getVisionApiKey(),
|
|
'model' => $model !== '' ? $model : null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array{
|
|
* enabled: bool,
|
|
* endpoint: ?string,
|
|
* api_key: ?string,
|
|
* model: ?string
|
|
* }
|
|
*/
|
|
public function getSpeechConfig(): array {
|
|
$settings = $this->mapper->getSettings();
|
|
$model = $settings->getSpeechModel();
|
|
$endpoint = $settings->getSpeechApiEndpoint();
|
|
|
|
return [
|
|
'enabled' => $model !== null && $model !== '',
|
|
'endpoint' => $endpoint,
|
|
'api_key' => $this->getSpeechApiKey(),
|
|
'model' => $model !== '' ? $model : null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array{
|
|
* rate_limit_enabled: bool,
|
|
* rate_limit_second: ?int,
|
|
* rate_limit_minute: int,
|
|
* rate_limit_hour: int,
|
|
* rate_limit_day: int,
|
|
* rate_limit_queue_message: ?string
|
|
* }
|
|
*/
|
|
public function getRateLimitConfig(): array {
|
|
$settings = $this->mapper->getSettings();
|
|
$second = $settings->getRateLimitSecond();
|
|
$minute = $settings->getRateLimitMinute();
|
|
$hour = $settings->getRateLimitHour();
|
|
$day = $settings->getRateLimitDay();
|
|
return [
|
|
'rate_limit_enabled' => (bool)$settings->getRateLimitEnabled(),
|
|
'rate_limit_second' => $second !== null && $second > 0 ? $second : null,
|
|
'rate_limit_minute' => $minute !== null && $minute > 0 ? $minute : 30,
|
|
'rate_limit_hour' => $hour !== null && $hour > 0 ? $hour : 200,
|
|
'rate_limit_day' => $day !== null && $day > 0 ? $day : 1000,
|
|
'rate_limit_queue_message' => $settings->getRateLimitQueueMessage(),
|
|
];
|
|
}
|
|
|
|
private function normalizeEmbeddingRateLimitMode(?string $mode): string {
|
|
return match ($mode) {
|
|
'disabled', 'custom' => $mode,
|
|
default => 'inherit',
|
|
};
|
|
}
|
|
|
|
private function decryptOptionalCredential(?string $encryptedValue): ?string {
|
|
if ($encryptedValue === null || $encryptedValue === '') {
|
|
return null;
|
|
}
|
|
|
|
$decrypted = $this->credentialService->decrypt($encryptedValue);
|
|
return $decrypted !== '' ? $decrypted : null;
|
|
}
|
|
}
|