feat(mesh): mesh-AI assistant scheduler + config panel (#50)

Adds the assistant scheduler, MeshAssistantPanel UI, and the remaining
config-RPC / live-toggle / Ollama-detect wiring on top of Phase 1.x.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-06-17 19:19:32 -04:00
co-authored by Claude Opus 4.8
parent 0947ecee11
commit 7a76d32e4b
10 changed files with 667 additions and 28 deletions
@@ -0,0 +1,155 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { useMeshStore } from '@/stores/mesh'
import ToggleSwitch from '@/components/ToggleSwitch.vue'
const mesh = useMeshStore()
const saving = ref(false)
const status = computed(() => mesh.assistantStatus)
const enabled = ref(false)
const model = ref('') // '' = use the backend's default model
const policy = ref<'trusted' | 'anyone'>('trusted')
const backend = ref<'claude' | 'ollama'>('claude')
// Sync local controls from the fetched status.
watch(
status,
(s) => {
if (!s) return
enabled.value = s.enabled
model.value = s.model ?? ''
policy.value = s.trusted_only ? 'trusted' : 'anyone'
backend.value = s.backend === 'ollama' ? 'ollama' : 'claude'
},
{ immediate: true },
)
onMounted(() => {
mesh.fetchAssistantStatus()
})
const claudeReady = computed(() => status.value?.claude_available ?? false)
const ollamaReady = computed(() => status.value?.ollama_detected ?? false)
const availableModels = computed(() => status.value?.models ?? [])
const defaultModel = computed(() =>
backend.value === 'claude' ? 'Claude Haiku 4.5' : status.value?.default_model ?? 'qwen2.5-coder',
)
// The selected backend is usable when its provider is available.
const backendReady = computed(() =>
backend.value === 'claude' ? claudeReady.value : ollamaReady.value,
)
async function apply(partial: {
enabled?: boolean
model?: string | null
trusted_only?: boolean
backend?: string
}) {
saving.value = true
try {
await mesh.configureAssistant(partial)
} finally {
saving.value = false
}
}
function onToggle(val: boolean) {
enabled.value = val
apply({ enabled: val })
}
function onBackend() {
// Reset the model override when switching backend (models differ).
model.value = ''
apply({ backend: backend.value, model: null })
}
function onModel() {
apply({ model: model.value === '' ? null : model.value })
}
function onPolicy() {
apply({ trusted_only: policy.value === 'trusted' })
}
</script>
<template>
<div class="glass-card mesh-assistant-panel">
<h3 class="mesh-panel-title">AI Assistant</h3>
<p class="mesh-panel-sub">Answer questions over the mesh with AI</p>
<!-- Backend chooser -->
<div class="mesh-assistant-field">
<label class="mesh-bitcoin-label">AI backend</label>
<select v-model="backend" class="mesh-bitcoin-input mesh-bitcoin-input-sm" @change="onBackend">
<option value="claude">Claude (shared token no GPU needed)</option>
<option value="ollama">Local model (Ollama on this node)</option>
</select>
</div>
<!-- Provider missing -> guidance / deep-link -->
<div v-if="status && backend === 'ollama' && !ollamaReady" class="mesh-assistant-install">
<p class="text-sm text-white/70 mb-3">
Local mode needs the <strong>Ollama</strong> app installed and running.
</p>
<RouterLink to="/dashboard/marketplace/ollama" class="glass-button mesh-assistant-install-btn">
Install AI (Ollama)
</RouterLink>
</div>
<div v-else-if="status && backend === 'claude' && !claudeReady" class="mesh-assistant-install">
<p class="text-sm text-white/70">
No Claude API token is configured on this node yet. Add one in Settings to use the shared
Claude backend, or switch to a local model.
</p>
</div>
<!-- Enable toggle -->
<button
type="button"
class="w-full flex items-center gap-4 p-4 rounded-xl border transition-all text-left"
:class="enabled ? 'bg-white/10 border-orange-500/40' : 'bg-black/20 border-white/10 hover:border-white/20'"
:style="!backendReady ? 'opacity:0.5;cursor:not-allowed' : ''"
@click="backendReady && onToggle(!enabled)"
>
<svg class="w-5 h-5 shrink-0" :class="enabled ? 'text-orange-400' : 'text-white/40'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium" :class="enabled ? 'text-white/95' : 'text-white/70'">
{{ enabled ? 'Answering mesh AI queries' : 'Answer mesh AI queries' }}
</p>
<p class="text-xs text-white/50 mt-0.5">Peers can ask this node's AI over the radio</p>
</div>
<ToggleSwitch :model-value="enabled" @click.stop @update:model-value="backendReady && onToggle($event)" />
</button>
<template v-if="enabled">
<div v-if="backend === 'ollama'" class="mesh-assistant-field">
<label class="mesh-bitcoin-label">Model</label>
<select v-model="model" class="mesh-bitcoin-input mesh-bitcoin-input-sm" @change="onModel">
<option value="">Default ({{ defaultModel }})</option>
<option v-for="m in availableModels" :key="m" :value="m">{{ m }}</option>
</select>
</div>
<div v-else class="mesh-assistant-field">
<label class="mesh-bitcoin-label">Model</label>
<input v-model="model" class="mesh-bitcoin-input mesh-bitcoin-input-sm" :placeholder="defaultModel" @change="onModel" />
</div>
<div class="mesh-assistant-field">
<label class="mesh-bitcoin-label">Who can ask</label>
<select v-model="policy" class="mesh-bitcoin-input mesh-bitcoin-input-sm" @change="onPolicy">
<option value="trusted">Trusted nodes only</option>
<option value="anyone">Anyone on the mesh</option>
</select>
<p class="text-xs text-white/40 mt-1">
{{ policy === 'anyone'
? 'Any peer can spend this node\'s AI budget + airtime.'
: 'Only federation-trusted peers may ask.' }}
</p>
</div>
<p class="text-xs text-white/50 mt-2">
Ask from any client by sending <code>!ai &lt;question&gt;</code> on the mesh channel.
</p>
</template>
</div>
</template>