feat(astrolabe): add Nextcloud PHP app for MCP server management

Adds a native Nextcloud app "Astroglobe" that provides:
- Personal settings: OAuth authorization for background MCP access
- Admin settings: Server status and vector sync monitoring
- API endpoints for MCP server communication

The app uses PKCE OAuth flow to obtain tokens for the MCP server,
enabling features like background vector sync per ADR-018.

Includes:
- PHP app structure (controllers, services, settings)
- Vue.js frontend components
- Docker compose mount configuration
- Installation hook for development testing
- ADR-018 documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2025-12-14 20:11:21 +01:00
parent a58a14111b
commit 21817543ad
72 changed files with 27253 additions and 0 deletions
+657
View File
@@ -0,0 +1,657 @@
<template>
<NcContent app-name="astroglobe">
<NcAppNavigation>
<template #list>
<NcAppNavigationItem
:name="t('astroglobe', 'Semantic Search')"
:active="activeSection === 'search'"
@click="activeSection = 'search'">
<template #icon>
<Magnify :size="20" />
</template>
</NcAppNavigationItem>
<NcAppNavigationItem
:name="t('astroglobe', 'Index Status')"
:active="activeSection === 'status'"
@click="activeSection = 'status'; loadVectorStatus()">
<template #icon>
<ChartBox :size="20" />
</template>
</NcAppNavigationItem>
</template>
<template #footer>
<ul class="app-navigation-entry__settings">
<NcAppNavigationItem
:name="t('astroglobe', 'Settings')"
@click="goToSettings">
<template #icon>
<Cog :size="20" />
</template>
</NcAppNavigationItem>
</ul>
</template>
</NcAppNavigation>
<NcAppContent>
<!-- Search Section -->
<div v-show="activeSection === 'search'" class="mcp-section">
<div class="mcp-section-header">
<h2>{{ t('astroglobe', 'Semantic Search') }}</h2>
<p class="mcp-description">
{{ t('astroglobe', 'Search your indexed content using semantic similarity. Find documents by meaning, not just keywords.') }}
</p>
</div>
<!-- Search Controls -->
<div class="mcp-search-card">
<div class="mcp-search-row">
<NcTextField
:value.sync="query"
:label="t('astroglobe', 'Search query')"
:placeholder="t('astroglobe', 'Enter your search query...')"
class="mcp-search-input"
@keyup.enter="performSearch" />
<NcSelect
v-model="selectedAlgorithmOption"
:options="algorithmOptions"
:placeholder="t('astroglobe', 'Algorithm')"
class="mcp-algorithm-select"
@input="algorithm = $event ? $event.id : 'hybrid'" />
<NcButton
type="primary"
:disabled="!query.trim() || loading"
@click="performSearch">
<template #icon>
<Magnify :size="20" />
</template>
{{ t('astroglobe', 'Search') }}
</NcButton>
</div>
<!-- Advanced Options Toggle -->
<NcButton
type="tertiary"
class="mcp-advanced-toggle"
@click="showAdvanced = !showAdvanced">
<template #icon>
<ChevronDown v-if="!showAdvanced" :size="20" />
<ChevronUp v-else :size="20" />
</template>
{{ showAdvanced ? t('astroglobe', 'Hide advanced') : t('astroglobe', 'Advanced options') }}
</NcButton>
<!-- Advanced Options -->
<div v-show="showAdvanced" class="mcp-advanced-options">
<div class="mcp-advanced-grid">
<div class="mcp-option-group">
<label>{{ t('astroglobe', 'Document Types') }}</label>
<div class="mcp-checkbox-grid">
<NcCheckboxRadioSwitch
v-for="docType in docTypeOptions"
:key="docType.id"
:checked.sync="selectedDocTypes"
:value="docType.id"
type="checkbox">
{{ docType.label }}
</NcCheckboxRadioSwitch>
</div>
</div>
<div class="mcp-option-group">
<label>{{ t('astroglobe', 'Result Limit') }}</label>
<NcTextField
:value.sync="limit"
type="number"
:min="1"
:max="100" />
</div>
</div>
</div>
</div>
<!-- Loading State -->
<div v-if="loading" class="mcp-loading">
<NcLoadingIcon :size="32" />
<span>{{ t('astroglobe', 'Searching...') }}</span>
</div>
<!-- Error State -->
<NcNoteCard v-if="error" type="error" class="mcp-error">
{{ error }}
</NcNoteCard>
<!-- Results -->
<div v-if="results.length > 0 && !loading" class="mcp-results">
<div class="mcp-results-header">
<span>{{ results.length }} {{ t('astroglobe', 'results found') }}</span>
<span class="mcp-algorithm-badge">{{ algorithmUsed }}</span>
</div>
<div class="mcp-results-list">
<div
v-for="result in results"
:key="result.id"
class="mcp-result-item"
:class="'mcp-doc-type-' + (result.doc_type || 'unknown')">
<div class="mcp-result-header">
<span class="mcp-result-type">{{ result.doc_type || 'unknown' }}</span>
<span class="mcp-result-score">{{ formatScore(result.score) }}%</span>
</div>
<a v-if="result.link" :href="result.link" target="_blank" class="mcp-result-title">
{{ result.title || t('astroglobe', 'Untitled') }}
</a>
<div v-else class="mcp-result-title">
{{ result.title || t('astroglobe', 'Untitled') }}
</div>
<div class="mcp-result-excerpt">{{ result.excerpt }}</div>
</div>
</div>
</div>
<!-- No Results -->
<NcEmptyContent
v-if="searched && results.length === 0 && !loading && !error"
:name="t('astroglobe', 'No results found')"
:description="t('astroglobe', 'Try a different query or search algorithm.')">
<template #icon>
<Magnify />
</template>
</NcEmptyContent>
<!-- Initial State -->
<NcEmptyContent
v-if="!searched && !loading"
:name="t('astroglobe', 'Semantic Search')"
:description="t('astroglobe', 'Enter a query above to search your indexed content.')">
<template #icon>
<Magnify />
</template>
</NcEmptyContent>
</div>
<!-- Index Status Section -->
<div v-show="activeSection === 'status'" class="mcp-section">
<div class="mcp-section-header">
<h2>{{ t('astroglobe', 'Index Status') }}</h2>
<p class="mcp-description">
{{ t('astroglobe', 'View the status of your vector index and sync progress.') }}
</p>
</div>
<div v-if="statusLoading" class="mcp-loading">
<NcLoadingIcon :size="32" />
<span>{{ t('astroglobe', 'Loading status...') }}</span>
</div>
<NcNoteCard v-else-if="statusError" type="error">
{{ statusError }}
</NcNoteCard>
<div v-else-if="vectorStatus" class="mcp-status-cards">
<div class="mcp-status-card">
<div class="mcp-status-label">{{ t('astroglobe', 'Sync Status') }}</div>
<div class="mcp-status-value" :class="'status-' + vectorStatus.status">
{{ vectorStatus.status }}
</div>
</div>
<div class="mcp-status-card">
<div class="mcp-status-label">{{ t('astroglobe', 'Indexed Documents') }}</div>
<div class="mcp-status-value">{{ vectorStatus.indexed_documents || 0 }}</div>
</div>
<div class="mcp-status-card">
<div class="mcp-status-label">{{ t('astroglobe', 'Pending Documents') }}</div>
<div class="mcp-status-value">{{ vectorStatus.pending_documents || 0 }}</div>
</div>
<div v-if="vectorStatus.last_sync_time" class="mcp-status-card">
<div class="mcp-status-label">{{ t('astroglobe', 'Last Sync') }}</div>
<div class="mcp-status-value">{{ vectorStatus.last_sync_time }}</div>
</div>
</div>
<NcButton type="secondary" @click="loadVectorStatus" :disabled="statusLoading">
<template #icon>
<Refresh :size="20" />
</template>
{{ t('astroglobe', 'Refresh') }}
</NcButton>
</div>
</NcAppContent>
</NcContent>
</template>
<script>
import NcContent from '@nextcloud/vue/dist/Components/NcContent.js'
import NcAppNavigation from '@nextcloud/vue/dist/Components/NcAppNavigation.js'
import NcAppNavigationItem from '@nextcloud/vue/dist/Components/NcAppNavigationItem.js'
import NcAppContent from '@nextcloud/vue/dist/Components/NcAppContent.js'
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js'
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js'
import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
import Magnify from 'vue-material-design-icons/Magnify.vue'
import ChartBox from 'vue-material-design-icons/ChartBox.vue'
import Cog from 'vue-material-design-icons/Cog.vue'
import ChevronDown from 'vue-material-design-icons/ChevronDown.vue'
import ChevronUp from 'vue-material-design-icons/ChevronUp.vue'
import Refresh from 'vue-material-design-icons/Refresh.vue'
import axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router'
// App name for translations
const APP_NAME = 'astroglobe'
export default {
name: 'App',
components: {
NcContent,
NcAppNavigation,
NcAppNavigationItem,
NcAppContent,
NcButton,
NcTextField,
NcSelect,
NcLoadingIcon,
NcNoteCard,
NcEmptyContent,
NcCheckboxRadioSwitch,
Magnify,
ChartBox,
Cog,
ChevronDown,
ChevronUp,
Refresh,
},
data() {
return {
activeSection: 'search',
// Search state
query: '',
algorithm: 'hybrid',
showAdvanced: false,
selectedDocTypes: [],
limit: '20',
loading: false,
error: null,
results: [],
algorithmUsed: '',
searched: false,
// Vector status state
vectorStatus: null,
statusLoading: false,
statusError: null,
}
},
computed: {
algorithmOptions() {
return [
{ id: 'hybrid', label: this.t('astroglobe', 'Hybrid') },
{ id: 'semantic', label: this.t('astroglobe', 'Semantic') },
{ id: 'bm25', label: this.t('astroglobe', 'Keyword (BM25)') },
]
},
docTypeOptions() {
return [
{ id: 'note', label: this.t('astroglobe', 'Notes') },
{ id: 'file', label: this.t('astroglobe', 'Files') },
{ id: 'deck_card', label: this.t('astroglobe', 'Deck Cards') },
{ id: 'calendar', label: this.t('astroglobe', 'Calendar') },
{ id: 'contact', label: this.t('astroglobe', 'Contacts') },
{ id: 'news_item', label: this.t('astroglobe', 'News') },
]
},
selectedAlgorithmOption() {
return this.algorithmOptions.find(opt => opt.id === this.algorithm) || this.algorithmOptions[0]
},
},
methods: {
async performSearch() {
const queryText = this.query.trim()
if (!queryText) {
return
}
this.loading = true
this.error = null
this.searched = true
try {
const url = generateUrl('/apps/astroglobe/api/search')
const params = {
query: queryText,
algorithm: this.algorithm,
limit: parseInt(this.limit) || 20,
}
if (this.selectedDocTypes.length > 0) {
params.doc_types = this.selectedDocTypes.join(',')
}
const response = await axios.get(url, { params })
if (response.data.success) {
this.results = response.data.results || []
this.algorithmUsed = response.data.algorithm_used || this.algorithm
} else {
this.error = response.data.error || this.t('astroglobe', 'Search failed')
this.results = []
}
} catch (err) {
console.error('Search error:', err)
this.error = this.t('astroglobe', 'Network error. Please try again.')
this.results = []
} finally {
this.loading = false
}
},
async loadVectorStatus() {
this.statusLoading = true
this.statusError = null
try {
const url = generateUrl('/apps/astroglobe/api/vector-status')
const response = await axios.get(url)
if (response.data.success) {
this.vectorStatus = response.data.status
} else {
this.statusError = response.data.error || this.t('astroglobe', 'Failed to load status')
}
} catch (err) {
console.error('Status error:', err)
this.statusError = this.t('astroglobe', 'Network error. Please try again.')
} finally {
this.statusLoading = false
}
},
formatScore(score) {
return Math.round((score || 0) * 100)
},
goToSettings() {
window.location.href = generateUrl('/settings/user/mcp')
},
},
}
</script>
<style scoped lang="scss">
.mcp-section {
padding: 24px;
max-width: 1000px;
}
.mcp-section-header {
margin-bottom: 24px;
h2 {
margin: 0 0 8px 0;
font-size: 22px;
font-weight: 600;
}
.mcp-description {
color: var(--color-text-maxcontrast);
margin: 0;
}
}
// Search card
.mcp-search-card {
background: var(--color-background-hover);
border-radius: var(--border-radius-large);
padding: 20px;
margin-bottom: 24px;
}
.mcp-search-row {
display: flex;
gap: 12px;
align-items: flex-end;
flex-wrap: wrap;
}
.mcp-search-input {
flex: 1;
min-width: 250px;
}
.mcp-algorithm-select {
min-width: 150px;
}
.mcp-advanced-toggle {
margin-top: 12px;
}
.mcp-advanced-options {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--color-border);
}
.mcp-advanced-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
}
.mcp-option-group {
label {
display: block;
font-weight: 600;
margin-bottom: 8px;
color: var(--color-text-maxcontrast);
}
}
.mcp-checkbox-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
// Loading and error states
.mcp-loading {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 48px;
color: var(--color-text-maxcontrast);
}
.mcp-error {
margin: 16px 0;
}
// Results
.mcp-results {
margin-top: 24px;
}
.mcp-results-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--color-border);
color: var(--color-text-maxcontrast);
}
.mcp-algorithm-badge {
padding: 4px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
background: var(--color-background-dark);
}
.mcp-results-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.mcp-result-item {
padding: 16px;
background: var(--color-background-hover);
border-radius: var(--border-radius-large);
border-left: 4px solid var(--color-primary-element);
transition: transform 0.15s, box-shadow 0.15s;
&:hover {
transform: translateX(4px);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
}
.mcp-result-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.mcp-result-type {
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
background: var(--color-primary-element-light);
color: var(--color-primary-element);
}
// Document type colors
.mcp-doc-type-note {
border-left-color: #1565c0;
.mcp-result-type { background: #e3f2fd; color: #1565c0; }
}
.mcp-doc-type-file {
border-left-color: #2e7d32;
.mcp-result-type { background: #e8f5e9; color: #2e7d32; }
}
.mcp-doc-type-deck_card {
border-left-color: #ef6c00;
.mcp-result-type { background: #fff3e0; color: #ef6c00; }
}
.mcp-doc-type-calendar {
border-left-color: #c2185b;
.mcp-result-type { background: #fce4ec; color: #c2185b; }
}
.mcp-doc-type-contact {
border-left-color: #7b1fa2;
.mcp-result-type { background: #f3e5f5; color: #7b1fa2; }
}
.mcp-doc-type-news_item {
border-left-color: #00838f;
.mcp-result-type { background: #e0f7fa; color: #00838f; }
}
.mcp-result-score {
font-size: 13px;
font-weight: 600;
color: var(--color-text-maxcontrast);
}
.mcp-result-title {
font-weight: 600;
font-size: 15px;
color: var(--color-main-text);
margin-bottom: 6px;
line-height: 1.4;
text-decoration: none;
display: block;
&:hover {
color: var(--color-primary-element);
}
}
a.mcp-result-title {
cursor: pointer;
}
.mcp-result-excerpt {
font-size: 13px;
color: var(--color-text-maxcontrast);
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
// Status section
.mcp-status-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.mcp-status-card {
background: var(--color-background-hover);
border-radius: var(--border-radius-large);
padding: 20px;
text-align: center;
}
.mcp-status-label {
font-size: 13px;
color: var(--color-text-maxcontrast);
margin-bottom: 8px;
}
.mcp-status-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); }
}
// Navigation footer
.app-navigation-entry__settings {
height: auto !important;
overflow: hidden !important;
padding-top: 0 !important;
flex: 0 0 auto;
padding: 3px;
margin: 0 3px;
}
@media (max-width: 768px) {
.mcp-search-row {
flex-direction: column;
align-items: stretch;
}
.mcp-search-input,
.mcp-algorithm-select {
min-width: 100%;
}
.mcp-checkbox-grid {
grid-template-columns: 1fr;
}
}
</style>
+8
View File
@@ -0,0 +1,8 @@
import Vue from 'vue'
import { translate as t, translatePlural as n } from '@nextcloud/l10n'
import App from './App.vue'
Vue.mixin({ methods: { t, n } })
const View = Vue.extend(App)
new View().$mount('#astroglobe')