feat: Migrate to vue 3
This commit is contained in:
@@ -99,7 +99,7 @@ Want to see another Nextcloud app supported? [Open an issue](https://github.com/
|
||||
|
||||
### Authentication Modes
|
||||
|
||||
The server supports two authentication modes:
|
||||
The server supports three authentication modes:
|
||||
|
||||
**Single-User Mode (BasicAuth):**
|
||||
- One set of credentials shared by all MCP clients
|
||||
@@ -113,6 +113,12 @@ The server supports two authentication modes:
|
||||
- More secure: tokens expire, credentials never shared with server
|
||||
- Best for: Teams, multi-user deployments, production environments with multiple users
|
||||
|
||||
**Hybrid Mode (Multi-User BasicAuth + OAuth):**
|
||||
- MCP clients use BasicAuth (simple, stateless)
|
||||
- Admin operations use OAuth (webhooks, background sync)
|
||||
- Best for: Nextcloud deployments with admin-managed webhooks and semantic search
|
||||
- Requires: `ENABLE_MULTI_USER_BASIC_AUTH=true` + `ENABLE_OFFLINE_ACCESS=true`
|
||||
|
||||
See [docs/authentication.md](docs/authentication.md) for detailed setup instructions.
|
||||
|
||||
## Semantic Search
|
||||
|
||||
@@ -140,6 +140,93 @@ Basic Authentication uses username and password credentials directly.
|
||||
- [Configuration](configuration.md#basic-authentication-legacy) - BasicAuth environment variables
|
||||
- [Running the Server](running.md#basicauth-mode-legacy) - BasicAuth examples
|
||||
|
||||
## Hybrid Authentication (Multi-User BasicAuth + OAuth)
|
||||
|
||||
When running in multi-user BasicAuth mode with `ENABLE_OFFLINE_ACCESS=true`, the server operates in **hybrid authentication mode**. This provides the simplicity of BasicAuth for normal operations with the security of OAuth for administrative functions.
|
||||
|
||||
### Authentication Domains
|
||||
|
||||
**MCP Operations** (Tools, Resources):
|
||||
- **Auth Method**: BasicAuth (HTTP Basic username/password)
|
||||
- **Characteristics**:
|
||||
- Stateless - no token storage
|
||||
- Simple configuration
|
||||
- Direct credential validation against Nextcloud
|
||||
- Credentials passed per-request in Authorization header
|
||||
- **Used For**: MCP tool calls from Claude, MCP client operations
|
||||
|
||||
**Management APIs** (Webhooks, Admin UI):
|
||||
- **Auth Method**: OAuth bearer tokens
|
||||
- **Characteristics**:
|
||||
- Per-user authorization via OAuth consent flow
|
||||
- Refresh tokens stored for background operations
|
||||
- Token validation via UnifiedTokenVerifier
|
||||
- Explicit user consent required
|
||||
- **Used For**: Astrolabe admin UI, webhook management, vector sync operations
|
||||
|
||||
### Configuration
|
||||
|
||||
```env
|
||||
# Enable multi-user BasicAuth
|
||||
ENABLE_MULTI_USER_BASIC_AUTH=true
|
||||
|
||||
# Enable hybrid mode (OAuth provisioning for management APIs)
|
||||
ENABLE_OFFLINE_ACCESS=true
|
||||
|
||||
# Enable background sync (required for hybrid mode currently)
|
||||
VECTOR_SYNC_ENABLED=true
|
||||
|
||||
# Encryption key for refresh token storage
|
||||
TOKEN_ENCRYPTION_KEY=<base64-encoded-key>
|
||||
|
||||
# Nextcloud connection
|
||||
NEXTCLOUD_HOST=https://cloud.example.com
|
||||
|
||||
# OAuth credentials (optional - uses DCR if not set)
|
||||
NEXTCLOUD_OIDC_CLIENT_ID=<client-id>
|
||||
NEXTCLOUD_OIDC_CLIENT_SECRET=<client-secret>
|
||||
```
|
||||
|
||||
### OAuth Provisioning Flow
|
||||
|
||||
1. Admin opens Astrolabe admin settings in Nextcloud
|
||||
2. Clicks "Authorize" to enable webhook management
|
||||
3. Redirected to `/oauth/authorize-nextcloud` on MCP server
|
||||
4. MCP server redirects to Nextcloud OAuth consent page
|
||||
5. Admin grants OAuth consent (scopes: `openid`, `profile`, `offline_access`)
|
||||
6. Redirected back to `/oauth/callback` on MCP server
|
||||
7. MCP server stores refresh token (encrypted)
|
||||
8. Admin can now manage webhooks from Astrolabe UI
|
||||
|
||||
### Benefits
|
||||
|
||||
- **Simple MCP client setup**: Use BasicAuth (no OAuth complexity for end users)
|
||||
- **Secure background operations**: Webhooks use per-user OAuth tokens (no shared credentials)
|
||||
- **Explicit authorization**: Admins must explicitly grant OAuth consent for webhook operations
|
||||
- **Per-user isolation**: Each admin's webhook operations use their own refresh token
|
||||
|
||||
### Trade-offs
|
||||
|
||||
- **Two auth systems**: More complex server configuration than pure BasicAuth or OAuth
|
||||
- **OAuth setup required**: Admins must complete OAuth flow before managing webhooks
|
||||
- **Token storage**: Requires database and encryption key for refresh tokens
|
||||
|
||||
### Comparison
|
||||
|
||||
| Feature | Pure BasicAuth | Hybrid Mode | Pure OAuth |
|
||||
|---------|---------------|-------------|------------|
|
||||
| MCP Operations | BasicAuth | BasicAuth | OAuth Bearer Token |
|
||||
| Management API | N/A | OAuth Bearer Token | OAuth Bearer Token |
|
||||
| Webhook Operations | N/A | OAuth Refresh Token | OAuth Refresh Token |
|
||||
| MCP Client Setup | Simple | Simple | Complex (PKCE flow) |
|
||||
| Admin UI Auth | N/A | OAuth Consent | OAuth Login |
|
||||
| Token Storage | None | Refresh tokens only | All tokens |
|
||||
| Deployment Complexity | Low | Medium | High |
|
||||
|
||||
### See Also
|
||||
- [OAuth Architecture](oauth-architecture.md) - Progressive Consent (Flow 2) details
|
||||
- [Configuration](configuration.md#enable_offline_access) - Hybrid mode configuration
|
||||
|
||||
## Mode Detection
|
||||
|
||||
The server automatically detects the authentication mode:
|
||||
|
||||
+250
-12
@@ -1,3 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
@@ -42,6 +44,7 @@ from nextcloud_mcp_server.auth.unified_verifier import UnifiedTokenVerifier
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.config import (
|
||||
DeploymentMode,
|
||||
Settings,
|
||||
get_document_processor_config,
|
||||
get_settings,
|
||||
)
|
||||
@@ -1012,6 +1015,160 @@ async def setup_oauth_config():
|
||||
)
|
||||
|
||||
|
||||
async def setup_oauth_config_for_multi_user_basic(
|
||||
settings: Settings,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
) -> tuple[UnifiedTokenVerifier, RefreshTokenStorage | None, str, str]:
|
||||
"""
|
||||
Setup minimal OAuth configuration for multi-user BasicAuth mode.
|
||||
|
||||
This is a lightweight version of setup_oauth_config() that:
|
||||
- Performs OIDC discovery to get endpoints
|
||||
- Creates UnifiedTokenVerifier for management API token validation
|
||||
- Creates RefreshTokenStorage for webhook token storage
|
||||
- Skips OAuth client creation (not needed for BasicAuth background sync)
|
||||
- Skips AuthSettings creation (not needed for BasicAuth MCP operations)
|
||||
|
||||
This enables hybrid authentication mode where:
|
||||
- MCP operations use BasicAuth (stateless, simple)
|
||||
- Management APIs use OAuth bearer tokens (secure, per-user)
|
||||
- Background operations use OAuth refresh tokens (webhook sync)
|
||||
|
||||
Args:
|
||||
settings: Application settings
|
||||
client_id: OAuth client ID (from DCR or static config)
|
||||
client_secret: OAuth client secret
|
||||
|
||||
Returns:
|
||||
Tuple of (token_verifier, refresh_token_storage, client_id, client_secret)
|
||||
|
||||
Raises:
|
||||
ValueError: If NEXTCLOUD_HOST is not set
|
||||
httpx.HTTPError: If OIDC discovery fails
|
||||
"""
|
||||
nextcloud_host = settings.nextcloud_host
|
||||
if not nextcloud_host:
|
||||
raise ValueError("NEXTCLOUD_HOST is required for OAuth infrastructure setup")
|
||||
|
||||
nextcloud_host = nextcloud_host.rstrip("/")
|
||||
|
||||
# Get OIDC discovery URL (always Nextcloud integrated mode for multi-user BasicAuth)
|
||||
discovery_url = os.getenv(
|
||||
"OIDC_DISCOVERY_URL",
|
||||
f"{nextcloud_host}/.well-known/openid-configuration",
|
||||
)
|
||||
logger.info(
|
||||
f"Performing OIDC discovery for multi-user BasicAuth hybrid mode: {discovery_url}"
|
||||
)
|
||||
|
||||
# Perform OIDC discovery
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
response = await http_client.get(discovery_url)
|
||||
response.raise_for_status()
|
||||
discovery = response.json()
|
||||
|
||||
logger.info("✓ OIDC discovery successful (multi-user BasicAuth)")
|
||||
|
||||
# Extract OIDC endpoints
|
||||
issuer = discovery["issuer"]
|
||||
userinfo_uri = discovery["userinfo_endpoint"]
|
||||
jwks_uri = discovery.get("jwks_uri")
|
||||
introspection_uri = discovery.get("introspection_endpoint")
|
||||
|
||||
# For multi-user BasicAuth, always assume Nextcloud integrated mode
|
||||
# and rewrite endpoints to use internal URL for backend access
|
||||
if jwks_uri:
|
||||
internal_jwks_uri = f"{nextcloud_host}/apps/oidc/jwks"
|
||||
jwks_uri = internal_jwks_uri
|
||||
if introspection_uri:
|
||||
internal_introspection_uri = f"{nextcloud_host}/apps/oidc/introspect"
|
||||
introspection_uri = internal_introspection_uri
|
||||
if userinfo_uri:
|
||||
internal_userinfo_uri = f"{nextcloud_host}/apps/oidc/userinfo"
|
||||
userinfo_uri = internal_userinfo_uri
|
||||
|
||||
logger.info("OIDC endpoints configured for management API:")
|
||||
logger.info(f" Issuer: {issuer}")
|
||||
logger.info(f" Userinfo: {userinfo_uri}")
|
||||
logger.info(f" JWKS: {jwks_uri}")
|
||||
logger.info(f" Introspection: {introspection_uri}")
|
||||
|
||||
# Get MCP server URL for audience validation
|
||||
mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000")
|
||||
nextcloud_resource_uri = os.getenv("NEXTCLOUD_RESOURCE_URI", nextcloud_host)
|
||||
|
||||
# Handle public issuer override (for JWT validation)
|
||||
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
||||
if public_issuer:
|
||||
public_issuer = public_issuer.rstrip("/")
|
||||
logger.info(
|
||||
f"Using public issuer URL override for JWT validation: {public_issuer}"
|
||||
)
|
||||
client_issuer = public_issuer
|
||||
else:
|
||||
client_issuer = issuer
|
||||
|
||||
# Update settings with discovered values for UnifiedTokenVerifier
|
||||
if not settings.oidc_client_id:
|
||||
settings.oidc_client_id = client_id
|
||||
if not settings.oidc_client_secret:
|
||||
settings.oidc_client_secret = client_secret
|
||||
if not settings.jwks_uri:
|
||||
settings.jwks_uri = jwks_uri
|
||||
if not settings.introspection_uri:
|
||||
settings.introspection_uri = introspection_uri
|
||||
if not settings.userinfo_uri:
|
||||
settings.userinfo_uri = userinfo_uri
|
||||
if not settings.oidc_issuer:
|
||||
settings.oidc_issuer = client_issuer
|
||||
if not settings.nextcloud_mcp_server_url:
|
||||
settings.nextcloud_mcp_server_url = mcp_server_url
|
||||
if not settings.nextcloud_resource_uri:
|
||||
settings.nextcloud_resource_uri = nextcloud_resource_uri
|
||||
|
||||
# Create Unified Token Verifier for management API authentication
|
||||
token_verifier = UnifiedTokenVerifier(settings)
|
||||
logger.info("✓ Token verifier created for management API (hybrid mode)")
|
||||
|
||||
if introspection_uri:
|
||||
logger.info(" Opaque token introspection enabled (RFC 7662)")
|
||||
if jwks_uri:
|
||||
logger.info(" JWT signature verification enabled (JWKS)")
|
||||
|
||||
# Initialize refresh token storage for background operations
|
||||
refresh_token_storage = None
|
||||
if settings.enable_offline_access:
|
||||
try:
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
|
||||
encryption_key = os.getenv("TOKEN_ENCRYPTION_KEY")
|
||||
if not encryption_key:
|
||||
logger.warning(
|
||||
"ENABLE_OFFLINE_ACCESS=true but TOKEN_ENCRYPTION_KEY not set. "
|
||||
"Refresh tokens will NOT be stored. Generate a key with:\n"
|
||||
' python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"'
|
||||
)
|
||||
else:
|
||||
refresh_token_storage = RefreshTokenStorage.from_env()
|
||||
await refresh_token_storage.initialize()
|
||||
logger.info(
|
||||
"✓ Refresh token storage initialized for background operations (hybrid mode)"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize refresh token storage: {e}")
|
||||
logger.debug(f"Full traceback:\n{traceback.format_exc()}")
|
||||
logger.warning(
|
||||
"Continuing without refresh token storage - webhook management may be limited"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"OAuth infrastructure setup complete for multi-user BasicAuth hybrid mode"
|
||||
)
|
||||
|
||||
return (token_verifier, refresh_token_storage, client_id, client_secret)
|
||||
|
||||
|
||||
def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = None):
|
||||
# Initialize observability (logging will be configured by uvicorn)
|
||||
settings = get_settings()
|
||||
@@ -1043,6 +1200,15 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
else DeploymentMode.SELF_HOSTED
|
||||
)
|
||||
|
||||
# Log hybrid authentication status for multi-user BasicAuth with offline access
|
||||
if mode == AuthMode.MULTI_USER_BASIC and settings.enable_offline_access:
|
||||
logger.info(
|
||||
"🔄 Hybrid authentication mode will be enabled:\n"
|
||||
" - MCP operations: BasicAuth (stateless, credentials per-request)\n"
|
||||
" - Management APIs: OAuth bearer tokens (secure, per-user)\n"
|
||||
" - Background operations: OAuth refresh tokens (webhook sync)"
|
||||
)
|
||||
|
||||
# Setup Prometheus metrics (always enabled by default)
|
||||
if settings.metrics_enabled:
|
||||
setup_metrics(port=settings.metrics_port)
|
||||
@@ -1070,6 +1236,8 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
# This must happen BEFORE uvicorn starts (same lifecycle point as OAuth modes)
|
||||
# to avoid async context issues
|
||||
multi_user_basic_oauth_creds: tuple[str, str] | None = None
|
||||
multi_user_token_verifier: UnifiedTokenVerifier | None = None
|
||||
multi_user_refresh_storage: RefreshTokenStorage | None = None
|
||||
|
||||
if (
|
||||
mode == AuthMode.MULTI_USER_BASIC
|
||||
@@ -1135,6 +1303,42 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
# Run DCR synchronously before uvicorn starts
|
||||
multi_user_basic_oauth_creds = anyio.run(setup_multi_user_basic_dcr)
|
||||
|
||||
# Setup OAuth infrastructure for management APIs and background operations
|
||||
# This creates the UnifiedTokenVerifier needed by management.py and
|
||||
# RefreshTokenStorage for webhook token persistence
|
||||
if multi_user_basic_oauth_creds:
|
||||
sync_client_id, sync_client_secret = multi_user_basic_oauth_creds
|
||||
|
||||
logger.info(
|
||||
"Setting up OAuth infrastructure for management APIs (hybrid mode)..."
|
||||
)
|
||||
|
||||
try:
|
||||
(
|
||||
multi_user_token_verifier,
|
||||
multi_user_refresh_storage,
|
||||
_,
|
||||
_,
|
||||
) = anyio.run(
|
||||
setup_oauth_config_for_multi_user_basic,
|
||||
settings,
|
||||
sync_client_id,
|
||||
sync_client_secret,
|
||||
)
|
||||
logger.info(
|
||||
"✓ OAuth infrastructure setup complete for multi-user BasicAuth hybrid mode"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to setup OAuth infrastructure: {e}")
|
||||
logger.debug(f"Full traceback:\n{traceback.format_exc()}")
|
||||
logger.warning(
|
||||
"Management API will be unavailable. "
|
||||
"Webhook management from Astrolabe admin UI will not work."
|
||||
)
|
||||
# Set to None to indicate failure
|
||||
multi_user_token_verifier = None
|
||||
multi_user_refresh_storage = None
|
||||
|
||||
# Create MCP server based on detected mode
|
||||
if mode in (AuthMode.OAUTH_SINGLE_AUDIENCE, AuthMode.OAUTH_TOKEN_EXCHANGE):
|
||||
logger.info("Configuring MCP server for OAuth mode")
|
||||
@@ -1410,11 +1614,14 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
# For multi-user BasicAuth with offline access, create oauth_context for management APIs
|
||||
# This allows Astrolabe to use management APIs with OAuth bearer tokens
|
||||
if settings.enable_multi_user_basic_auth and settings.enable_offline_access:
|
||||
# Check if we have OAuth credentials from DCR
|
||||
if multi_user_basic_oauth_creds:
|
||||
# Check if we have OAuth credentials AND infrastructure from setup
|
||||
if (
|
||||
multi_user_basic_oauth_creds
|
||||
and multi_user_token_verifier is not None
|
||||
):
|
||||
sync_client_id, sync_client_secret = multi_user_basic_oauth_creds
|
||||
|
||||
# Create minimal oauth_context for management API authentication
|
||||
# Create oauth_context for management API authentication
|
||||
nextcloud_host_for_context = settings.nextcloud_host
|
||||
mcp_server_url = os.getenv(
|
||||
"NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000"
|
||||
@@ -1425,9 +1632,10 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
)
|
||||
|
||||
oauth_context_dict = {
|
||||
"storage": basic_auth_storage,
|
||||
# Use OAuth refresh token storage if available, fallback to basic_auth_storage
|
||||
"storage": multi_user_refresh_storage or basic_auth_storage,
|
||||
"oauth_client": None, # Not needed for management APIs
|
||||
"token_verifier": None, # Will be set when token broker is created
|
||||
"token_verifier": multi_user_token_verifier, # FIXED: Now has real verifier!
|
||||
"config": {
|
||||
"mcp_server_url": mcp_server_url,
|
||||
"discovery_url": discovery_url,
|
||||
@@ -1441,7 +1649,19 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
}
|
||||
app.state.oauth_context = oauth_context_dict
|
||||
logger.info(
|
||||
f"OAuth context initialized for management APIs (multi-user BasicAuth, client_id={sync_client_id[:16]}...)"
|
||||
f"✓ OAuth context initialized for management APIs (hybrid mode, client_id={sync_client_id[:16]}...)"
|
||||
)
|
||||
elif multi_user_basic_oauth_creds and multi_user_token_verifier is None:
|
||||
logger.warning(
|
||||
"OAuth infrastructure setup failed - management API will be unavailable. "
|
||||
"This is expected if OIDC discovery failed or token verifier creation failed. "
|
||||
"Webhook management from Astrolabe admin UI will not work."
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"OAuth credentials not available - management API will be unavailable. "
|
||||
"This is expected if DCR failed or static credentials were not provided. "
|
||||
"Webhook management from Astrolabe admin UI will not work."
|
||||
)
|
||||
|
||||
# Also share with browser_app for webhook routes
|
||||
@@ -2028,7 +2248,21 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
# Note: Metrics endpoint is NOT exposed on main HTTP port for security reasons.
|
||||
# Metrics are served on dedicated port via setup_metrics() (default: 9090)
|
||||
|
||||
if oauth_enabled:
|
||||
# Determine if OAuth provisioning is available
|
||||
# This is true for:
|
||||
# 1. OAuth modes (primary auth method for MCP operations)
|
||||
# 2. Multi-user BasicAuth with offline access (hybrid mode)
|
||||
oauth_provisioning_available = oauth_enabled or (
|
||||
mode == AuthMode.MULTI_USER_BASIC
|
||||
and settings.enable_offline_access
|
||||
and multi_user_token_verifier is not None # Ensure OAuth setup succeeded
|
||||
)
|
||||
|
||||
if oauth_provisioning_available:
|
||||
logger.info(
|
||||
f"OAuth provisioning routes enabled for mode: {mode.value} "
|
||||
f"(oauth_enabled={oauth_enabled}, hybrid_mode={not oauth_enabled})"
|
||||
)
|
||||
# Import OAuth routes (ADR-004 Progressive Consent)
|
||||
from nextcloud_mcp_server.auth.oauth_routes import oauth_authorize
|
||||
|
||||
@@ -2091,10 +2325,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
"Protected Resource Metadata (PRM) endpoints enabled (path-based + root)"
|
||||
)
|
||||
|
||||
# Add OAuth login routes (ADR-004 Progressive Consent Flow 1)
|
||||
routes.append(Route("/oauth/authorize", oauth_authorize, methods=["GET"]))
|
||||
logger.info("OAuth login routes enabled: /oauth/authorize (Flow 1)")
|
||||
|
||||
# Add unified OAuth callback endpoint supporting both flows
|
||||
from nextcloud_mcp_server.auth.oauth_routes import (
|
||||
oauth_authorize_nextcloud,
|
||||
@@ -2124,9 +2354,17 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
)
|
||||
)
|
||||
logger.info(
|
||||
"OAuth resource provisioning routes enabled: /oauth/authorize-nextcloud, /oauth/callback-nextcloud (Flow 2, legacy)"
|
||||
"OAuth resource provisioning routes enabled: /oauth/authorize-nextcloud, /oauth/callback-nextcloud (Flow 2)"
|
||||
)
|
||||
|
||||
# Add OAuth Flow 1 routes (MCP client login) - ONLY for OAuth modes
|
||||
# Multi-user BasicAuth uses hybrid mode with only Flow 2 (resource provisioning)
|
||||
if oauth_enabled:
|
||||
from nextcloud_mcp_server.auth.oauth_routes import oauth_authorize
|
||||
|
||||
routes.append(Route("/oauth/authorize", oauth_authorize, methods=["GET"]))
|
||||
logger.info("OAuth login routes enabled: /oauth/authorize (Flow 1)")
|
||||
|
||||
# Add browser OAuth login routes (OAuth mode only)
|
||||
if oauth_enabled:
|
||||
from nextcloud_mcp_server.auth.browser_oauth_routes import (
|
||||
|
||||
+10
@@ -74,6 +74,16 @@ return [
|
||||
],
|
||||
|
||||
// Admin settings routes
|
||||
[
|
||||
'name' => 'api#serverStatus',
|
||||
'url' => '/api/admin/server-status',
|
||||
'verb' => 'GET',
|
||||
],
|
||||
[
|
||||
'name' => 'api#adminVectorStatus',
|
||||
'url' => '/api/admin/vector-status',
|
||||
'verb' => 'GET',
|
||||
],
|
||||
[
|
||||
'name' => 'api#saveSearchSettings',
|
||||
'url' => '/api/admin/search-settings',
|
||||
|
||||
@@ -254,6 +254,54 @@ class ApiController extends Controller {
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MCP server status.
|
||||
*
|
||||
* Admin-only endpoint for admin settings page.
|
||||
* Returns server version, uptime, and vector sync availability.
|
||||
*
|
||||
* @return JSONResponse
|
||||
*/
|
||||
public function serverStatus(): JSONResponse {
|
||||
$status = $this->client->getStatus();
|
||||
|
||||
if (isset($status['error'])) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => $status['error']
|
||||
], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
return new JSONResponse([
|
||||
'success' => true,
|
||||
'status' => $status
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get vector sync status for admin.
|
||||
*
|
||||
* Admin-only endpoint for admin settings page.
|
||||
* Returns indexing metrics and sync status.
|
||||
*
|
||||
* @return JSONResponse
|
||||
*/
|
||||
public function adminVectorStatus(): JSONResponse {
|
||||
$status = $this->client->getVectorSyncStatus();
|
||||
|
||||
if (isset($status['error'])) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => $status['error']
|
||||
], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
return new JSONResponse([
|
||||
'success' => true,
|
||||
'status' => $status
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save admin search settings.
|
||||
*
|
||||
|
||||
+7
-34
@@ -47,11 +47,7 @@ class Admin implements ISettings {
|
||||
* @return TemplateResponse
|
||||
*/
|
||||
public function getForm(): TemplateResponse {
|
||||
// Fetch data from MCP server
|
||||
$serverStatus = $this->client->getStatus();
|
||||
$vectorSyncStatus = $this->client->getVectorSyncStatus();
|
||||
|
||||
// Get configuration from config.php
|
||||
// Get configuration from config.php (local, fast)
|
||||
$serverUrl = $this->config->getSystemValue('mcp_server_url', '');
|
||||
$apiKeyConfigured = !empty($this->config->getSystemValue('mcp_server_api_key', ''));
|
||||
$clientId = $this->config->getSystemValue('astrolabe_client_id', '');
|
||||
@@ -59,21 +55,6 @@ class Admin implements ISettings {
|
||||
$clientSecret = $this->config->getSystemValue('astrolabe_client_secret', '');
|
||||
$clientSecretConfigured = !empty($clientSecret);
|
||||
|
||||
// Check for server connection error
|
||||
if (isset($serverStatus['error'])) {
|
||||
return new TemplateResponse(
|
||||
Application::APP_ID,
|
||||
'settings/error',
|
||||
[
|
||||
'error' => 'Cannot connect to MCP server',
|
||||
'details' => $serverStatus['error'],
|
||||
'server_url' => $serverUrl,
|
||||
'help_text' => 'Ensure MCP server is running and accessible. Check config.php for correct mcp_server_url.',
|
||||
],
|
||||
TemplateResponse::RENDER_AS_BLANK
|
||||
);
|
||||
}
|
||||
|
||||
// Load search settings from app config
|
||||
$searchSettings = [
|
||||
'algorithm' => $this->config->getAppValue(
|
||||
@@ -98,27 +79,19 @@ class Admin implements ISettings {
|
||||
),
|
||||
];
|
||||
|
||||
// Provide initial state for Vue.js frontend (if needed)
|
||||
$this->initialState->provideInitialState('server-data', [
|
||||
'serverStatus' => $serverStatus,
|
||||
'vectorSyncStatus' => $vectorSyncStatus,
|
||||
// Provide initial state for Vue.js frontend
|
||||
// MCP server data will be fetched asynchronously by Vue component
|
||||
$this->initialState->provideInitialState('admin-config', [
|
||||
'config' => [
|
||||
'serverUrl' => $serverUrl,
|
||||
'apiKeyConfigured' => $apiKeyConfigured,
|
||||
'clientIdConfigured' => $clientIdConfigured,
|
||||
'clientSecretConfigured' => $clientSecretConfigured,
|
||||
],
|
||||
'searchSettings' => $searchSettings,
|
||||
]);
|
||||
|
||||
$parameters = [
|
||||
'serverStatus' => $serverStatus,
|
||||
'vectorSyncStatus' => $vectorSyncStatus,
|
||||
'serverUrl' => $serverUrl,
|
||||
'apiKeyConfigured' => $apiKeyConfigured,
|
||||
'clientIdConfigured' => $clientIdConfigured,
|
||||
'clientSecretConfigured' => $clientSecretConfigured,
|
||||
'vectorSyncEnabled' => $serverStatus['vector_sync_enabled'] ?? false,
|
||||
'searchSettings' => $searchSettings,
|
||||
];
|
||||
$parameters = [];
|
||||
|
||||
return new TemplateResponse(
|
||||
Application::APP_ID,
|
||||
|
||||
+581
-1991
File diff suppressed because it is too large
Load Diff
Vendored
+3
-1
@@ -19,6 +19,8 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@nextcloud/axios": "^2.5.1",
|
||||
"@nextcloud/dialogs": "^7.2.0",
|
||||
"@nextcloud/initial-state": "^3.0.0",
|
||||
"@nextcloud/l10n": "^3.1.0",
|
||||
"@nextcloud/router": "^3.0.1",
|
||||
"@nextcloud/vue": "^9.0.0",
|
||||
@@ -32,8 +34,8 @@
|
||||
"@nextcloud/browserslist-config": "3.1.2",
|
||||
"@nextcloud/eslint-config": "8.4.2",
|
||||
"@nextcloud/stylelint-config": "3.1.1",
|
||||
"@nextcloud/vite-config": "1.7.2",
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"sass-embedded": "^1.97.1",
|
||||
"terser": "5.44.1",
|
||||
"vite": "7.2.7"
|
||||
}
|
||||
|
||||
Vendored
+7
-5
@@ -48,10 +48,11 @@
|
||||
<div class="mcp-search-card">
|
||||
<div class="mcp-search-row">
|
||||
<NcTextField
|
||||
v-model:value="query"
|
||||
:value="query"
|
||||
:label="t('astrolabe', 'Search query')"
|
||||
:placeholder="t('astrolabe', 'Enter your search query...')"
|
||||
class="mcp-search-input"
|
||||
@update:value="query = $event"
|
||||
@keyup.enter="performSearch" />
|
||||
|
||||
<NcSelect
|
||||
@@ -104,10 +105,11 @@
|
||||
<div class="mcp-option-group">
|
||||
<label>{{ t('astrolabe', 'Result Limit') }}</label>
|
||||
<NcTextField
|
||||
v-model:value="limit"
|
||||
:value="limit"
|
||||
type="number"
|
||||
:min="1"
|
||||
:max="100" />
|
||||
:max="100"
|
||||
@update:value="limit = Number($event)" />
|
||||
</div>
|
||||
|
||||
<div class="mcp-option-group">
|
||||
@@ -152,9 +154,9 @@
|
||||
<div class="mcp-viz-header">
|
||||
<h3>{{ t('astrolabe', 'Vector Space Visualization') }}</h3>
|
||||
<NcCheckboxRadioSwitch
|
||||
v-model:checked="showQueryPoint"
|
||||
:checked="showQueryPoint"
|
||||
type="switch"
|
||||
@update:checked="updatePlot">
|
||||
@update:checked="showQueryPoint = $event; updatePlot()">
|
||||
{{ t('astrolabe', 'Show query point') }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
</div>
|
||||
|
||||
+11
-238
@@ -1,245 +1,18 @@
|
||||
/**
|
||||
* Admin settings page JavaScript for Astrolabe.
|
||||
* Admin settings page Vue app for Astrolabe.
|
||||
*
|
||||
* Handles:
|
||||
* - Loading webhook presets
|
||||
* - Enabling/disabling webhook presets
|
||||
* - Search settings form submission
|
||||
* Mounts the AdminSettings Vue component for async loading
|
||||
* and improved UX.
|
||||
*/
|
||||
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import axios from '@nextcloud/axios'
|
||||
import './styles/settings.css'
|
||||
import { createApp } from 'vue'
|
||||
import { translate as t, translatePlural as n } from '@nextcloud/l10n'
|
||||
import AdminSettings from './components/admin/AdminSettings.vue'
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Initialize search settings form
|
||||
initSearchSettingsForm()
|
||||
const app = createApp(AdminSettings)
|
||||
|
||||
// Initialize webhook management (only if webhook section exists)
|
||||
if (document.getElementById('webhook-presets')) {
|
||||
initWebhookManagement()
|
||||
}
|
||||
})
|
||||
// Add translation methods globally
|
||||
app.config.globalProperties.t = t
|
||||
app.config.globalProperties.n = n
|
||||
|
||||
/**
|
||||
* Initialize search settings form handling.
|
||||
*/
|
||||
function initSearchSettingsForm() {
|
||||
const form = document.getElementById('astrolabe-search-settings-form')
|
||||
if (!form) return
|
||||
|
||||
const scoreThresholdInput = document.getElementById('search-score-threshold')
|
||||
const scoreThresholdValue = document.getElementById('score-threshold-value')
|
||||
|
||||
// Update score threshold display when slider changes
|
||||
if (scoreThresholdInput && scoreThresholdValue) {
|
||||
scoreThresholdInput.addEventListener('input', (e) => {
|
||||
scoreThresholdValue.textContent = e.target.value + '%'
|
||||
})
|
||||
}
|
||||
|
||||
// Handle form submission
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
const formData = new FormData(form)
|
||||
const data = {
|
||||
algorithm: formData.get('algorithm'),
|
||||
fusion: formData.get('fusion'),
|
||||
scoreThreshold: parseInt(formData.get('scoreThreshold')),
|
||||
limit: parseInt(formData.get('limit')),
|
||||
}
|
||||
|
||||
const statusEl = document.getElementById('search-settings-status')
|
||||
if (statusEl) {
|
||||
statusEl.textContent = 'Saving...'
|
||||
statusEl.className = 'mcp-status-message'
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
generateUrl('/apps/astrolabe/api/admin/search-settings'),
|
||||
data,
|
||||
{ headers: { 'Content-Type': 'application/json' } },
|
||||
)
|
||||
|
||||
if (response.data.success) {
|
||||
if (statusEl) {
|
||||
statusEl.textContent = '✓ Settings saved'
|
||||
statusEl.className = 'mcp-status-message success'
|
||||
setTimeout(() => {
|
||||
statusEl.textContent = ''
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save search settings:', error)
|
||||
if (statusEl) {
|
||||
statusEl.textContent = '✗ Failed to save'
|
||||
statusEl.className = 'mcp-status-message error'
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize webhook management UI.
|
||||
*/
|
||||
async function initWebhookManagement() {
|
||||
const container = document.getElementById('webhook-presets-container')
|
||||
if (!container) return
|
||||
|
||||
try {
|
||||
// Load webhook presets from API
|
||||
const response = await axios.get(
|
||||
generateUrl('/apps/astrolabe/api/admin/webhooks/presets'),
|
||||
)
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.error || 'Failed to load presets')
|
||||
}
|
||||
|
||||
const presets = response.data.presets
|
||||
renderWebhookPresets(container, presets)
|
||||
} catch (error) {
|
||||
console.error('Failed to load webhook presets:', error)
|
||||
container.innerHTML = `
|
||||
<div class="notecard notecard-error">
|
||||
<p><strong>Error loading webhook presets:</strong></p>
|
||||
<p>${error.message || 'Unknown error'}</p>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render webhook preset cards.
|
||||
*
|
||||
* @param {HTMLElement} container Container element
|
||||
* @param {object} presets Preset configurations
|
||||
*/
|
||||
function renderWebhookPresets(container, presets) {
|
||||
const presetIds = Object.keys(presets)
|
||||
|
||||
if (presetIds.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="notecard notecard-info">
|
||||
<p>No webhook presets available. Install supported apps (Notes, Calendar, Tables, Forms) to enable webhooks.</p>
|
||||
</div>
|
||||
`
|
||||
return
|
||||
}
|
||||
|
||||
// Create preset cards grid
|
||||
const grid = document.createElement('div')
|
||||
grid.className = 'mcp-preset-grid'
|
||||
|
||||
presetIds.forEach(presetId => {
|
||||
const preset = presets[presetId]
|
||||
const card = createPresetCard(presetId, preset)
|
||||
grid.appendChild(card)
|
||||
})
|
||||
|
||||
container.innerHTML = ''
|
||||
container.appendChild(grid)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a webhook preset card.
|
||||
*
|
||||
* @param {string} presetId Preset ID
|
||||
* @param {object} preset Preset configuration
|
||||
* @return {HTMLElement} Card element
|
||||
*/
|
||||
function createPresetCard(presetId, preset) {
|
||||
const card = document.createElement('div')
|
||||
card.className = 'mcp-preset-card'
|
||||
card.dataset.presetId = presetId
|
||||
|
||||
const statusClass = preset.enabled ? 'enabled' : 'disabled'
|
||||
const statusText = preset.enabled ? 'Enabled' : 'Disabled'
|
||||
const buttonText = preset.enabled ? 'Disable' : 'Enable'
|
||||
const buttonClass = preset.enabled ? 'secondary' : 'primary'
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="mcp-preset-header">
|
||||
<h4>${escapeHtml(preset.name)}</h4>
|
||||
<span class="mcp-preset-status mcp-status-${statusClass}">${statusText}</span>
|
||||
</div>
|
||||
<p class="mcp-preset-description">${escapeHtml(preset.description)}</p>
|
||||
<div class="mcp-preset-meta">
|
||||
<span class="mcp-preset-app">App: ${escapeHtml(preset.app)}</span>
|
||||
<span class="mcp-preset-events">${preset.events.length} events</span>
|
||||
</div>
|
||||
<div class="mcp-preset-actions">
|
||||
<button class="mcp-preset-toggle ${buttonClass}" data-preset-id="${presetId}">
|
||||
${buttonText}
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
|
||||
// Attach event listener to toggle button
|
||||
const toggleBtn = card.querySelector('.mcp-preset-toggle')
|
||||
toggleBtn.addEventListener('click', () => togglePreset(presetId, preset.enabled))
|
||||
|
||||
return card
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle a webhook preset (enable/disable).
|
||||
*
|
||||
* @param {string} presetId Preset ID
|
||||
* @param {boolean} currentlyEnabled Current enabled state
|
||||
*/
|
||||
async function togglePreset(presetId, currentlyEnabled) {
|
||||
const card = document.querySelector(`[data-preset-id="${presetId}"]`)
|
||||
if (!card) return
|
||||
|
||||
const toggleBtn = card.querySelector('.mcp-preset-toggle')
|
||||
const originalText = toggleBtn.textContent
|
||||
|
||||
// Disable button during request
|
||||
toggleBtn.disabled = true
|
||||
toggleBtn.textContent = currentlyEnabled ? 'Disabling...' : 'Enabling...'
|
||||
|
||||
try {
|
||||
const action = currentlyEnabled ? 'disable' : 'enable'
|
||||
const url = generateUrl(`/apps/astrolabe/api/admin/webhooks/presets/${presetId}/${action}`)
|
||||
|
||||
const response = await axios.post(url)
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.error || `Failed to ${action} preset`)
|
||||
}
|
||||
|
||||
// Reload presets to update UI
|
||||
await initWebhookManagement()
|
||||
|
||||
// Show success notification
|
||||
OC.Notification.showTemporary(response.data.message || `Preset ${action}d successfully`)
|
||||
} catch (error) {
|
||||
console.error(`Failed to toggle preset ${presetId}:`, error)
|
||||
|
||||
// Restore button state
|
||||
toggleBtn.disabled = false
|
||||
toggleBtn.textContent = originalText
|
||||
|
||||
// Show error notification
|
||||
OC.Notification.showTemporary(
|
||||
error.message || 'Failed to toggle webhook preset',
|
||||
{ type: 'error' },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS.
|
||||
*
|
||||
* @param {string} text Text to escape
|
||||
* @return {string} Escaped text
|
||||
*/
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div')
|
||||
div.textContent = text
|
||||
return div.innerHTML
|
||||
}
|
||||
app.mount('#astrolabe-admin-settings')
|
||||
|
||||
+38
-51
@@ -3,65 +3,52 @@
|
||||
<div class="markdown-viewer" v-html="html" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
|
||||
export default {
|
||||
name: 'MarkdownViewer',
|
||||
|
||||
props: {
|
||||
content: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
const props = defineProps({
|
||||
content: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
data() {
|
||||
const md = new MarkdownIt({
|
||||
html: false, // Disable HTML for security
|
||||
linkify: true,
|
||||
breaks: true,
|
||||
typographer: true,
|
||||
})
|
||||
const html = ref('')
|
||||
|
||||
return {
|
||||
html: '',
|
||||
md,
|
||||
}
|
||||
},
|
||||
// Initialize markdown renderer
|
||||
const md = new MarkdownIt({
|
||||
html: false, // Disable HTML for security
|
||||
linkify: true,
|
||||
breaks: true,
|
||||
typographer: true,
|
||||
})
|
||||
|
||||
watch: {
|
||||
content: {
|
||||
immediate: true,
|
||||
handler(newContent) {
|
||||
this.renderMarkdown(newContent)
|
||||
},
|
||||
},
|
||||
},
|
||||
function renderMarkdown(text) {
|
||||
if (!text) {
|
||||
html.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
methods: {
|
||||
renderMarkdown(text) {
|
||||
if (!text) {
|
||||
this.html = ''
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
this.html = this.md.render(text)
|
||||
} catch (error) {
|
||||
console.error('Markdown rendering error:', error)
|
||||
// Fallback to escaped plain text
|
||||
this.html = `<pre>${this.escapeHtml(text)}</pre>`
|
||||
}
|
||||
},
|
||||
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div')
|
||||
div.textContent = text
|
||||
return div.innerHTML
|
||||
},
|
||||
},
|
||||
try {
|
||||
html.value = md.render(text)
|
||||
} catch (error) {
|
||||
console.error('Markdown rendering error:', error)
|
||||
// Fallback to escaped plain text
|
||||
html.value = `<pre>${escapeHtml(text)}</pre>`
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div')
|
||||
div.textContent = text
|
||||
return div.innerHTML
|
||||
}
|
||||
|
||||
// Watch for content changes
|
||||
watch(() => props.content, (newContent) => {
|
||||
renderMarkdown(newContent)
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
+146
-145
@@ -8,165 +8,166 @@
|
||||
<AlertCircle :size="48" />
|
||||
<p>{{ error }}</p>
|
||||
</div>
|
||||
<div v-else ref="container" class="pdf-canvas-container">
|
||||
<canvas ref="canvas" />
|
||||
<div v-else ref="containerRef" class="pdf-canvas-container">
|
||||
<canvas ref="canvasRef" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import * as pdfjsLib from 'pdfjs-dist'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||
import { NcLoadingIcon } from '@nextcloud/vue'
|
||||
import AlertCircle from 'vue-material-design-icons/AlertCircle.vue'
|
||||
|
||||
export default {
|
||||
name: 'PDFViewer',
|
||||
components: {
|
||||
NcLoadingIcon,
|
||||
AlertCircle,
|
||||
const props = defineProps({
|
||||
filePath: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
props: {
|
||||
filePath: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
pageNumber: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
scale: {
|
||||
type: Number,
|
||||
default: 1.5,
|
||||
},
|
||||
pageNumber: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
pdfDoc: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
totalPages: 0,
|
||||
scale: {
|
||||
type: Number,
|
||||
default: 1.5,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['loaded', 'error', 'page-rendered'])
|
||||
|
||||
// Reactive state
|
||||
const pdfDoc = ref(null)
|
||||
const loading = ref(true)
|
||||
const error = ref(null)
|
||||
const totalPages = ref(0)
|
||||
const canvasRef = ref(null)
|
||||
const containerRef = ref(null)
|
||||
|
||||
// Methods
|
||||
async function loadPDF() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
// Clean and encode the file path
|
||||
const cleanPath = props.filePath.startsWith('/')
|
||||
? props.filePath.substring(1)
|
||||
: props.filePath
|
||||
const encodedPath = cleanPath.split('/').map(encodeURIComponent).join('/')
|
||||
const downloadUrl = generateUrl(`/remote.php/webdav/${encodedPath}`)
|
||||
|
||||
// Load PDF document
|
||||
const loadingTask = pdfjsLib.getDocument({
|
||||
url: downloadUrl,
|
||||
withCredentials: true,
|
||||
useWorkerFetch: false, // Disable worker fetch for CSP compliance
|
||||
isEvalSupported: false, // Disable eval for CSP
|
||||
})
|
||||
|
||||
pdfDoc.value = await loadingTask.promise
|
||||
totalPages.value = pdfDoc.value.numPages
|
||||
emit('loaded', { totalPages: totalPages.value })
|
||||
|
||||
// Set loading to false - the watcher will handle rendering
|
||||
loading.value = false
|
||||
} catch (err) {
|
||||
console.error('PDF load error:', err)
|
||||
|
||||
// Provide user-friendly error messages
|
||||
if (err.name === 'MissingPDFException') {
|
||||
error.value = t('astrolabe', 'PDF file not found')
|
||||
} else if (err.name === 'InvalidPDFException') {
|
||||
error.value = t('astrolabe', 'Invalid or corrupted PDF file')
|
||||
} else if (err.message?.includes('NetworkError') || err.message?.includes('Network')) {
|
||||
error.value = t('astrolabe', 'Network error loading PDF')
|
||||
} else if (err.message?.includes('404')) {
|
||||
error.value = t('astrolabe', 'PDF file not found')
|
||||
} else {
|
||||
error.value = t('astrolabe', 'Unable to load PDF file')
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
pageNumber(newPage) {
|
||||
if (this.pdfDoc && newPage > 0 && newPage <= this.totalPages) {
|
||||
this.renderPage(newPage)
|
||||
}
|
||||
},
|
||||
filePath() {
|
||||
// Reload PDF if file path changes
|
||||
this.loadPDF()
|
||||
},
|
||||
async loading(newLoading) {
|
||||
// When loading completes, wait for canvas to be available and render
|
||||
if (!newLoading && this.pdfDoc && !this.error) {
|
||||
// Wait for Vue to update DOM
|
||||
await this.$nextTick()
|
||||
// Canvas should now be rendered (v-else condition)
|
||||
if (this.$refs.canvas) {
|
||||
await this.renderPage(this.pageNumber)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
async mounted() {
|
||||
await this.loadPDF()
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.pdfDoc) {
|
||||
this.pdfDoc.destroy()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
t,
|
||||
async loadPDF() {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
// Clean and encode the file path
|
||||
const cleanPath = this.filePath.startsWith('/')
|
||||
? this.filePath.substring(1)
|
||||
: this.filePath
|
||||
const encodedPath = cleanPath.split('/').map(encodeURIComponent).join('/')
|
||||
const downloadUrl = generateUrl(`/remote.php/webdav/${encodedPath}`)
|
||||
|
||||
// Load PDF document
|
||||
const loadingTask = pdfjsLib.getDocument({
|
||||
url: downloadUrl,
|
||||
withCredentials: true,
|
||||
useWorkerFetch: false, // Disable worker fetch for CSP compliance
|
||||
isEvalSupported: false, // Disable eval for CSP
|
||||
})
|
||||
|
||||
this.pdfDoc = await loadingTask.promise
|
||||
this.totalPages = this.pdfDoc.numPages
|
||||
this.$emit('loaded', { totalPages: this.totalPages })
|
||||
|
||||
// Set loading to false - the watcher will handle rendering
|
||||
this.loading = false
|
||||
} catch (err) {
|
||||
console.error('PDF load error:', err)
|
||||
|
||||
// Provide user-friendly error messages
|
||||
if (err.name === 'MissingPDFException') {
|
||||
this.error = t('astrolabe', 'PDF file not found')
|
||||
} else if (err.name === 'InvalidPDFException') {
|
||||
this.error = t('astrolabe', 'Invalid or corrupted PDF file')
|
||||
} else if (err.message?.includes('NetworkError') || err.message?.includes('Network')) {
|
||||
this.error = t('astrolabe', 'Network error loading PDF')
|
||||
} else if (err.message?.includes('404')) {
|
||||
this.error = t('astrolabe', 'PDF file not found')
|
||||
} else {
|
||||
this.error = t('astrolabe', 'Unable to load PDF file')
|
||||
}
|
||||
|
||||
this.$emit('error', err)
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
async renderPage(pageNum) {
|
||||
if (!this.pdfDoc) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const page = await this.pdfDoc.getPage(pageNum)
|
||||
const canvas = this.$refs.canvas
|
||||
|
||||
if (!canvas) {
|
||||
console.error('PDF canvas ref not found')
|
||||
this.error = t('astrolabe', 'Canvas element not available')
|
||||
return
|
||||
}
|
||||
|
||||
const context = canvas.getContext('2d')
|
||||
|
||||
// Use scale for better resolution on high-DPI screens
|
||||
const viewport = page.getViewport({ scale: this.scale })
|
||||
|
||||
canvas.height = viewport.height
|
||||
canvas.width = viewport.width
|
||||
|
||||
// Render page to canvas
|
||||
const renderContext = {
|
||||
canvasContext: context,
|
||||
viewport,
|
||||
}
|
||||
|
||||
await page.render(renderContext).promise
|
||||
|
||||
this.$emit('page-rendered', { pageNumber: pageNum })
|
||||
} catch (err) {
|
||||
console.error('PDF render error:', err)
|
||||
this.error = t('astrolabe', 'Error rendering PDF page')
|
||||
this.$emit('error', err)
|
||||
}
|
||||
},
|
||||
},
|
||||
emit('error', err)
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function renderPage(pageNum) {
|
||||
if (!pdfDoc.value) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const page = await pdfDoc.value.getPage(pageNum)
|
||||
const canvas = canvasRef.value
|
||||
|
||||
if (!canvas) {
|
||||
console.error('PDF canvas ref not found')
|
||||
error.value = t('astrolabe', 'Canvas element not available')
|
||||
return
|
||||
}
|
||||
|
||||
const context = canvas.getContext('2d')
|
||||
|
||||
// Use scale for better resolution on high-DPI screens
|
||||
const viewport = page.getViewport({ scale: props.scale })
|
||||
|
||||
canvas.height = viewport.height
|
||||
canvas.width = viewport.width
|
||||
|
||||
// Render page to canvas
|
||||
const renderContext = {
|
||||
canvasContext: context,
|
||||
viewport,
|
||||
}
|
||||
|
||||
await page.render(renderContext).promise
|
||||
|
||||
emit('page-rendered', { pageNumber: pageNum })
|
||||
} catch (err) {
|
||||
console.error('PDF render error:', err)
|
||||
error.value = t('astrolabe', 'Error rendering PDF page')
|
||||
emit('error', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Watchers
|
||||
watch(() => props.pageNumber, (newPage) => {
|
||||
if (pdfDoc.value && newPage > 0 && newPage <= totalPages.value) {
|
||||
renderPage(newPage)
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.filePath, () => {
|
||||
// Reload PDF if file path changes
|
||||
loadPDF()
|
||||
})
|
||||
|
||||
watch(loading, async (newLoading) => {
|
||||
// When loading completes, wait for canvas to be available and render
|
||||
if (!newLoading && pdfDoc.value && !error.value) {
|
||||
// Wait for Vue to update DOM
|
||||
await nextTick()
|
||||
// Canvas should now be rendered (v-else condition)
|
||||
if (canvasRef.value) {
|
||||
await renderPage(props.pageNumber)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Lifecycle hooks
|
||||
onMounted(() => {
|
||||
loadPDF()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (pdfDoc.value) {
|
||||
pdfDoc.value.destroy()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -0,0 +1,672 @@
|
||||
<template>
|
||||
<div class="admin-settings">
|
||||
<NcLoadingIcon v-if="loading" :size="64" class="loading-icon" />
|
||||
|
||||
<NcNoteCard v-else-if="error" type="error">
|
||||
<p><strong>{{ t('astrolabe', 'Cannot connect to MCP server') }}</strong></p>
|
||||
<p>{{ error }}</p>
|
||||
<p class="help-text">{{ t('astrolabe', 'Ensure MCP server is running and accessible. Check config.php for correct mcp_server_url.') }}</p>
|
||||
</NcNoteCard>
|
||||
|
||||
<template v-else>
|
||||
<!-- Service Status -->
|
||||
<div class="admin-section">
|
||||
<h3>{{ t('astrolabe', 'Service Status') }}</h3>
|
||||
<div class="status-card">
|
||||
<p><strong>{{ t('astrolabe', 'Version') }}:</strong> {{ serverStatus?.version || 'Unknown' }}</p>
|
||||
<p v-if="serverStatus?.uptime_seconds">
|
||||
<strong>{{ t('astrolabe', 'Uptime') }}:</strong> {{ formatUptime(serverStatus.uptime_seconds) }}
|
||||
</p>
|
||||
<p>
|
||||
<strong>{{ t('astrolabe', 'Semantic Search') }}:</strong>
|
||||
<span v-if="vectorSyncEnabled" class="status-badge status-enabled">
|
||||
{{ t('astrolabe', 'Enabled') }}
|
||||
</span>
|
||||
<span v-else class="status-badge status-disabled">
|
||||
{{ t('astrolabe', 'Disabled') }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Indexing Metrics -->
|
||||
<div v-if="vectorSyncEnabled && vectorSyncStatus" class="admin-section">
|
||||
<h3>{{ t('astrolabe', 'Indexing Metrics') }}</h3>
|
||||
<div class="metrics-grid">
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">{{ t('astrolabe', 'Status') }}</div>
|
||||
<div class="metric-value" :class="`status-${vectorSyncStatus.status}`">
|
||||
{{ vectorSyncStatus.status }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">{{ t('astrolabe', 'Indexed Documents') }}</div>
|
||||
<div class="metric-value">{{ formatNumber(vectorSyncStatus.indexed_documents) }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">{{ t('astrolabe', 'Pending Documents') }}</div>
|
||||
<div class="metric-value">{{ formatNumber(vectorSyncStatus.pending_documents) }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">{{ t('astrolabe', 'Processing Rate') }}</div>
|
||||
<div class="metric-value">{{ formatNumber(vectorSyncStatus.documents_per_second, 1) }} docs/sec</div>
|
||||
</div>
|
||||
</div>
|
||||
<NcButton type="secondary" @click="refreshStatus">
|
||||
<template #icon>
|
||||
<Refresh :size="20" />
|
||||
</template>
|
||||
{{ t('astrolabe', 'Refresh Status') }}
|
||||
</NcButton>
|
||||
</div>
|
||||
|
||||
<!-- Webhook Management -->
|
||||
<div v-if="vectorSyncEnabled" class="admin-section">
|
||||
<h3>{{ t('astrolabe', 'Webhook Management') }}</h3>
|
||||
<p class="section-description">
|
||||
{{ t('astrolabe', 'Configure real-time synchronization for Nextcloud apps using webhooks. Webhooks provide instant updates to the MCP server when content changes.') }}
|
||||
</p>
|
||||
|
||||
<div v-if="webhooksLoading" class="loading-indicator">
|
||||
<NcLoadingIcon :size="32" />
|
||||
<p>{{ t('astrolabe', 'Loading webhook presets...') }}</p>
|
||||
</div>
|
||||
|
||||
<NcNoteCard v-else-if="webhooksError" type="warning">
|
||||
<p><strong>{{ t('astrolabe', 'Authorization Required') }}</strong></p>
|
||||
<p v-if="webhooksError.includes('authorization')">
|
||||
{{ t('astrolabe', 'To manage webhooks, you must first authorize Astrolabe with the MCP server in your Personal Settings.') }}
|
||||
</p>
|
||||
<p v-else>{{ webhooksError }}</p>
|
||||
<div class="webhook-auth-actions">
|
||||
<NcButton type="primary" @click="openPersonalSettings">
|
||||
{{ t('astrolabe', 'Go to Personal Settings') }}
|
||||
</NcButton>
|
||||
</div>
|
||||
</NcNoteCard>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="webhookPresets.length === 0" class="empty-state">
|
||||
<NcNoteCard type="info">
|
||||
<p>{{ t('astrolabe', 'No webhook presets available. Install supported apps (Notes, Calendar, Tables, Forms) to enable webhooks.') }}</p>
|
||||
</NcNoteCard>
|
||||
</div>
|
||||
|
||||
<div v-else class="webhook-presets-grid">
|
||||
<div v-for="preset in webhookPresets" :key="preset.id" class="webhook-preset-card">
|
||||
<div class="preset-header">
|
||||
<h4>{{ preset.name }}</h4>
|
||||
<span :class="`preset-status preset-status-${preset.enabled ? 'enabled' : 'disabled'}`">
|
||||
{{ preset.enabled ? t('astrolabe', 'Enabled') : t('astrolabe', 'Disabled') }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="preset-description">{{ preset.description }}</p>
|
||||
<div class="preset-meta">
|
||||
<span class="preset-app">{{ t('astrolabe', 'App') }}: {{ preset.app }}</span>
|
||||
<span class="preset-events">{{ preset.events.length }} {{ t('astrolabe', 'events') }}</span>
|
||||
</div>
|
||||
<div class="preset-actions">
|
||||
<NcButton
|
||||
:type="preset.enabled ? 'secondary' : 'primary'"
|
||||
:disabled="preset.toggling"
|
||||
@click="toggleWebhookPreset(preset)">
|
||||
{{ preset.toggling ? t('astrolabe', 'Please wait...') : (preset.enabled ? t('astrolabe', 'Disable') : t('astrolabe', 'Enable')) }}
|
||||
</NcButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NcNoteCard type="info" class="webhook-info">
|
||||
<p><strong>{{ t('astrolabe', 'How Webhooks Work') }}</strong></p>
|
||||
<ul>
|
||||
<li>{{ t('astrolabe', 'Enable a preset to register webhooks for that app with the MCP server') }}</li>
|
||||
<li>{{ t('astrolabe', 'When content changes in Nextcloud, webhooks notify the MCP server instantly') }}</li>
|
||||
<li>{{ t('astrolabe', 'The MCP server updates its vector index in real-time for semantic search') }}</li>
|
||||
<li>{{ t('astrolabe', 'Disable a preset to stop receiving updates for that app') }}</li>
|
||||
</ul>
|
||||
</NcNoteCard>
|
||||
|
||||
<NcNoteCard type="warning" class="webhook-requirements">
|
||||
<p><strong>{{ t('astrolabe', 'Requirements') }}</strong></p>
|
||||
<ul>
|
||||
<li>{{ t('astrolabe', 'The webhook_listeners app must be installed and enabled in Nextcloud') }}</li>
|
||||
<li>{{ t('astrolabe', 'The MCP server must be reachable from your Nextcloud instance') }}</li>
|
||||
<li>{{ t('astrolabe', 'You must have authorized Astrolabe with the MCP server (see Personal Settings)') }}</li>
|
||||
</ul>
|
||||
</NcNoteCard>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Search Settings -->
|
||||
<div v-if="vectorSyncEnabled" class="admin-section">
|
||||
<h3>{{ t('astrolabe', 'AI Search Provider Settings') }}</h3>
|
||||
<p class="section-description">
|
||||
{{ t('astrolabe', 'Configure the default search parameters for the AI Search provider in Nextcloud unified search.') }}
|
||||
</p>
|
||||
|
||||
<div class="settings-form">
|
||||
<NcSelect
|
||||
v-model="settings.algorithm"
|
||||
:options="algorithmOptions"
|
||||
:label="t('astrolabe', 'Search Algorithm')"
|
||||
class="form-field" />
|
||||
<p class="help-text">
|
||||
{{ t('astrolabe', 'Hybrid combines semantic understanding with keyword matching. Semantic finds conceptually similar content. BM25 matches exact keywords.') }}
|
||||
</p>
|
||||
|
||||
<NcSelect
|
||||
v-model="settings.fusion"
|
||||
:options="fusionOptions"
|
||||
:label="t('astrolabe', 'Fusion Method')"
|
||||
class="form-field" />
|
||||
<p class="help-text">
|
||||
{{ t('astrolabe', '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 class="form-field">
|
||||
<label>{{ t('astrolabe', 'Minimum Score Threshold') }}: {{ settings.scoreThreshold }}%</label>
|
||||
<input
|
||||
v-model="settings.scoreThreshold"
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
step="5"
|
||||
class="score-slider" />
|
||||
<p class="help-text">
|
||||
{{ t('astrolabe', 'Filter out results below this relevance score. Set to 0 to show all results.') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<NcTextField
|
||||
:value="settings.limit"
|
||||
:label="t('astrolabe', 'Maximum Results')"
|
||||
type="number"
|
||||
:min="5"
|
||||
:max="100"
|
||||
:step="5"
|
||||
class="form-field"
|
||||
@update:value="settings.limit = Number($event)" />
|
||||
<p class="help-text">
|
||||
{{ t('astrolabe', 'Maximum number of results to return per search query (5-100).') }}
|
||||
</p>
|
||||
|
||||
<div class="form-actions">
|
||||
<NcButton type="primary" :disabled="saving" @click="saveSettings">
|
||||
{{ saving ? t('astrolabe', 'Saving...') : t('astrolabe', 'Save Settings') }}
|
||||
</NcButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Documentation -->
|
||||
<div class="admin-section">
|
||||
<h3>{{ t('astrolabe', 'Documentation') }}</h3>
|
||||
<ul class="doc-links">
|
||||
<li>
|
||||
<a href="https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/configuration.md" target="_blank">
|
||||
{{ t('astrolabe', 'Configuration Guide') }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://github.com/cbcoutinho/nextcloud-mcp-server" target="_blank">
|
||||
{{ t('astrolabe', 'GitHub Repository') }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { loadState } from '@nextcloud/initial-state'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
import axios from '@nextcloud/axios'
|
||||
import { showError, showSuccess } from '@nextcloud/dialogs'
|
||||
|
||||
import {
|
||||
NcLoadingIcon,
|
||||
NcNoteCard,
|
||||
NcButton,
|
||||
NcSelect,
|
||||
NcTextField,
|
||||
} from '@nextcloud/vue'
|
||||
|
||||
import Refresh from 'vue-material-design-icons/Refresh.vue'
|
||||
|
||||
// Reactive state
|
||||
const loading = ref(true)
|
||||
const error = ref(null)
|
||||
const serverStatus = ref(null)
|
||||
const vectorSyncStatus = ref(null)
|
||||
const vectorSyncEnabled = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
// Webhook management state
|
||||
const webhooksLoading = ref(false)
|
||||
const webhooksError = ref(null)
|
||||
const webhookPresets = ref([])
|
||||
|
||||
// Load initial state from PHP
|
||||
const initialData = loadState('astrolabe', 'admin-config', {})
|
||||
const settings = ref(initialData.searchSettings || {
|
||||
algorithm: 'hybrid',
|
||||
fusion: 'rrf',
|
||||
scoreThreshold: 0,
|
||||
limit: 20,
|
||||
})
|
||||
|
||||
// Computed properties
|
||||
const algorithmOptions = computed(() => [
|
||||
{ id: 'hybrid', label: t('astrolabe', 'Hybrid (Recommended)') },
|
||||
{ id: 'semantic', label: t('astrolabe', 'Semantic Only') },
|
||||
{ id: 'bm25', label: t('astrolabe', 'Keyword (BM25) Only') },
|
||||
])
|
||||
|
||||
const fusionOptions = computed(() => [
|
||||
{ id: 'rrf', label: t('astrolabe', 'RRF - Reciprocal Rank Fusion (Recommended)') },
|
||||
{ id: 'dbsf', label: t('astrolabe', 'DBSF - Distribution-Based Score Fusion') },
|
||||
])
|
||||
|
||||
// Methods
|
||||
async function loadServerStatus() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
// Fetch server status asynchronously
|
||||
const [statusResponse, syncResponse] = await Promise.all([
|
||||
axios.get(generateUrl('/apps/astrolabe/api/admin/server-status')),
|
||||
axios.get(generateUrl('/apps/astrolabe/api/admin/vector-status')),
|
||||
])
|
||||
|
||||
if (statusResponse.data.success) {
|
||||
serverStatus.value = statusResponse.data.status
|
||||
vectorSyncEnabled.value = statusResponse.data.status?.vector_sync_enabled ?? false
|
||||
}
|
||||
|
||||
if (syncResponse.data.success) {
|
||||
vectorSyncStatus.value = syncResponse.data.status
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load server status:', err)
|
||||
error.value = err.response?.data?.error || err.message || t('astrolabe', 'Network error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshStatus() {
|
||||
await loadServerStatus()
|
||||
showSuccess(t('astrolabe', 'Status refreshed'))
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
generateUrl('/apps/astrolabe/api/admin/search-settings'),
|
||||
settings.value,
|
||||
{ headers: { 'Content-Type': 'application/json' } },
|
||||
)
|
||||
|
||||
if (response.data.success) {
|
||||
showSuccess(t('astrolabe', 'Settings saved successfully'))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to save settings:', err)
|
||||
showError(t('astrolabe', 'Failed to save settings'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWebhookPresets() {
|
||||
webhooksLoading.value = true
|
||||
webhooksError.value = null
|
||||
|
||||
try {
|
||||
const response = await axios.get(generateUrl('/apps/astrolabe/api/admin/webhooks/presets'))
|
||||
|
||||
if (response.data.success) {
|
||||
// Convert presets object to array with IDs
|
||||
const presetsObj = response.data.presets
|
||||
webhookPresets.value = Object.keys(presetsObj).map(id => ({
|
||||
id,
|
||||
...presetsObj[id],
|
||||
toggling: false,
|
||||
}))
|
||||
} else {
|
||||
webhooksError.value = response.data.error || t('astrolabe', 'Failed to load webhook presets')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load webhook presets:', err)
|
||||
webhooksError.value = err.response?.data?.error || err.message || t('astrolabe', 'Network error')
|
||||
} finally {
|
||||
webhooksLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleWebhookPreset(preset) {
|
||||
preset.toggling = true
|
||||
|
||||
const endpoint = preset.enabled
|
||||
? `/apps/astrolabe/api/admin/webhooks/presets/${preset.id}/disable`
|
||||
: `/apps/astrolabe/api/admin/webhooks/presets/${preset.id}/enable`
|
||||
|
||||
try {
|
||||
const response = await axios.post(generateUrl(endpoint))
|
||||
|
||||
if (response.data.success) {
|
||||
// Toggle the enabled state
|
||||
preset.enabled = !preset.enabled
|
||||
showSuccess(response.data.message || (preset.enabled ? t('astrolabe', 'Webhook preset enabled') : t('astrolabe', 'Webhook preset disabled')))
|
||||
} else {
|
||||
showError(response.data.error || t('astrolabe', 'Failed to toggle webhook preset'))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle webhook preset:', err)
|
||||
showError(err.response?.data?.error || err.message || t('astrolabe', 'Network error'))
|
||||
} finally {
|
||||
preset.toggling = false
|
||||
}
|
||||
}
|
||||
|
||||
function openPersonalSettings() {
|
||||
window.location.href = generateUrl('/settings/user/astrolabe')
|
||||
}
|
||||
|
||||
function formatUptime(seconds) {
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
return t('astrolabe', '{hours} hours, {minutes} minutes', { hours, minutes })
|
||||
}
|
||||
|
||||
function formatNumber(value, decimals = 0) {
|
||||
if (value === undefined || value === null) return '0'
|
||||
return Number(value).toLocaleString(undefined, {
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals,
|
||||
})
|
||||
}
|
||||
|
||||
// Lifecycle hooks
|
||||
onMounted(async () => {
|
||||
await loadServerStatus()
|
||||
// Load webhook presets if vector sync is enabled
|
||||
if (vectorSyncEnabled.value) {
|
||||
await loadWebhookPresets()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.admin-settings {
|
||||
padding: 20px;
|
||||
max-width: 900px;
|
||||
|
||||
// Fix NcNoteCard icon sizing issues in Vue 3/@nextcloud/vue 9
|
||||
:deep(.notecard) {
|
||||
max-width: 100%;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.notecard__icon {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
|
||||
svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loading-icon {
|
||||
margin: 40px auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.admin-section {
|
||||
margin-bottom: 32px;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.section-description {
|
||||
color: var(--color-text-maxcontrast);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.help-text {
|
||||
color: var(--color-text-maxcontrast);
|
||||
font-size: 13px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-large);
|
||||
padding: 20px;
|
||||
|
||||
p {
|
||||
margin: 8px 0;
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
|
||||
&.status-enabled {
|
||||
background: var(--color-success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.status-disabled {
|
||||
background: var(--color-background-dark);
|
||||
color: var(--color-text-maxcontrast);
|
||||
}
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-large);
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-maxcontrast);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--color-main-text);
|
||||
|
||||
&.status-idle {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
&.status-syncing {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
&.status-error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
}
|
||||
|
||||
.settings-form {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-large);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.form-field {
|
||||
margin-bottom: 20px;
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: var(--color-main-text);
|
||||
}
|
||||
}
|
||||
|
||||
.score-slider {
|
||||
width: 100%;
|
||||
accent-color: var(--color-primary-element);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.doc-links {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
|
||||
li {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-primary-element);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Webhook management styles
|
||||
.loading-indicator {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 32px;
|
||||
color: var(--color-text-maxcontrast);
|
||||
}
|
||||
|
||||
.webhook-presets-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.webhook-preset-card {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-large);
|
||||
padding: 16px;
|
||||
transition: border-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary-element);
|
||||
}
|
||||
|
||||
.preset-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.preset-status {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
|
||||
&.preset-status-enabled {
|
||||
background: var(--color-success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.preset-status-disabled {
|
||||
background: var(--color-background-dark);
|
||||
color: var(--color-text-maxcontrast);
|
||||
}
|
||||
}
|
||||
|
||||
.preset-description {
|
||||
color: var(--color-text-maxcontrast);
|
||||
font-size: 14px;
|
||||
margin: 0 0 12px 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.preset-meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-maxcontrast);
|
||||
margin-bottom: 12px;
|
||||
|
||||
.preset-app {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.preset-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.webhook-info,
|
||||
.webhook-requirements {
|
||||
margin-top: 16px;
|
||||
|
||||
ul {
|
||||
margin: 8px 0 0 0;
|
||||
padding-left: 20px;
|
||||
|
||||
li {
|
||||
margin: 4px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+4
-295
@@ -2,305 +2,14 @@
|
||||
/**
|
||||
* Admin settings template for Astrolabe.
|
||||
*
|
||||
* Displays semantic search service status, indexing metrics, configuration,
|
||||
* and provides administrative controls.
|
||||
*
|
||||
* @var array $_ Template parameters
|
||||
* @var array $_['serverStatus'] Server status from API
|
||||
* @var array $_['vectorSyncStatus'] Vector sync metrics from API
|
||||
* @var string $_['serverUrl'] Configured Astrolabe service URL
|
||||
* @var bool $_['apiKeyConfigured'] Whether API key is set in config.php
|
||||
* @var bool $_['vectorSyncEnabled'] Whether vector sync is enabled
|
||||
* Mounts the Vue.js admin settings component for async loading
|
||||
* and improved UX.
|
||||
*/
|
||||
|
||||
script('astrolabe', 'astrolabe-adminSettings');
|
||||
style('astrolabe', 'astrolabe-adminSettings');
|
||||
?>
|
||||
|
||||
<div id="mcp-admin-settings" class="section">
|
||||
<h2><?php p($l->t('Astrolabe Administration')); ?></h2>
|
||||
|
||||
<div class="mcp-settings-info">
|
||||
<p><?php p($l->t('Monitor and configure the semantic search service for your Nextcloud instance.')); ?></p>
|
||||
<p><?php p($l->t('Use the "MCP Server Configuration" section above to configure the connection settings.')); ?></p>
|
||||
</div>
|
||||
|
||||
<!-- Service Status -->
|
||||
<div class="mcp-status-card">
|
||||
<h3><?php p($l->t('Service Status')); ?></h3>
|
||||
<table class="mcp-info-table">
|
||||
<tr>
|
||||
<td><strong><?php p($l->t('Version')); ?></strong></td>
|
||||
<td><?php p($_['serverStatus']['version'] ?? 'Unknown'); ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong><?php p($l->t('Uptime')); ?></strong></td>
|
||||
<td>
|
||||
<?php if (isset($_['serverStatus']['uptime_seconds'])): ?>
|
||||
<?php
|
||||
$uptime = $_['serverStatus']['uptime_seconds'];
|
||||
$hours = floor($uptime / 3600);
|
||||
$minutes = floor(($uptime % 3600) / 60);
|
||||
p(sprintf('%d hours, %d minutes', $hours, $minutes));
|
||||
?>
|
||||
<?php else: ?>
|
||||
<?php p($l->t('Unknown')); ?>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong><?php p($l->t('Semantic Search')); ?></strong></td>
|
||||
<td>
|
||||
<?php if ($_['vectorSyncEnabled']): ?>
|
||||
<span class="badge badge-success">
|
||||
<span class="icon icon-checkmark-white"></span>
|
||||
<?php p($l->t('Enabled')); ?>
|
||||
</span>
|
||||
<?php else: ?>
|
||||
<span class="badge badge-neutral">
|
||||
<?php p($l->t('Disabled')); ?>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Indexing Metrics -->
|
||||
<?php if ($_['vectorSyncEnabled'] && !isset($_['vectorSyncStatus']['error'])): ?>
|
||||
<div class="mcp-status-card" id="vector-sync-metrics">
|
||||
<h3><?php p($l->t('Indexing Metrics')); ?></h3>
|
||||
<table class="mcp-info-table">
|
||||
<tr>
|
||||
<td><strong><?php p($l->t('Status')); ?></strong></td>
|
||||
<td>
|
||||
<?php
|
||||
$status = $_['vectorSyncStatus']['status'] ?? 'unknown';
|
||||
$statusClass = $status === 'idle' ? 'success' : ($status === 'syncing' ? 'info' : 'neutral');
|
||||
?>
|
||||
<span class="badge badge-<?php p($statusClass); ?>">
|
||||
<?php p(ucfirst($status)); ?>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong><?php p($l->t('Indexed Documents')); ?></strong></td>
|
||||
<td><?php p(number_format($_['vectorSyncStatus']['indexed_documents'] ?? 0)); ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong><?php p($l->t('Pending Documents')); ?></strong></td>
|
||||
<td><?php p(number_format($_['vectorSyncStatus']['pending_documents'] ?? 0)); ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong><?php p($l->t('Last Sync')); ?></strong></td>
|
||||
<td><?php p($_['vectorSyncStatus']['last_sync_time'] ?? 'Never'); ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong><?php p($l->t('Processing Rate')); ?></strong></td>
|
||||
<td><?php p(sprintf('%.1f docs/sec', $_['vectorSyncStatus']['documents_per_second'] ?? 0)); ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong><?php p($l->t('Errors (24h)')); ?></strong></td>
|
||||
<td>
|
||||
<?php
|
||||
$errors = $_['vectorSyncStatus']['errors_24h'] ?? 0;
|
||||
if ($errors > 0): ?>
|
||||
<span class="error"><?php p($errors); ?></span>
|
||||
<?php else: ?>
|
||||
<?php p('0'); ?>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p class="mcp-help-text">
|
||||
<?php p($l->t('Metrics are updated in real-time. Refresh the page to see latest values.')); ?>
|
||||
</p>
|
||||
</div>
|
||||
<?php elseif ($_['vectorSyncEnabled']): ?>
|
||||
<div class="mcp-status-card mcp-error">
|
||||
<h3><?php p($l->t('Indexing Metrics')); ?></h3>
|
||||
<div class="notecard notecard-error">
|
||||
<p><?php p($l->t('Failed to retrieve indexing status:')); ?></p>
|
||||
<p><code><?php p($_['vectorSyncStatus']['error'] ?? 'Unknown error'); ?></code></p>
|
||||
</div>
|
||||
</div>
|
||||
<?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="astrolabe-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; ?>
|
||||
|
||||
<!-- Webhook Management -->
|
||||
<?php if ($_['vectorSyncEnabled']): ?>
|
||||
<div class="mcp-status-card" id="webhook-presets">
|
||||
<h3><?php p($l->t('Webhook Management')); ?></h3>
|
||||
<p class="mcp-settings-description">
|
||||
<?php p($l->t('Configure real-time synchronization for Nextcloud apps using webhooks. Webhooks provide instant updates to the MCP server when content changes.')); ?>
|
||||
</p>
|
||||
|
||||
<div id="webhook-presets-container">
|
||||
<div class="mcp-loading">
|
||||
<?php p($l->t('Loading webhook presets...')); ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="notecard notecard-info">
|
||||
<p><strong><?php p($l->t('How Webhooks Work')); ?></strong></p>
|
||||
<ul>
|
||||
<li><?php p($l->t('Enable a preset to register webhooks for that app with the MCP server')); ?></li>
|
||||
<li><?php p($l->t('When content changes in Nextcloud, webhooks notify the MCP server instantly')); ?></li>
|
||||
<li><?php p($l->t('The MCP server updates its vector index in real-time for semantic search')); ?></li>
|
||||
<li><?php p($l->t('Disable a preset to stop receiving updates for that app')); ?></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="notecard notecard-warning">
|
||||
<p><strong><?php p($l->t('Requirements')); ?></strong></p>
|
||||
<ul>
|
||||
<li><?php p($l->t('The webhook_listeners app must be installed and enabled in Nextcloud')); ?></li>
|
||||
<li><?php p($l->t('The MCP server must be reachable from your Nextcloud instance')); ?></li>
|
||||
<li><?php p($l->t('You must have authorized Astrolabe with the MCP server (see Personal Settings)')); ?></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Capabilities -->
|
||||
<div class="mcp-status-card">
|
||||
<h3><?php p($l->t('Capabilities')); ?></h3>
|
||||
<ul class="mcp-feature-list">
|
||||
<li>
|
||||
<span class="icon icon-search"></span>
|
||||
<strong><?php p($l->t('Semantic Search')); ?></strong>
|
||||
<p><?php p($l->t('Search by meaning across Notes, Files, Calendar, and Deck using natural language queries.')); ?></p>
|
||||
</li>
|
||||
<?php if ($_['vectorSyncEnabled']): ?>
|
||||
<li>
|
||||
<span class="icon icon-category-monitoring"></span>
|
||||
<strong><?php p($l->t('Vector Visualization')); ?></strong>
|
||||
<p><?php p($l->t('Explore content relationships in an interactive 2D visualization.')); ?></p>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
<li>
|
||||
<span class="icon icon-user"></span>
|
||||
<strong><?php p($l->t('Per-User Indexing')); ?></strong>
|
||||
<p><?php p($l->t('Users control their own content indexing via Personal Settings.')); ?></p>
|
||||
</li>
|
||||
<li>
|
||||
<span class="icon icon-toggle"></span>
|
||||
<strong><?php p($l->t('Hybrid Search')); ?></strong>
|
||||
<p><?php p($l->t('Combines semantic understanding with keyword matching for optimal results.')); ?></p>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Documentation -->
|
||||
<div class="mcp-status-card">
|
||||
<h3><?php p($l->t('Documentation')); ?></h3>
|
||||
<ul class="mcp-links">
|
||||
<li>
|
||||
<a href="https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/configuration.md" target="_blank">
|
||||
<?php p($l->t('Configuration Guide')); ?>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://github.com/cbcoutinho/nextcloud-mcp-server" target="_blank">
|
||||
<?php p($l->t('GitHub Repository')); ?>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="astrolabe-admin-settings" class="section">
|
||||
<!-- Vue component will be mounted here -->
|
||||
</div>
|
||||
|
||||
Vendored
+10
-4
@@ -5,7 +5,7 @@ import { resolve } from 'path'
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
build: {
|
||||
outDir: 'js',
|
||||
outDir: '.',
|
||||
emptyOutDir: false,
|
||||
rollupOptions: {
|
||||
input: {
|
||||
@@ -14,9 +14,15 @@ export default defineConfig({
|
||||
'astrolabe-personalSettings': resolve(__dirname, 'src/personalSettings.js'),
|
||||
},
|
||||
output: {
|
||||
entryFileNames: '[name].mjs',
|
||||
chunkFileNames: '[name]-[hash].chunk.mjs',
|
||||
assetFileNames: '[name][extname]',
|
||||
entryFileNames: 'js/[name].mjs',
|
||||
chunkFileNames: 'js/[name]-[hash].chunk.mjs',
|
||||
assetFileNames: (assetInfo) => {
|
||||
// Output CSS to css/ directory, JS/other assets to js/
|
||||
if (assetInfo.name && assetInfo.name.endsWith('.css')) {
|
||||
return 'css/[name][extname]';
|
||||
}
|
||||
return 'js/[name][extname]';
|
||||
},
|
||||
},
|
||||
},
|
||||
sourcemap: true,
|
||||
|
||||
Reference in New Issue
Block a user