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:
co-authored by
Claude Opus 4.8
parent
0947ecee11
commit
7a76d32e4b
@@ -122,6 +122,26 @@ export interface BlockHeader {
|
||||
announced_by: string
|
||||
}
|
||||
|
||||
export interface AssistantStatus {
|
||||
enabled: boolean
|
||||
model: string | null
|
||||
trusted_only: boolean
|
||||
backend: string
|
||||
default_model: string
|
||||
ollama_detected: boolean
|
||||
claude_available: boolean
|
||||
models: string[]
|
||||
}
|
||||
|
||||
export interface ScheduledMessage {
|
||||
id: number
|
||||
contact_id: number | null
|
||||
channel: number | null
|
||||
body: string
|
||||
fire_at: number
|
||||
attempts: number
|
||||
}
|
||||
|
||||
export interface NodePosition {
|
||||
lat: number
|
||||
lng: number
|
||||
@@ -574,6 +594,59 @@ export const useMeshStore = defineStore('mesh', () => {
|
||||
const blockHeaders = ref<BlockHeader[]>([])
|
||||
const latestBlockHeight = ref(0)
|
||||
|
||||
// Mesh-AI assistant (issue #50)
|
||||
const assistantStatus = ref<AssistantStatus | null>(null)
|
||||
|
||||
async function fetchAssistantStatus() {
|
||||
try {
|
||||
assistantStatus.value = await rpcClient.call<AssistantStatus>({ method: 'mesh.assistant-status' })
|
||||
} catch {
|
||||
// Assistant not available (mesh service down)
|
||||
}
|
||||
}
|
||||
|
||||
async function configureAssistant(config: {
|
||||
enabled?: boolean
|
||||
model?: string | null
|
||||
trusted_only?: boolean
|
||||
backend?: string
|
||||
}) {
|
||||
const res = await rpcClient.call<Partial<AssistantStatus>>({
|
||||
method: 'mesh.assistant-configure',
|
||||
params: config,
|
||||
})
|
||||
await fetchAssistantStatus()
|
||||
return res
|
||||
}
|
||||
|
||||
// Scheduled / queued mesh messages (issue #50, phase 1.7)
|
||||
const scheduledMessages = ref<ScheduledMessage[]>([])
|
||||
|
||||
async function fetchScheduledMessages() {
|
||||
try {
|
||||
const res = await rpcClient.call<{ messages: ScheduledMessage[] }>({ method: 'mesh.list-scheduled' })
|
||||
scheduledMessages.value = res.messages || []
|
||||
} catch {
|
||||
scheduledMessages.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function scheduleMessage(params: {
|
||||
contact_id?: number
|
||||
channel?: number
|
||||
body: string
|
||||
fire_at: number
|
||||
}) {
|
||||
const res = await rpcClient.call<ScheduledMessage>({ method: 'mesh.schedule-message', params })
|
||||
await fetchScheduledMessages()
|
||||
return res
|
||||
}
|
||||
|
||||
async function cancelScheduledMessage(id: number) {
|
||||
await rpcClient.call({ method: 'mesh.cancel-scheduled', params: { id } })
|
||||
await fetchScheduledMessages()
|
||||
}
|
||||
|
||||
async function fetchDeadmanStatus() {
|
||||
try {
|
||||
deadmanStatus.value = await rpcClient.call<AlertStatus>({ method: 'mesh.deadman-status' })
|
||||
@@ -693,6 +766,13 @@ export const useMeshStore = defineStore('mesh', () => {
|
||||
fetchDeadmanStatus,
|
||||
configureDeadman,
|
||||
deadmanCheckin,
|
||||
assistantStatus,
|
||||
fetchAssistantStatus,
|
||||
configureAssistant,
|
||||
scheduledMessages,
|
||||
fetchScheduledMessages,
|
||||
scheduleMessage,
|
||||
cancelScheduledMessage,
|
||||
fetchBlockHeaders,
|
||||
relayTransaction,
|
||||
relayLightning,
|
||||
|
||||
+23
-21
@@ -8,6 +8,7 @@ import AnimatedLogo from '@/components/AnimatedLogo.vue'
|
||||
import MeshMap from '@/components/MeshMap.vue'
|
||||
import MeshBitcoinPanel from '@/views/mesh/MeshBitcoinPanel.vue'
|
||||
import MeshDeadmanPanel from '@/views/mesh/MeshDeadmanPanel.vue'
|
||||
import MeshAssistantPanel from '@/views/mesh/MeshAssistantPanel.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import '@/views/mesh/mesh-styles.css'
|
||||
|
||||
@@ -253,10 +254,10 @@ async function clearAllMesh() {
|
||||
}
|
||||
|
||||
// Phase 4: Off-grid Bitcoin + Dead Man's Switch
|
||||
const activeTab = ref<'chat' | 'bitcoin' | 'deadman' | 'map'>('chat')
|
||||
const activeTab = ref<'chat' | 'bitcoin' | 'deadman' | 'assistant' | 'map'>('chat')
|
||||
|
||||
// Tools tab for 3rd column on wide desktop and mobile below-chat
|
||||
const toolsTab = ref<'bitcoin' | 'deadman' | 'map'>('bitcoin')
|
||||
const toolsTab = ref<'bitcoin' | 'deadman' | 'assistant' | 'map'>('bitcoin')
|
||||
|
||||
// Panel visibility computeds
|
||||
const showChatPanel = computed(() =>
|
||||
@@ -274,6 +275,12 @@ const showDeadmanPanel = computed(() => {
|
||||
if (isMobile.value && !mobileShowChat.value) return toolsTab.value === 'deadman'
|
||||
return activeTab.value === 'deadman'
|
||||
})
|
||||
const showAssistantPanel = computed(() => {
|
||||
if (isVeryWideDesktop.value) return true
|
||||
if (isWideDesktop.value) return toolsTab.value === 'assistant'
|
||||
if (isMobile.value && !mobileShowChat.value) return toolsTab.value === 'assistant'
|
||||
return activeTab.value === 'assistant'
|
||||
})
|
||||
const showMapPanel = computed(() => {
|
||||
if (isVeryWideDesktop.value) return true
|
||||
if (isWideDesktop.value) return toolsTab.value === 'map'
|
||||
@@ -1478,17 +1485,16 @@ function isImageMime(mime?: string): boolean {
|
||||
<!-- Tab bar (medium desktop only) -->
|
||||
<div v-if="showTabBar" class="mesh-tab-bar">
|
||||
<button class="mesh-tab" :class="{ active: activeTab === 'chat' }" @click="activeTab = 'chat'">Chat</button>
|
||||
<button class="mesh-tab" :class="{ active: activeTab === 'bitcoin' }" @click="activeTab = 'bitcoin'">
|
||||
Off-Grid Bitcoin
|
||||
<span v-if="mesh.latestBlockHeight > 0" class="mesh-tab-badge">{{ mesh.latestBlockHeight }}</span>
|
||||
</button>
|
||||
<button class="mesh-tab" :class="{ active: activeTab === 'bitcoin' }" @click="activeTab = 'bitcoin'">Bitcoin</button>
|
||||
<button class="mesh-tab" :class="{ active: activeTab === 'deadman' }" @click="activeTab = 'deadman'">
|
||||
Dead Man
|
||||
<span v-if="mesh.deadmanStatus?.triggered" class="mesh-tab-badge mesh-tab-badge-alert">!</span>
|
||||
</button>
|
||||
<button class="mesh-tab" :class="{ active: activeTab === 'assistant' }" @click="activeTab = 'assistant'">
|
||||
AI
|
||||
</button>
|
||||
<button class="mesh-tab" :class="{ active: activeTab === 'map' }" @click="activeTab = 'map'">
|
||||
Map
|
||||
<span v-if="mesh.nodePositions.size > 0" class="mesh-tab-badge">{{ mesh.nodePositions.size }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1764,21 +1770,19 @@ function isImageMime(mime?: string): boolean {
|
||||
<!-- Tools panels (3rd column on wide screens) -->
|
||||
<div class="mesh-tools-wrapper" data-controller-zone="mesh-tools">
|
||||
<div v-if="isWideDesktop && !isVeryWideDesktop" class="mesh-tools-tab-bar">
|
||||
<button class="mesh-tab" :class="{ active: toolsTab === 'bitcoin' }" @click="toolsTab = 'bitcoin'">
|
||||
Off-Grid Bitcoin
|
||||
<span v-if="mesh.latestBlockHeight > 0" class="mesh-tab-badge">{{ mesh.latestBlockHeight }}</span>
|
||||
</button>
|
||||
<button class="mesh-tab" :class="{ active: toolsTab === 'bitcoin' }" @click="toolsTab = 'bitcoin'">Bitcoin</button>
|
||||
<button class="mesh-tab" :class="{ active: toolsTab === 'deadman' }" @click="toolsTab = 'deadman'">
|
||||
Dead Man
|
||||
<span v-if="mesh.deadmanStatus?.triggered" class="mesh-tab-badge mesh-tab-badge-alert">!</span>
|
||||
</button>
|
||||
<button class="mesh-tab" :class="{ active: toolsTab === 'map' }" @click="toolsTab = 'map'">
|
||||
Map
|
||||
<span v-if="mesh.nodePositions.size > 0" class="mesh-tab-badge">{{ mesh.nodePositions.size }}</span>
|
||||
<button class="mesh-tab" :class="{ active: toolsTab === 'assistant' }" @click="toolsTab = 'assistant'">
|
||||
AI
|
||||
</button>
|
||||
<button class="mesh-tab" :class="{ active: toolsTab === 'map' }" @click="toolsTab = 'map'">Map</button>
|
||||
</div>
|
||||
<MeshBitcoinPanel v-if="showBitcoinPanel" />
|
||||
<MeshDeadmanPanel v-if="showDeadmanPanel" />
|
||||
<MeshAssistantPanel v-if="showAssistantPanel" />
|
||||
<div v-if="showMapPanel" class="glass-card mesh-map-panel"><MeshMap /></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1786,22 +1790,20 @@ function isImageMime(mime?: string): boolean {
|
||||
<!-- Mobile tools: show under peers list on first view -->
|
||||
<div v-if="showMobileTools" class="mesh-mobile-tools">
|
||||
<div class="mesh-tools-tab-bar">
|
||||
<button class="mesh-tab" :class="{ active: toolsTab === 'bitcoin' }" @click="toolsTab = 'bitcoin'">
|
||||
Off-Grid Bitcoin
|
||||
<span v-if="mesh.latestBlockHeight > 0" class="mesh-tab-badge">{{ mesh.latestBlockHeight }}</span>
|
||||
</button>
|
||||
<button class="mesh-tab" :class="{ active: toolsTab === 'bitcoin' }" @click="toolsTab = 'bitcoin'">Bitcoin</button>
|
||||
<button class="mesh-tab" :class="{ active: toolsTab === 'deadman' }" @click="toolsTab = 'deadman'">
|
||||
Dead Man
|
||||
<span v-if="mesh.deadmanStatus?.triggered" class="mesh-tab-badge mesh-tab-badge-alert">!</span>
|
||||
</button>
|
||||
<button class="mesh-tab" :class="{ active: toolsTab === 'map' }" @click="toolsTab = 'map'">
|
||||
Map
|
||||
<span v-if="mesh.nodePositions.size > 0" class="mesh-tab-badge">{{ mesh.nodePositions.size }}</span>
|
||||
<button class="mesh-tab" :class="{ active: toolsTab === 'assistant' }" @click="toolsTab = 'assistant'">
|
||||
AI
|
||||
</button>
|
||||
<button class="mesh-tab" :class="{ active: toolsTab === 'map' }" @click="toolsTab = 'map'">Map</button>
|
||||
</div>
|
||||
<div v-if="showMapPanel" class="glass-card mesh-map-panel"><MeshMap /></div>
|
||||
<MeshBitcoinPanel v-if="showBitcoinPanel" />
|
||||
<MeshDeadmanPanel v-if="showDeadmanPanel" />
|
||||
<MeshAssistantPanel v-if="showAssistantPanel" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 <question></code> on the mesh channel.
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -47,9 +47,11 @@
|
||||
.mesh-columns-very-wide .mesh-tools-wrapper { display: grid; grid-template-rows: minmax(0, 1fr) minmax(0, 0.85fr) minmax(0, 1fr); gap: 12px; overflow: hidden; }
|
||||
.mesh-columns-very-wide .mesh-tools-wrapper .mesh-bitcoin-panel,
|
||||
.mesh-columns-very-wide .mesh-tools-wrapper .mesh-deadman-panel,
|
||||
.mesh-columns-very-wide .mesh-tools-wrapper .mesh-assistant-panel,
|
||||
.mesh-columns-very-wide .mesh-tools-wrapper .mesh-map-panel { min-height: 0; height: 100%; overflow: hidden; }
|
||||
.mesh-columns-wide:not(.mesh-columns-very-wide) .mesh-tools-wrapper .mesh-bitcoin-panel,
|
||||
.mesh-columns-wide:not(.mesh-columns-very-wide) .mesh-tools-wrapper .mesh-deadman-panel,
|
||||
.mesh-columns-wide:not(.mesh-columns-very-wide) .mesh-tools-wrapper .mesh-assistant-panel,
|
||||
.mesh-columns-wide:not(.mesh-columns-very-wide) .mesh-tools-wrapper .mesh-map-panel { flex: 1 1 auto; min-height: 0; height: auto; }
|
||||
.mesh-columns-very-wide .mesh-tools-tab-bar { display: none; }
|
||||
.mesh-columns-wide .mesh-mobile-back-btn,
|
||||
@@ -154,12 +156,14 @@
|
||||
.mesh-mobile-tools { margin-top: 12px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.mesh-mobile-tools .mesh-tools-tab-bar { display: flex; gap: 2px; background: rgba(0,0,0,0.3); border-radius: 10px; padding: 3px; }
|
||||
.mesh-mobile-tools :deep(.mesh-bitcoin-panel),
|
||||
.mesh-mobile-tools :deep(.mesh-assistant-panel),
|
||||
.mesh-mobile-tools :deep(.mesh-deadman-panel) { min-height: 320px; max-height: min(68dvh, 620px); overflow-y: auto; }
|
||||
.mesh-mobile-tools .mesh-map-panel { min-height: 360px; max-height: min(68dvh, 620px); overflow: hidden; }
|
||||
.mesh-status-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.mesh-chat-back { display: block; }
|
||||
.mobile-hidden { display: none !important; }
|
||||
:deep(.mesh-bitcoin-panel),
|
||||
:deep(.mesh-assistant-panel),
|
||||
:deep(.mesh-deadman-panel) { flex: none; cursor: pointer; flex-shrink: 0; }
|
||||
.mesh-mobile-back-btn:hover { color: rgba(255, 255, 255, 0.9); }
|
||||
}
|
||||
@@ -223,7 +227,11 @@
|
||||
|
||||
/* Bitcoin & Deadman panels (child components) */
|
||||
.mesh-bitcoin-panel,
|
||||
.mesh-deadman-panel { padding: 16px; display: flex; flex-direction: column; gap: 12px; flex: 1; min-height: 0; overflow-y: auto; }
|
||||
.mesh-deadman-panel,
|
||||
.mesh-assistant-panel { padding: 16px; display: flex; flex-direction: column; gap: 12px; flex: 1; min-height: 0; overflow-y: auto; }
|
||||
.mesh-assistant-field { display: flex; flex-direction: column; gap: 4px; }
|
||||
.mesh-assistant-install { padding: 12px; background: rgba(251,146,60,0.08); border: 1px solid rgba(251,146,60,0.25); border-radius: 10px; }
|
||||
.mesh-assistant-install-btn { display: inline-block; text-align: center; padding: 8px 14px; font-size: 0.8rem; }
|
||||
.mesh-panel-title { font-size: 1rem; font-weight: 700; color: rgba(255,255,255,0.95); margin: 0; }
|
||||
.mesh-panel-sub { font-size: 0.8rem; color: rgba(255,255,255,0.45); margin: -4px 0 0; }
|
||||
.mesh-bitcoin-section { display: flex; flex-direction: column; gap: 8px; }
|
||||
|
||||
Reference in New Issue
Block a user