diff --git a/third_party/astroglobe/appinfo/routes.php b/third_party/astroglobe/appinfo/routes.php index e786d3b..3ecc74f 100644 --- a/third_party/astroglobe/appinfo/routes.php +++ b/third_party/astroglobe/appinfo/routes.php @@ -45,5 +45,12 @@ return [ 'url' => '/api/vector-status', 'verb' => 'GET', ], + + // Admin settings routes + [ + 'name' => 'api#saveSearchSettings', + 'url' => '/api/admin/search-settings', + 'verb' => 'POST', + ], ], ]; diff --git a/third_party/astroglobe/lib/Controller/ApiController.php b/third_party/astroglobe/lib/Controller/ApiController.php index 24efc18..0c49ca1 100644 --- a/third_party/astroglobe/lib/Controller/ApiController.php +++ b/third_party/astroglobe/lib/Controller/ApiController.php @@ -6,12 +6,14 @@ namespace OCA\Astroglobe\Controller; use OCA\Astroglobe\Service\McpServerClient; use OCA\Astroglobe\Service\McpTokenStorage; +use OCA\Astroglobe\Settings\Admin as AdminSettings; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\Attribute\NoCSRFRequired; use OCP\AppFramework\Http\JSONResponse; use OCP\AppFramework\Http\RedirectResponse; +use OCP\IConfig; use OCP\IRequest; use OCP\IURLGenerator; use OCP\IUserSession; @@ -28,6 +30,7 @@ class ApiController extends Controller { private $urlGenerator; private $logger; private $tokenStorage; + private $config; public function __construct( string $appName, @@ -36,7 +39,8 @@ class ApiController extends Controller { IUserSession $userSession, IURLGenerator $urlGenerator, LoggerInterface $logger, - McpTokenStorage $tokenStorage + McpTokenStorage $tokenStorage, + IConfig $config ) { parent::__construct($appName, $request); $this->client = $client; @@ -44,6 +48,7 @@ class ApiController extends Controller { $this->urlGenerator = $urlGenerator; $this->logger = $logger; $this->tokenStorage = $tokenStorage; + $this->config = $config; } /** @@ -223,4 +228,83 @@ class ApiController extends Controller { 'status' => $status ]); } + + /** + * Save admin search settings. + * + * Admin-only endpoint to configure AI Search provider parameters. + * + * @return JSONResponse + */ + public function saveSearchSettings(): JSONResponse { + // Parse JSON body + $input = file_get_contents('php://input'); + $data = json_decode($input, true); + + if ($data === null) { + return new JSONResponse([ + 'success' => false, + 'error' => 'Invalid JSON body' + ], Http::STATUS_BAD_REQUEST); + } + + // Validate and save algorithm + $validAlgorithms = ['hybrid', 'semantic', 'bm25']; + $algorithm = $data['algorithm'] ?? AdminSettings::DEFAULT_SEARCH_ALGORITHM; + if (!in_array($algorithm, $validAlgorithms)) { + $algorithm = AdminSettings::DEFAULT_SEARCH_ALGORITHM; + } + $this->config->setAppValue( + $this->appName, + AdminSettings::SETTING_SEARCH_ALGORITHM, + $algorithm + ); + + // Validate and save fusion method + $validFusions = ['rrf', 'dbsf']; + $fusion = $data['fusion'] ?? AdminSettings::DEFAULT_SEARCH_FUSION; + if (!in_array($fusion, $validFusions)) { + $fusion = AdminSettings::DEFAULT_SEARCH_FUSION; + } + $this->config->setAppValue( + $this->appName, + AdminSettings::SETTING_SEARCH_FUSION, + $fusion + ); + + // Validate and save score threshold (0-100) + $scoreThreshold = (int)($data['scoreThreshold'] ?? AdminSettings::DEFAULT_SEARCH_SCORE_THRESHOLD); + $scoreThreshold = max(0, min(100, $scoreThreshold)); + $this->config->setAppValue( + $this->appName, + AdminSettings::SETTING_SEARCH_SCORE_THRESHOLD, + (string)$scoreThreshold + ); + + // Validate and save limit (5-100) + $limit = (int)($data['limit'] ?? AdminSettings::DEFAULT_SEARCH_LIMIT); + $limit = max(5, min(100, $limit)); + $this->config->setAppValue( + $this->appName, + AdminSettings::SETTING_SEARCH_LIMIT, + (string)$limit + ); + + $this->logger->info('Admin search settings saved', [ + 'algorithm' => $algorithm, + 'fusion' => $fusion, + 'scoreThreshold' => $scoreThreshold, + 'limit' => $limit, + ]); + + return new JSONResponse([ + 'success' => true, + 'settings' => [ + 'algorithm' => $algorithm, + 'fusion' => $fusion, + 'scoreThreshold' => $scoreThreshold, + 'limit' => $limit, + ] + ]); + } } diff --git a/third_party/astroglobe/lib/Controller/OAuthController.php b/third_party/astroglobe/lib/Controller/OAuthController.php index 8b4a627..1e7b94d 100644 --- a/third_party/astroglobe/lib/Controller/OAuthController.php +++ b/third_party/astroglobe/lib/Controller/OAuthController.php @@ -367,6 +367,10 @@ class OAuthController extends Controller { 'astroglobe.oauth.oauthCallback' ); + // Get public MCP server URL for token audience (RFC 8707 Resource Indicator) + // Use public URL that clients/browsers see, not internal Docker URL + $mcpServerPublicUrl = $this->config->getSystemValue('mcp_server_public_url', $mcpServerUrl); + // Build authorization URL with PKCE $params = [ 'client_id' => 'nextcloudMcpServerUIPublicClient', // Public client ID (32+ chars required by NC OIDC) @@ -376,6 +380,7 @@ class OAuthController extends Controller { 'state' => $state, 'code_challenge' => $codeChallenge, 'code_challenge_method' => 'S256', + 'resource' => $mcpServerPublicUrl, // RFC 8707 Resource Indicator - request token with MCP server audience ]; return $authEndpoint . '?' . http_build_query($params); diff --git a/third_party/astroglobe/lib/Search/SemanticSearchProvider.php b/third_party/astroglobe/lib/Search/SemanticSearchProvider.php index bb3d753..c19eb25 100644 --- a/third_party/astroglobe/lib/Search/SemanticSearchProvider.php +++ b/third_party/astroglobe/lib/Search/SemanticSearchProvider.php @@ -50,7 +50,7 @@ class SemanticSearchProvider implements IProvider { * Display name shown in search results grouping. */ public function getName(): string { - return $this->l10n->t('Astroglob'); + return $this->l10n->t('Astroglobe'); } /** diff --git a/third_party/astroglobe/lib/Service/McpServerClient.php b/third_party/astroglobe/lib/Service/McpServerClient.php index d9dd600..ea66710 100644 --- a/third_party/astroglobe/lib/Service/McpServerClient.php +++ b/third_party/astroglobe/lib/Service/McpServerClient.php @@ -248,6 +248,80 @@ class McpServerClient { } } + /** + * Execute semantic search for Nextcloud Unified Search. + * + * Simplified search method specifically for the unified search provider. + * Uses OAuth bearer token for authentication and user-scoped filtering. + * + * @param string $query Search query string + * @param string $token OAuth bearer token for authentication + * @param int $limit Maximum number of results (default: 20) + * @param int $offset Pagination offset (default: 0) + * @param string $algorithm Search algorithm: hybrid, semantic, or bm25 (default: hybrid) + * @param string $fusion Fusion method for hybrid: rrf or dbsf (default: rrf) + * @param float $scoreThreshold Minimum score threshold 0-1 (default: 0) + * @return array{ + * results?: array, + * total_found?: int, + * algorithm_used?: string, + * error?: string + * } + */ + public function searchForUnifiedSearch( + string $query, + string $token, + int $limit = 20, + int $offset = 0, + string $algorithm = 'hybrid', + string $fusion = 'rrf', + float $scoreThreshold = 0.0 + ): array { + try { + $response = $this->httpClient->post( + $this->baseUrl . '/api/v1/search', + [ + 'headers' => [ + 'Authorization' => 'Bearer ' . $token, + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'query' => $query, + 'algorithm' => $algorithm, + 'fusion' => $fusion, + 'score_threshold' => $scoreThreshold, + 'limit' => min($limit, 100), + 'offset' => $offset, + 'include_pca' => false, + 'include_chunks' => true, + ] + ] + ); + $data = json_decode($response->getBody(), true); + + if (json_last_error() !== JSON_ERROR_NONE) { + throw new \RuntimeException('Invalid JSON response from server'); + } + + return $data; + } catch (\Exception $e) { + $this->logger->error('Unified search failed', [ + 'error' => $e->getMessage(), + 'query' => $query, + ]); + return ['error' => $e->getMessage()]; + } + } + /** * Check if the MCP server is reachable and API key is valid. * diff --git a/third_party/astroglobe/lib/Settings/Admin.php b/third_party/astroglobe/lib/Settings/Admin.php index 2bab18f..7f12a0d 100644 --- a/third_party/astroglobe/lib/Settings/Admin.php +++ b/third_party/astroglobe/lib/Settings/Admin.php @@ -18,6 +18,17 @@ use OCP\Settings\ISettings; * configuration, and provides administrative controls. */ class Admin implements ISettings { + // Search settings keys and defaults + public const SETTING_SEARCH_ALGORITHM = 'search_algorithm'; + public const SETTING_SEARCH_FUSION = 'search_fusion'; + public const SETTING_SEARCH_SCORE_THRESHOLD = 'search_score_threshold'; + public const SETTING_SEARCH_LIMIT = 'search_limit'; + + public const DEFAULT_SEARCH_ALGORITHM = 'hybrid'; + public const DEFAULT_SEARCH_FUSION = 'rrf'; + public const DEFAULT_SEARCH_SCORE_THRESHOLD = 0; + public const DEFAULT_SEARCH_LIMIT = 20; + private $client; private $config; private $initialState; @@ -59,6 +70,30 @@ class Admin implements ISettings { ); } + // Load search settings from app config + $searchSettings = [ + 'algorithm' => $this->config->getAppValue( + Application::APP_ID, + self::SETTING_SEARCH_ALGORITHM, + self::DEFAULT_SEARCH_ALGORITHM + ), + 'fusion' => $this->config->getAppValue( + Application::APP_ID, + self::SETTING_SEARCH_FUSION, + self::DEFAULT_SEARCH_FUSION + ), + 'scoreThreshold' => (int)$this->config->getAppValue( + Application::APP_ID, + self::SETTING_SEARCH_SCORE_THRESHOLD, + (string)self::DEFAULT_SEARCH_SCORE_THRESHOLD + ), + 'limit' => (int)$this->config->getAppValue( + Application::APP_ID, + self::SETTING_SEARCH_LIMIT, + (string)self::DEFAULT_SEARCH_LIMIT + ), + ]; + // Provide initial state for Vue.js frontend (if needed) $this->initialState->provideInitialState('server-data', [ 'serverStatus' => $serverStatus, @@ -67,6 +102,7 @@ class Admin implements ISettings { 'serverUrl' => $serverUrl, 'apiKeyConfigured' => $apiKeyConfigured, ], + 'searchSettings' => $searchSettings, ]); $parameters = [ @@ -75,6 +111,7 @@ class Admin implements ISettings { 'serverUrl' => $serverUrl, 'apiKeyConfigured' => $apiKeyConfigured, 'vectorSyncEnabled' => $serverStatus['vector_sync_enabled'] ?? false, + 'searchSettings' => $searchSettings, ]; return new TemplateResponse( diff --git a/third_party/astroglobe/lib/Settings/AdminSection.php b/third_party/astroglobe/lib/Settings/AdminSection.php index d5ca194..f470cc4 100644 --- a/third_party/astroglobe/lib/Settings/AdminSection.php +++ b/third_party/astroglobe/lib/Settings/AdminSection.php @@ -47,6 +47,6 @@ class AdminSection implements IIconSection { * @return string Section icon (SVG or image URL) */ public function getIcon(): string { - return $this->urlGenerator->imagePath('astroglobe', 'app.svg'); + return $this->urlGenerator->imagePath('astroglobe', 'app-dark.svg'); } } diff --git a/third_party/astroglobe/lib/Settings/PersonalSection.php b/third_party/astroglobe/lib/Settings/PersonalSection.php index 23fa21c..cf9a6c6 100644 --- a/third_party/astroglobe/lib/Settings/PersonalSection.php +++ b/third_party/astroglobe/lib/Settings/PersonalSection.php @@ -47,6 +47,6 @@ class PersonalSection implements IIconSection { * @return string Section icon (SVG or image URL) */ public function getIcon(): string { - return $this->urlGenerator->imagePath('astroglobe', 'app.svg'); + return $this->urlGenerator->imagePath('astroglobe', 'app-dark.svg'); } } diff --git a/third_party/astroglobe/src/App.vue b/third_party/astroglobe/src/App.vue index 9251df4..1a475d7 100644 --- a/third_party/astroglobe/src/App.vue +++ b/third_party/astroglobe/src/App.vue @@ -109,6 +109,17 @@ :min="1" :max="100" /> + +
+ + +
@@ -127,7 +138,12 @@
- {{ results.length }} {{ t('astroglobe', 'results found') }} + + {{ filteredResults.length }} {{ t('astroglobe', 'results') }} + + ({{ results.length - filteredResults.length }} {{ t('astroglobe', 'filtered by score') }}) + + {{ algorithmUsed }}
@@ -149,21 +165,43 @@
{{ result.doc_type || 'unknown' }} - {{ formatScore(result.score) }}% +
+ + + + {{ formatScore(result.score) }}% +
- + {{ result.title || t('astroglobe', 'Untitled') }} + -
- {{ result.title || t('astroglobe', 'Untitled') }} +
+ {{ result.excerpt }} +
+
+ {{ truncateExcerpt(result.excerpt) }}
-
{{ result.excerpt }}
@@ -261,6 +299,9 @@ import Cog from 'vue-material-design-icons/Cog.vue' import ChevronDown from 'vue-material-design-icons/ChevronDown.vue' import ChevronUp from 'vue-material-design-icons/ChevronUp.vue' import Refresh from 'vue-material-design-icons/Refresh.vue' +import TextBoxOutline from 'vue-material-design-icons/TextBoxOutline.vue' +import TextBoxRemoveOutline from 'vue-material-design-icons/TextBoxRemoveOutline.vue' +import OpenInNew from 'vue-material-design-icons/OpenInNew.vue' import axios from '@nextcloud/axios' import { generateUrl } from '@nextcloud/router' @@ -289,6 +330,9 @@ export default { ChevronDown, ChevronUp, Refresh, + TextBoxOutline, + TextBoxRemoveOutline, + OpenInNew, }, data() { return { @@ -299,11 +343,13 @@ export default { showAdvanced: false, selectedDocTypes: [], limit: '20', + scoreThreshold: 0, loading: false, error: null, results: [], algorithmUsed: '', searched: false, + expandedExcerpts: {}, // Visualization state coordinates: [], queryCoords: [], @@ -335,6 +381,10 @@ export default { selectedAlgorithmOption() { return this.algorithmOptions.find(opt => opt.id === this.algorithm) || this.algorithmOptions[0] }, + filteredResults() { + const threshold = this.scoreThreshold / 100 + return this.results.filter(r => (r.score || 0) >= threshold) + }, }, methods: { async performSearch() { @@ -348,6 +398,7 @@ export default { this.searched = true this.coordinates = [] this.queryCoords = [] + this.expandedExcerpts = {} try { const url = generateUrl('/apps/astroglobe/api/search') @@ -414,6 +465,51 @@ export default { return Math.round((score || 0) * 100) }, + toggleExcerpt(index) { + this.$set(this.expandedExcerpts, index, !this.expandedExcerpts[index]) + }, + + truncateExcerpt(text, maxLength = 150) { + if (!text || text.length <= maxLength) return text + return text.substring(0, maxLength).trim() + '...' + }, + + getDocumentUrl(result) { + const docType = result.doc_type || 'unknown' + const id = result.id || result.note_id + + switch (docType) { + case 'note': + return generateUrl(`/apps/notes/#/note/${id}`) + case 'file': + if (result.path) { + const dir = result.path.substring(0, result.path.lastIndexOf('/')) || '/' + const file = result.path.substring(result.path.lastIndexOf('/') + 1) + return generateUrl(`/apps/files/?dir=${encodeURIComponent(dir)}&scrollto=${encodeURIComponent(file)}`) + } + return generateUrl('/apps/files/') + case 'deck_card': + if (result.board_id && result.card_id) { + return generateUrl(`/apps/deck/#!/board/${result.board_id}/card/${result.card_id}`) + } + return generateUrl('/apps/deck/') + case 'calendar': + case 'calendar_event': + return generateUrl('/apps/calendar/') + case 'news_item': + return generateUrl('/apps/news/') + case 'contact': + return generateUrl('/apps/contacts/') + default: + return generateUrl('/apps/astroglobe/') + } + }, + + navigateToDocument(result) { + const url = this.getDocumentUrl(result) + window.open(url, '_blank') + }, + goToSettings() { window.location.href = generateUrl('/settings/user/astroglobe') }, @@ -691,6 +787,12 @@ export default { background: var(--color-background-dark); } +.mcp-filter-info { + font-size: 12px; + color: var(--color-text-lighter); + font-weight: normal; +} + .mcp-results-list { display: flex; flex-direction: column; @@ -785,6 +887,39 @@ a.mcp-result-title { -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; + + &--expanded { + display: block; + -webkit-line-clamp: unset; + background: var(--color-background-dark); + padding: 12px; + border-radius: var(--border-radius); + margin-top: 8px; + white-space: pre-wrap; + word-break: break-word; + } +} + +.mcp-result-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.mcp-external-icon { + opacity: 0.5; + margin-left: 4px; + vertical-align: middle; +} + +.mcp-result-title:hover .mcp-external-icon { + opacity: 1; +} + +.mcp-score-slider { + width: 100%; + margin-top: 8px; + accent-color: var(--color-primary-element); } // Status section diff --git a/third_party/astroglobe/templates/settings/admin.php b/third_party/astroglobe/templates/settings/admin.php index 0c1dcdd..07b141a 100644 --- a/third_party/astroglobe/templates/settings/admin.php +++ b/third_party/astroglobe/templates/settings/admin.php @@ -173,6 +173,92 @@ style('astroglobe', 'astroglobe-settings');
+ + +
+

t('AI Search Provider Settings')); ?>

+

+ t('Configure the default search parameters for the AI Search provider in Nextcloud unified search.')); ?> +

+ +
+
+ + +

+ t('Hybrid combines semantic understanding with keyword matching. Semantic finds conceptually similar content. BM25 matches exact keywords.')); ?> +

+
+ +
+ + +

+ t('Only applies to hybrid search. RRF balances results well for most queries. DBSF may work better when keyword matches are over/under-weighted.')); ?> +

+
+ +
+ + +

+ t('Filter out results below this relevance score. Set to 0 to show all results.')); ?> +

+
+ +
+ + +

+ t('Maximum number of results to return per search query (5-100).')); ?> +

+
+ +
+ + +
+
+
+ + +

t('Capabilities')); ?>