feat(astrolabe): add admin search settings and enhanced UI
Add comprehensive admin controls for the unified search provider and enhance the frontend UI with filtering and visualization improvements. **Admin Settings:** - Configure default search algorithm (hybrid, semantic, bm25) - Set fusion method for hybrid search (rrf, dbsf) - Adjust minimum score threshold (0-100%) - Set result limit (1-100 results) **Frontend Enhancements:** - Add score-based result filtering with slider control - Add expandable excerpts for search results - Improve result visualization and formatting - Add algorithm badge to show search method used **API Changes:** - Add `/api/admin/search-settings` POST endpoint - Add `searchForUnifiedSearch()` method to McpServerClient - Load and apply admin settings in SemanticSearchProvider **Technical Details:** - Settings stored in app config table - Defaults: hybrid algorithm, rrf fusion, 0% threshold, 20 results - SemanticSearchProvider respects admin-configured limits 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.5
parent
24e63a967a
commit
4cce4f6392
+7
@@ -45,5 +45,12 @@ return [
|
|||||||
'url' => '/api/vector-status',
|
'url' => '/api/vector-status',
|
||||||
'verb' => 'GET',
|
'verb' => 'GET',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// Admin settings routes
|
||||||
|
[
|
||||||
|
'name' => 'api#saveSearchSettings',
|
||||||
|
'url' => '/api/admin/search-settings',
|
||||||
|
'verb' => 'POST',
|
||||||
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
+85
-1
@@ -6,12 +6,14 @@ namespace OCA\Astroglobe\Controller;
|
|||||||
|
|
||||||
use OCA\Astroglobe\Service\McpServerClient;
|
use OCA\Astroglobe\Service\McpServerClient;
|
||||||
use OCA\Astroglobe\Service\McpTokenStorage;
|
use OCA\Astroglobe\Service\McpTokenStorage;
|
||||||
|
use OCA\Astroglobe\Settings\Admin as AdminSettings;
|
||||||
use OCP\AppFramework\Controller;
|
use OCP\AppFramework\Controller;
|
||||||
use OCP\AppFramework\Http;
|
use OCP\AppFramework\Http;
|
||||||
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
||||||
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
|
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
|
||||||
use OCP\AppFramework\Http\JSONResponse;
|
use OCP\AppFramework\Http\JSONResponse;
|
||||||
use OCP\AppFramework\Http\RedirectResponse;
|
use OCP\AppFramework\Http\RedirectResponse;
|
||||||
|
use OCP\IConfig;
|
||||||
use OCP\IRequest;
|
use OCP\IRequest;
|
||||||
use OCP\IURLGenerator;
|
use OCP\IURLGenerator;
|
||||||
use OCP\IUserSession;
|
use OCP\IUserSession;
|
||||||
@@ -28,6 +30,7 @@ class ApiController extends Controller {
|
|||||||
private $urlGenerator;
|
private $urlGenerator;
|
||||||
private $logger;
|
private $logger;
|
||||||
private $tokenStorage;
|
private $tokenStorage;
|
||||||
|
private $config;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
string $appName,
|
string $appName,
|
||||||
@@ -36,7 +39,8 @@ class ApiController extends Controller {
|
|||||||
IUserSession $userSession,
|
IUserSession $userSession,
|
||||||
IURLGenerator $urlGenerator,
|
IURLGenerator $urlGenerator,
|
||||||
LoggerInterface $logger,
|
LoggerInterface $logger,
|
||||||
McpTokenStorage $tokenStorage
|
McpTokenStorage $tokenStorage,
|
||||||
|
IConfig $config
|
||||||
) {
|
) {
|
||||||
parent::__construct($appName, $request);
|
parent::__construct($appName, $request);
|
||||||
$this->client = $client;
|
$this->client = $client;
|
||||||
@@ -44,6 +48,7 @@ class ApiController extends Controller {
|
|||||||
$this->urlGenerator = $urlGenerator;
|
$this->urlGenerator = $urlGenerator;
|
||||||
$this->logger = $logger;
|
$this->logger = $logger;
|
||||||
$this->tokenStorage = $tokenStorage;
|
$this->tokenStorage = $tokenStorage;
|
||||||
|
$this->config = $config;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -223,4 +228,83 @@ class ApiController extends Controller {
|
|||||||
'status' => $status
|
'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,
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -367,6 +367,10 @@ class OAuthController extends Controller {
|
|||||||
'astroglobe.oauth.oauthCallback'
|
'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
|
// Build authorization URL with PKCE
|
||||||
$params = [
|
$params = [
|
||||||
'client_id' => 'nextcloudMcpServerUIPublicClient', // Public client ID (32+ chars required by NC OIDC)
|
'client_id' => 'nextcloudMcpServerUIPublicClient', // Public client ID (32+ chars required by NC OIDC)
|
||||||
@@ -376,6 +380,7 @@ class OAuthController extends Controller {
|
|||||||
'state' => $state,
|
'state' => $state,
|
||||||
'code_challenge' => $codeChallenge,
|
'code_challenge' => $codeChallenge,
|
||||||
'code_challenge_method' => 'S256',
|
'code_challenge_method' => 'S256',
|
||||||
|
'resource' => $mcpServerPublicUrl, // RFC 8707 Resource Indicator - request token with MCP server audience
|
||||||
];
|
];
|
||||||
|
|
||||||
return $authEndpoint . '?' . http_build_query($params);
|
return $authEndpoint . '?' . http_build_query($params);
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ class SemanticSearchProvider implements IProvider {
|
|||||||
* Display name shown in search results grouping.
|
* Display name shown in search results grouping.
|
||||||
*/
|
*/
|
||||||
public function getName(): string {
|
public function getName(): string {
|
||||||
return $this->l10n->t('Astroglob');
|
return $this->l10n->t('Astroglobe');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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<array{
|
||||||
|
* id?: string|int,
|
||||||
|
* title?: string,
|
||||||
|
* doc_type?: string,
|
||||||
|
* excerpt?: string,
|
||||||
|
* score?: float,
|
||||||
|
* path?: string,
|
||||||
|
* board_id?: int,
|
||||||
|
* card_id?: int
|
||||||
|
* }>,
|
||||||
|
* 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.
|
* Check if the MCP server is reachable and API key is valid.
|
||||||
*
|
*
|
||||||
|
|||||||
+37
@@ -18,6 +18,17 @@ use OCP\Settings\ISettings;
|
|||||||
* configuration, and provides administrative controls.
|
* configuration, and provides administrative controls.
|
||||||
*/
|
*/
|
||||||
class Admin implements ISettings {
|
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 $client;
|
||||||
private $config;
|
private $config;
|
||||||
private $initialState;
|
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)
|
// Provide initial state for Vue.js frontend (if needed)
|
||||||
$this->initialState->provideInitialState('server-data', [
|
$this->initialState->provideInitialState('server-data', [
|
||||||
'serverStatus' => $serverStatus,
|
'serverStatus' => $serverStatus,
|
||||||
@@ -67,6 +102,7 @@ class Admin implements ISettings {
|
|||||||
'serverUrl' => $serverUrl,
|
'serverUrl' => $serverUrl,
|
||||||
'apiKeyConfigured' => $apiKeyConfigured,
|
'apiKeyConfigured' => $apiKeyConfigured,
|
||||||
],
|
],
|
||||||
|
'searchSettings' => $searchSettings,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$parameters = [
|
$parameters = [
|
||||||
@@ -75,6 +111,7 @@ class Admin implements ISettings {
|
|||||||
'serverUrl' => $serverUrl,
|
'serverUrl' => $serverUrl,
|
||||||
'apiKeyConfigured' => $apiKeyConfigured,
|
'apiKeyConfigured' => $apiKeyConfigured,
|
||||||
'vectorSyncEnabled' => $serverStatus['vector_sync_enabled'] ?? false,
|
'vectorSyncEnabled' => $serverStatus['vector_sync_enabled'] ?? false,
|
||||||
|
'searchSettings' => $searchSettings,
|
||||||
];
|
];
|
||||||
|
|
||||||
return new TemplateResponse(
|
return new TemplateResponse(
|
||||||
|
|||||||
+1
-1
@@ -47,6 +47,6 @@ class AdminSection implements IIconSection {
|
|||||||
* @return string Section icon (SVG or image URL)
|
* @return string Section icon (SVG or image URL)
|
||||||
*/
|
*/
|
||||||
public function getIcon(): string {
|
public function getIcon(): string {
|
||||||
return $this->urlGenerator->imagePath('astroglobe', 'app.svg');
|
return $this->urlGenerator->imagePath('astroglobe', 'app-dark.svg');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -47,6 +47,6 @@ class PersonalSection implements IIconSection {
|
|||||||
* @return string Section icon (SVG or image URL)
|
* @return string Section icon (SVG or image URL)
|
||||||
*/
|
*/
|
||||||
public function getIcon(): string {
|
public function getIcon(): string {
|
||||||
return $this->urlGenerator->imagePath('astroglobe', 'app.svg');
|
return $this->urlGenerator->imagePath('astroglobe', 'app-dark.svg');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+143
-8
@@ -109,6 +109,17 @@
|
|||||||
:min="1"
|
:min="1"
|
||||||
:max="100" />
|
:max="100" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mcp-option-group">
|
||||||
|
<label>{{ t('astroglobe', 'Minimum Score') }}: {{ scoreThreshold }}%</label>
|
||||||
|
<input
|
||||||
|
v-model="scoreThreshold"
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="5"
|
||||||
|
class="mcp-score-slider" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -127,7 +138,12 @@
|
|||||||
<!-- Results -->
|
<!-- Results -->
|
||||||
<div v-if="results.length > 0 && !loading" class="mcp-results">
|
<div v-if="results.length > 0 && !loading" class="mcp-results">
|
||||||
<div class="mcp-results-header">
|
<div class="mcp-results-header">
|
||||||
<span>{{ results.length }} {{ t('astroglobe', 'results found') }}</span>
|
<span>
|
||||||
|
{{ filteredResults.length }} {{ t('astroglobe', 'results') }}
|
||||||
|
<span v-if="filteredResults.length !== results.length" class="mcp-filter-info">
|
||||||
|
({{ results.length - filteredResults.length }} {{ t('astroglobe', 'filtered by score') }})
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
<span class="mcp-algorithm-badge">{{ algorithmUsed }}</span>
|
<span class="mcp-algorithm-badge">{{ algorithmUsed }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -149,21 +165,43 @@
|
|||||||
|
|
||||||
<div class="mcp-results-list">
|
<div class="mcp-results-list">
|
||||||
<div
|
<div
|
||||||
v-for="result in results"
|
v-for="(result, index) in filteredResults"
|
||||||
:key="result.id"
|
:key="result.id || index"
|
||||||
class="mcp-result-item"
|
class="mcp-result-item"
|
||||||
:class="'mcp-doc-type-' + (result.doc_type || 'unknown')">
|
:class="'mcp-doc-type-' + (result.doc_type || 'unknown')">
|
||||||
<div class="mcp-result-header">
|
<div class="mcp-result-header">
|
||||||
<span class="mcp-result-type">{{ result.doc_type || 'unknown' }}</span>
|
<span class="mcp-result-type">{{ result.doc_type || 'unknown' }}</span>
|
||||||
<span class="mcp-result-score">{{ formatScore(result.score) }}%</span>
|
<div class="mcp-result-actions">
|
||||||
|
<NcButton
|
||||||
|
v-if="result.excerpt"
|
||||||
|
type="tertiary"
|
||||||
|
:aria-label="t('astroglobe', 'Toggle excerpt')"
|
||||||
|
@click="toggleExcerpt(index)">
|
||||||
|
<template #icon>
|
||||||
|
<TextBoxOutline v-if="!expandedExcerpts[index]" :size="18" />
|
||||||
|
<TextBoxRemoveOutline v-else :size="18" />
|
||||||
|
</template>
|
||||||
|
</NcButton>
|
||||||
|
<span class="mcp-result-score">{{ formatScore(result.score) }}%</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a v-if="result.link" :href="result.link" target="_blank" class="mcp-result-title">
|
<a
|
||||||
|
:href="getDocumentUrl(result)"
|
||||||
|
class="mcp-result-title"
|
||||||
|
@click.prevent="navigateToDocument(result)">
|
||||||
{{ result.title || t('astroglobe', 'Untitled') }}
|
{{ result.title || t('astroglobe', 'Untitled') }}
|
||||||
|
<OpenInNew :size="14" class="mcp-external-icon" />
|
||||||
</a>
|
</a>
|
||||||
<div v-else class="mcp-result-title">
|
<div
|
||||||
{{ result.title || t('astroglobe', 'Untitled') }}
|
v-if="result.excerpt && expandedExcerpts[index]"
|
||||||
|
class="mcp-result-excerpt mcp-result-excerpt--expanded">
|
||||||
|
{{ result.excerpt }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="result.excerpt"
|
||||||
|
class="mcp-result-excerpt">
|
||||||
|
{{ truncateExcerpt(result.excerpt) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="mcp-result-excerpt">{{ result.excerpt }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -261,6 +299,9 @@ import Cog from 'vue-material-design-icons/Cog.vue'
|
|||||||
import ChevronDown from 'vue-material-design-icons/ChevronDown.vue'
|
import ChevronDown from 'vue-material-design-icons/ChevronDown.vue'
|
||||||
import ChevronUp from 'vue-material-design-icons/ChevronUp.vue'
|
import ChevronUp from 'vue-material-design-icons/ChevronUp.vue'
|
||||||
import Refresh from 'vue-material-design-icons/Refresh.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 axios from '@nextcloud/axios'
|
||||||
import { generateUrl } from '@nextcloud/router'
|
import { generateUrl } from '@nextcloud/router'
|
||||||
@@ -289,6 +330,9 @@ export default {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
Refresh,
|
Refresh,
|
||||||
|
TextBoxOutline,
|
||||||
|
TextBoxRemoveOutline,
|
||||||
|
OpenInNew,
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -299,11 +343,13 @@ export default {
|
|||||||
showAdvanced: false,
|
showAdvanced: false,
|
||||||
selectedDocTypes: [],
|
selectedDocTypes: [],
|
||||||
limit: '20',
|
limit: '20',
|
||||||
|
scoreThreshold: 0,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
results: [],
|
results: [],
|
||||||
algorithmUsed: '',
|
algorithmUsed: '',
|
||||||
searched: false,
|
searched: false,
|
||||||
|
expandedExcerpts: {},
|
||||||
// Visualization state
|
// Visualization state
|
||||||
coordinates: [],
|
coordinates: [],
|
||||||
queryCoords: [],
|
queryCoords: [],
|
||||||
@@ -335,6 +381,10 @@ export default {
|
|||||||
selectedAlgorithmOption() {
|
selectedAlgorithmOption() {
|
||||||
return this.algorithmOptions.find(opt => opt.id === this.algorithm) || this.algorithmOptions[0]
|
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: {
|
methods: {
|
||||||
async performSearch() {
|
async performSearch() {
|
||||||
@@ -348,6 +398,7 @@ export default {
|
|||||||
this.searched = true
|
this.searched = true
|
||||||
this.coordinates = []
|
this.coordinates = []
|
||||||
this.queryCoords = []
|
this.queryCoords = []
|
||||||
|
this.expandedExcerpts = {}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = generateUrl('/apps/astroglobe/api/search')
|
const url = generateUrl('/apps/astroglobe/api/search')
|
||||||
@@ -414,6 +465,51 @@ export default {
|
|||||||
return Math.round((score || 0) * 100)
|
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() {
|
goToSettings() {
|
||||||
window.location.href = generateUrl('/settings/user/astroglobe')
|
window.location.href = generateUrl('/settings/user/astroglobe')
|
||||||
},
|
},
|
||||||
@@ -691,6 +787,12 @@ export default {
|
|||||||
background: var(--color-background-dark);
|
background: var(--color-background-dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mcp-filter-info {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-lighter);
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
.mcp-results-list {
|
.mcp-results-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -785,6 +887,39 @@ a.mcp-result-title {
|
|||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
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
|
// Status section
|
||||||
|
|||||||
@@ -173,6 +173,92 @@ style('astroglobe', 'astroglobe-settings');
|
|||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<!-- Search Settings -->
|
||||||
|
<?php if ($_['vectorSyncEnabled']): ?>
|
||||||
|
<div class="mcp-status-card" id="search-settings">
|
||||||
|
<h3><?php p($l->t('AI Search Provider Settings')); ?></h3>
|
||||||
|
<p class="mcp-settings-description">
|
||||||
|
<?php p($l->t('Configure the default search parameters for the AI Search provider in Nextcloud unified search.')); ?>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form id="astroglobe-search-settings-form" class="mcp-settings-form">
|
||||||
|
<div class="mcp-form-group">
|
||||||
|
<label for="search-algorithm"><?php p($l->t('Search Algorithm')); ?></label>
|
||||||
|
<select id="search-algorithm" name="algorithm" class="mcp-select">
|
||||||
|
<option value="hybrid" <?php if ($_['searchSettings']['algorithm'] === 'hybrid') echo 'selected'; ?>>
|
||||||
|
<?php p($l->t('Hybrid (Recommended)')); ?>
|
||||||
|
</option>
|
||||||
|
<option value="semantic" <?php if ($_['searchSettings']['algorithm'] === 'semantic') echo 'selected'; ?>>
|
||||||
|
<?php p($l->t('Semantic Only')); ?>
|
||||||
|
</option>
|
||||||
|
<option value="bm25" <?php if ($_['searchSettings']['algorithm'] === 'bm25') echo 'selected'; ?>>
|
||||||
|
<?php p($l->t('Keyword (BM25) Only')); ?>
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<p class="mcp-help-text">
|
||||||
|
<?php p($l->t('Hybrid combines semantic understanding with keyword matching. Semantic finds conceptually similar content. BM25 matches exact keywords.')); ?>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mcp-form-group">
|
||||||
|
<label for="search-fusion"><?php p($l->t('Fusion Method')); ?></label>
|
||||||
|
<select id="search-fusion" name="fusion" class="mcp-select">
|
||||||
|
<option value="rrf" <?php if ($_['searchSettings']['fusion'] === 'rrf') echo 'selected'; ?>>
|
||||||
|
<?php p($l->t('RRF - Reciprocal Rank Fusion (Recommended)')); ?>
|
||||||
|
</option>
|
||||||
|
<option value="dbsf" <?php if ($_['searchSettings']['fusion'] === 'dbsf') echo 'selected'; ?>>
|
||||||
|
<?php p($l->t('DBSF - Distribution-Based Score Fusion')); ?>
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<p class="mcp-help-text">
|
||||||
|
<?php p($l->t('Only applies to hybrid search. RRF balances results well for most queries. DBSF may work better when keyword matches are over/under-weighted.')); ?>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mcp-form-group">
|
||||||
|
<label for="search-score-threshold">
|
||||||
|
<?php p($l->t('Minimum Score Threshold')); ?>:
|
||||||
|
<span id="score-threshold-value"><?php p($_['searchSettings']['scoreThreshold']); ?>%</span>
|
||||||
|
</label>
|
||||||
|
<input type="range"
|
||||||
|
id="search-score-threshold"
|
||||||
|
name="scoreThreshold"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="5"
|
||||||
|
value="<?php p($_['searchSettings']['scoreThreshold']); ?>"
|
||||||
|
class="mcp-range" />
|
||||||
|
<p class="mcp-help-text">
|
||||||
|
<?php p($l->t('Filter out results below this relevance score. Set to 0 to show all results.')); ?>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mcp-form-group">
|
||||||
|
<label for="search-limit"><?php p($l->t('Maximum Results')); ?></label>
|
||||||
|
<input type="number"
|
||||||
|
id="search-limit"
|
||||||
|
name="limit"
|
||||||
|
min="5"
|
||||||
|
max="100"
|
||||||
|
step="5"
|
||||||
|
value="<?php p($_['searchSettings']['limit']); ?>"
|
||||||
|
class="mcp-input" />
|
||||||
|
<p class="mcp-help-text">
|
||||||
|
<?php p($l->t('Maximum number of results to return per search query (5-100).')); ?>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mcp-form-actions">
|
||||||
|
<button type="submit" class="primary">
|
||||||
|
<?php p($l->t('Save Settings')); ?>
|
||||||
|
</button>
|
||||||
|
<span id="search-settings-status" class="mcp-status-message"></span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<!-- Capabilities -->
|
<!-- Capabilities -->
|
||||||
<div class="mcp-status-card">
|
<div class="mcp-status-card">
|
||||||
<h3><?php p($l->t('Capabilities')); ?></h3>
|
<h3><?php p($l->t('Capabilities')); ?></h3>
|
||||||
|
|||||||
Reference in New Issue
Block a user