Merge remote-tracking branch 'gitea-ai/gsd/phase-13-aiui-functional-conversational-node-control-and-content-surf'
Demo images / Build & push demo images (push) Successful in 3m14s
Demo images / Build & push demo images (push) Successful in 3m14s
This commit is contained in:
+233
-3
@@ -30,7 +30,17 @@
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- AIUI iframe — on mobile, leave room for close bar + tab bar at bottom -->
|
||||
<!-- AIUI iframe — on mobile, leave room for close bar + tab bar at bottom.
|
||||
No `sandbox` attribute: it was considered and rejected for this
|
||||
phase (AIUI-04, 13-RESEARCH.md Open Question 2). `allow-scripts`
|
||||
together with `allow-same-origin` is the well-known escape pattern,
|
||||
and dropping `allow-same-origin` moves AIUI to an opaque origin,
|
||||
breaking its storage and its origin-checked postMessage bridge — a
|
||||
change bigger than this phase budgeted. The enforced boundary
|
||||
instead is the /aiui/-scoped Content-Security-Policy (nginx) plus
|
||||
the node-side rate limit (G-B3, 13-12); the residual risk (a
|
||||
browser that ignores or partially enforces CSP) is named in
|
||||
13-AI-SPEC.md §6, not silently assumed away. -->
|
||||
<iframe
|
||||
v-if="aiuiUrl"
|
||||
ref="aiuiFrame"
|
||||
@@ -38,6 +48,7 @@
|
||||
:title="t('chat.aiAssistant')"
|
||||
class="chat-iframe chat-iframe-mobile"
|
||||
allow="microphone"
|
||||
referrerpolicy="no-referrer"
|
||||
style="background: transparent"
|
||||
/>
|
||||
|
||||
@@ -59,19 +70,67 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 13-08 (D-11): the destructive-tool confirmation dialog — trusted
|
||||
chrome, mounted as a SIBLING of the iframe, never inside it. The
|
||||
component Teleports to body with a full-screen backdrop, so it
|
||||
covers the whole viewport including the area over the iframe, and
|
||||
no ancestor transform can trap its position: fixed. Its text is
|
||||
node-authored, fetched by the ContextBroker over the page's own
|
||||
RPC session — nothing the iframe sends can open or resolve it. -->
|
||||
<ToolConfirmModal
|
||||
:show="!!toolConfirm"
|
||||
:description="toolConfirm?.description ?? ''"
|
||||
@approve="resolveToolConfirm(true)"
|
||||
@deny="resolveToolConfirm(false)"
|
||||
@dismiss="dismissToolConfirm"
|
||||
/>
|
||||
|
||||
<!-- A tool the operator asked for was blocked by an ungranted
|
||||
category. Trusted chrome, and Teleported to body for the same
|
||||
reason ToolConfirmModal is: a transformed ancestor would trap
|
||||
position:fixed. This only OFFERS the settings screen — it never
|
||||
changes a grant itself, so nothing the iframe or the model says
|
||||
can widen permissions. -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="permissionNeeded.length" class="chat-permission-offer" role="status">
|
||||
<p class="text-sm text-white/85">
|
||||
{{ t('chat.permissionNeeded', { categories: permissionNeededLabels }) }}
|
||||
</p>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<button class="chat-permission-btn" @click="openAISettings">
|
||||
{{ t('chat.openAISettings') }}
|
||||
</button>
|
||||
<button
|
||||
class="chat-permission-dismiss"
|
||||
:aria-label="t('common.dismiss')"
|
||||
@click="permissionNeeded = []"
|
||||
>
|
||||
<svg class="w-4 h-4" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onActivated, onBeforeUnmount, onDeactivated, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ref, computed, onActivated, onBeforeUnmount, onDeactivated, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ContextBroker } from '@/services/contextBroker'
|
||||
import ToolConfirmModal from '@/components/ToolConfirmModal.vue'
|
||||
import { AI_PERMISSION_CATEGORIES } from '@/stores/aiPermissions'
|
||||
import { IS_DEMO } from '@/composables/useDemoIntro'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const aiuiFrame = ref<HTMLIFrameElement | null>(null)
|
||||
const aiuiConnected = ref(false)
|
||||
// Belt-and-suspenders backstop (2026-07-30 live-testing follow-up): the
|
||||
@@ -109,6 +168,47 @@ const aiuiUrl = computed(() => {
|
||||
return ''
|
||||
})
|
||||
|
||||
// ⌘K → "Talk to AIUI about it" hands the typed text over as `?ask=`.
|
||||
//
|
||||
// It is delivered by postMessage, NOT by adding a query param to `aiuiUrl`.
|
||||
// That is deliberate: the comment on D14_FLAGS above explains that aiuiUrl must
|
||||
// have no reactive dependencies so the iframe `src` stays byte-identical and
|
||||
// AIUI survives a tab switch. Threading `ask` through the URL would rebuild the
|
||||
// src on every question and reload AIUI, discarding the conversation — the
|
||||
// exact opposite of what this feature is for.
|
||||
//
|
||||
// The ask is queued rather than sent directly, because the common case is
|
||||
// arriving from ⌘K on a cold Chat tab where the iframe has not handshaked yet.
|
||||
// `ready` flushes it.
|
||||
const pendingAsk = ref('')
|
||||
|
||||
function flushAsk() {
|
||||
const text = pendingAsk.value
|
||||
if (!text || !aiuiConnected.value) return
|
||||
const frame = aiuiFrame.value
|
||||
if (!frame?.contentWindow || !aiuiUrl.value) return
|
||||
let targetOrigin: string
|
||||
try {
|
||||
targetOrigin = new URL(aiuiUrl.value, window.location.origin).origin
|
||||
} catch { return }
|
||||
frame.contentWindow.postMessage({ type: 'chat:prefill', text }, targetOrigin)
|
||||
pendingAsk.value = ''
|
||||
// Drop ask/askedAt from the URL so a refresh or a back-nav does not re-ask.
|
||||
const { ask: _a, askedAt: _t, ...rest } = route.query
|
||||
router.replace({ path: route.path, query: rest })
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.query.askedAt,
|
||||
() => {
|
||||
const ask = route.query.ask
|
||||
if (!ask) return
|
||||
pendingAsk.value = String(ask)
|
||||
flushAsk()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function closeChat() {
|
||||
if (window.history.length > 1) {
|
||||
router.back()
|
||||
@@ -117,6 +217,73 @@ function closeChat() {
|
||||
}
|
||||
}
|
||||
|
||||
// 13-08 (D-11): the pending destructive-tool confirmation the trusted
|
||||
// chrome is currently showing. Set ONLY from the ContextBroker's
|
||||
// aiui:tool-confirm-request CustomEvent, whose payload is node-fetched
|
||||
// over the page's own RPC session — never from anything the iframe posts.
|
||||
const toolConfirm = ref<{ reqId: string; description: string } | null>(null)
|
||||
|
||||
function onToolConfirmRequest(e: Event) {
|
||||
const detail = (e as CustomEvent).detail as { reqId?: string; description?: string }
|
||||
if (!detail?.reqId || typeof detail.description !== 'string') return
|
||||
toolConfirm.value = { reqId: detail.reqId, description: detail.description }
|
||||
}
|
||||
|
||||
function resolveToolConfirm(approved: boolean) {
|
||||
const current = toolConfirm.value
|
||||
toolConfirm.value = null
|
||||
if (!current) return
|
||||
// The decision travels back to the broker (and from there to the node
|
||||
// over the authenticated RPC session) — never through the iframe.
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('aiui:tool-confirm-response', {
|
||||
detail: { reqId: current.reqId, approved },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function dismissToolConfirm() {
|
||||
// Closed without a decision: send nothing. The action stays pending on
|
||||
// the node until its own timeout declines it — never silently approved.
|
||||
toolConfirm.value = null
|
||||
}
|
||||
|
||||
function onToolConfirmExpired(e: Event) {
|
||||
// 13-08 on-device UAT: the node no longer holds this pending action
|
||||
// (timed out, or resolved elsewhere) — close the dialog rather than
|
||||
// leave the human an Approve button whose click can only be refused.
|
||||
const detail = (e as CustomEvent).detail as { reqId?: string }
|
||||
if (toolConfirm.value && detail?.reqId === toolConfirm.value.reqId) {
|
||||
toolConfirm.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// A tool the operator's question needed was refused because its category
|
||||
// is off. The node reports WHICH categories; we name them and offer the
|
||||
// screen that owns the toggles. Never flips a toggle here — the operator
|
||||
// decides, on the settings screen, in the trusted chrome.
|
||||
const permissionNeeded = ref<string[]>([])
|
||||
|
||||
const permissionNeededLabels = computed(() =>
|
||||
permissionNeeded.value
|
||||
.map((id) => AI_PERMISSION_CATEGORIES.find((c) => c.id === id)?.label ?? id)
|
||||
.join(', '),
|
||||
)
|
||||
|
||||
function onPermissionNeeded(e: Event) {
|
||||
const detail = (e as CustomEvent).detail as { categories?: unknown }
|
||||
const categories = Array.isArray(detail?.categories) ? detail.categories : []
|
||||
const known = categories.filter(
|
||||
(c): c is string => typeof c === 'string' && AI_PERMISSION_CATEGORIES.some((k) => k.id === c),
|
||||
)
|
||||
if (known.length) permissionNeeded.value = known
|
||||
}
|
||||
|
||||
function openAISettings() {
|
||||
permissionNeeded.value = []
|
||||
router.push({ path: '/dashboard/settings', hash: '#ai-data-access' })
|
||||
}
|
||||
|
||||
function onAiuiMessage(event: MessageEvent) {
|
||||
if (!aiuiUrl.value) return
|
||||
// Validate origin — only accept messages from AIUI
|
||||
@@ -128,6 +295,8 @@ function onAiuiMessage(event: MessageEvent) {
|
||||
if (event.data?.type === 'ready') {
|
||||
aiuiConnected.value = true
|
||||
if (loadTimeout) { clearTimeout(loadTimeout); loadTimeout = null }
|
||||
// A ⌘K ask that arrived before the handshake is waiting — send it now.
|
||||
flushAsk()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,6 +311,12 @@ function onAiuiMessage(event: MessageEvent) {
|
||||
function armChatLive() {
|
||||
window.removeEventListener('message', onAiuiMessage)
|
||||
window.addEventListener('message', onAiuiMessage)
|
||||
window.removeEventListener('aiui:tool-confirm-request', onToolConfirmRequest)
|
||||
window.addEventListener('aiui:tool-confirm-request', onToolConfirmRequest)
|
||||
window.removeEventListener('aiui:tool-confirm-expired', onToolConfirmExpired)
|
||||
window.addEventListener('aiui:tool-confirm-expired', onToolConfirmExpired)
|
||||
window.removeEventListener('aiui:permission-needed', onPermissionNeeded)
|
||||
window.addEventListener('aiui:permission-needed', onPermissionNeeded)
|
||||
broker?.stop()
|
||||
broker = null
|
||||
if (aiuiUrl.value) {
|
||||
@@ -161,6 +336,9 @@ onActivated(() => armChatLive())
|
||||
|
||||
onDeactivated(() => {
|
||||
window.removeEventListener('message', onAiuiMessage)
|
||||
window.removeEventListener('aiui:tool-confirm-request', onToolConfirmRequest)
|
||||
window.removeEventListener('aiui:tool-confirm-expired', onToolConfirmExpired)
|
||||
window.removeEventListener('aiui:permission-needed', onPermissionNeeded)
|
||||
broker?.stop()
|
||||
broker = null
|
||||
if (loadTimeout) { clearTimeout(loadTimeout); loadTimeout = null }
|
||||
@@ -174,6 +352,9 @@ onMounted(() => armChatLive())
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('message', onAiuiMessage)
|
||||
window.removeEventListener('aiui:tool-confirm-request', onToolConfirmRequest)
|
||||
window.removeEventListener('aiui:tool-confirm-expired', onToolConfirmExpired)
|
||||
window.removeEventListener('aiui:permission-needed', onPermissionNeeded)
|
||||
broker?.stop()
|
||||
broker = null
|
||||
if (loadTimeout) { clearTimeout(loadTimeout); loadTimeout = null }
|
||||
@@ -181,6 +362,55 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Teleported to body, so this is positioned against the viewport, not the
|
||||
chat panel. Sits above the iframe but below the confirm modal — a
|
||||
blocking decision must always win over a passive offer. */
|
||||
.chat-permission-offer {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
bottom: calc(1.25rem + var(--safe-bottom, 0px));
|
||||
z-index: 60;
|
||||
max-width: min(40rem, calc(100vw - 2rem));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 0.875rem;
|
||||
border-radius: 0.875rem;
|
||||
background: rgba(24, 24, 27, 0.92);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
backdrop-filter: blur(12px);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.chat-permission-btn {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
color: #fdba74;
|
||||
background: rgba(251, 146, 60, 0.14);
|
||||
border: 1px solid rgba(251, 146, 60, 0.3);
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.chat-permission-btn:hover {
|
||||
background: rgba(251, 146, 60, 0.24);
|
||||
}
|
||||
|
||||
.chat-permission-dismiss {
|
||||
padding: 0.375rem;
|
||||
border-radius: 0.5rem;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
transition: color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.chat-permission-dismiss:hover {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.chat-loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
@@ -1656,9 +1656,18 @@ const fetchedUrls = ref<Map<string, string>>(new Map())
|
||||
// `immediate: true` so already-loaded history gets the same treatment as
|
||||
// newly-arriving messages.
|
||||
const autoFetchedCids = new Set<string>()
|
||||
watch(
|
||||
() => chatMessages.value.length,
|
||||
() => {
|
||||
|
||||
// NOT `immediate: true`. This watcher calls handleFetchContent, whose body
|
||||
// touches consts declared further down the setup block — and an immediate
|
||||
// watcher runs DURING setup, before those exist. Vue surfaced it as
|
||||
// "ReferenceError: Cannot access 'b' before initialization" from
|
||||
// Ye.immediate, and it fired whenever history already contained an inline
|
||||
// content_ref, taking the whole Mesh view down with it.
|
||||
//
|
||||
// onMounted runs after setup completes, so every binding is initialized and
|
||||
// already-loaded history still gets the same treatment as new messages —
|
||||
// which is what `immediate` was there for.
|
||||
function autoFetchInlineContent() {
|
||||
for (const msg of chatMessages.value) {
|
||||
const payload = msg.typed_payload as { cid?: string; inline?: boolean } | undefined
|
||||
if (
|
||||
@@ -1673,9 +1682,10 @@ watch(
|
||||
void handleFetchContent(msg.typed_payload as any)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
}
|
||||
|
||||
watch(() => chatMessages.value.length, autoFetchInlineContent)
|
||||
onMounted(autoFetchInlineContent)
|
||||
|
||||
// Transport chooser modal state — populated when advice comes back as
|
||||
// "choose" (size fits both inline-over-mesh AND Tor). User picks a path;
|
||||
|
||||
@@ -13,9 +13,16 @@ import Chat from '../Chat.vue'
|
||||
|
||||
const routerBackMock = vi.fn()
|
||||
const routerPushMock = vi.fn()
|
||||
const routerReplaceMock = vi.fn()
|
||||
|
||||
// Chat reads route.query.ask/askedAt to receive a ⌘K "Talk to AIUI about it"
|
||||
// handoff, and route.path when it strips those params back off. Kept empty by
|
||||
// default so the byte-stability assertions below see no ask in play.
|
||||
const routeMock = { path: '/dashboard/chat', query: {} as Record<string, string> }
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ back: routerBackMock, push: routerPushMock }),
|
||||
useRouter: () => ({ back: routerBackMock, push: routerPushMock, replace: routerReplaceMock }),
|
||||
useRoute: () => routeMock,
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
@@ -62,6 +69,8 @@ describe('Chat / AIUI embed URL stability + D-14 defaults (02-07)', () => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
routeMock.query = {}
|
||||
routerReplaceMock.mockClear()
|
||||
})
|
||||
|
||||
it('carries embedded=true, hideClose=true, and both D-14 flags', () => {
|
||||
@@ -75,6 +84,56 @@ describe('Chat / AIUI embed URL stability + D-14 defaults (02-07)', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
// ⌘K → "Talk to AIUI about it" hands the typed text to AIUI. It must travel
|
||||
// by postMessage: putting it in the URL would give aiuiUrl a reactive
|
||||
// dependency and reload AIUI on every question, which is precisely the
|
||||
// byte-stability property the rest of this file exists to protect.
|
||||
it('delivers a ⌘K ask by postMessage on ready, leaving the iframe src untouched', async () => {
|
||||
routeMock.query = { ask: 'why is bitcoin syncing slowly', askedAt: '111' }
|
||||
const { wrapper } = mountChatInKeepAlive()
|
||||
const before = iframeSrc(wrapper)
|
||||
expect(before).not.toContain('ask=')
|
||||
|
||||
const frame = wrapper.find('iframe').element as HTMLIFrameElement
|
||||
const post = vi.fn()
|
||||
Object.defineProperty(frame, 'contentWindow', { configurable: true, value: { postMessage: post } })
|
||||
|
||||
window.dispatchEvent(new MessageEvent('message', {
|
||||
origin: 'http://localhost:5173',
|
||||
data: { type: 'ready' },
|
||||
}))
|
||||
await flushPromises()
|
||||
|
||||
expect(post).toHaveBeenCalledWith(
|
||||
{ type: 'chat:prefill', text: 'why is bitcoin syncing slowly' },
|
||||
'http://localhost:5173',
|
||||
)
|
||||
// src must be byte-identical after the ask round-trip
|
||||
expect(iframeSrc(wrapper)).toBe(before)
|
||||
// and the params are stripped so a refresh does not silently re-ask
|
||||
expect(routerReplaceMock).toHaveBeenCalled()
|
||||
const replaceArg = routerReplaceMock.mock.calls[0]![0]
|
||||
expect(replaceArg.query.ask).toBeUndefined()
|
||||
expect(replaceArg.query.askedAt).toBeUndefined()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not post a prefill when there is no ask in the route', async () => {
|
||||
const { wrapper } = mountChatInKeepAlive()
|
||||
const frame = wrapper.find('iframe').element as HTMLIFrameElement
|
||||
const post = vi.fn()
|
||||
Object.defineProperty(frame, 'contentWindow', { configurable: true, value: { postMessage: post } })
|
||||
|
||||
window.dispatchEvent(new MessageEvent('message', {
|
||||
origin: 'http://localhost:5173',
|
||||
data: { type: 'ready' },
|
||||
}))
|
||||
await flushPromises()
|
||||
|
||||
expect(post).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('is string-equal before and after a simulated viewport resize across the mobile breakpoint', async () => {
|
||||
const { wrapper } = mountChatInKeepAlive()
|
||||
const before = iframeSrc(wrapper)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { PackageState, type PackageDataEntry } from '@/types/api'
|
||||
import { canLaunch, filterEntriesForTab, hasFrontendUi, isServiceContainer, isServicePackage, isWebsitePackage, launchBlockedReason, resolveAppIcon, useCategoriesWithApps } from '../appsConfig'
|
||||
import { canLaunch, filterEntriesForTab, hasFrontendUi, isServiceContainer, isServicePackage, isWebsitePackage, launchBlockedReason, resolveAppIcon, useCategoriesWithApps, DEFAULT_APP_ICON } from '../appsConfig'
|
||||
|
||||
function makePkg(id: string, title: string, category: string): PackageDataEntry {
|
||||
return {
|
||||
@@ -82,6 +82,13 @@ describe('appsConfig service filtering', () => {
|
||||
expect(resolveAppIcon('gitea', pkg)).toBe('/assets/img/app-icons/gitea.svg')
|
||||
})
|
||||
|
||||
it('an unmapped id gets the A mark, not a guessed png that 404s', () => {
|
||||
// strfry 404'd live on 2026-08-07: no curated entry, no fallback entry,
|
||||
// no service prefix — the old `${id}.png` guess produced a broken tile.
|
||||
const pkg = makePkg('strfry', 'strfry', 'nostr')
|
||||
expect(resolveAppIcon('strfry', pkg)).toBe(DEFAULT_APP_ICON)
|
||||
})
|
||||
|
||||
it('classifies an unknown app by whether its manifest declares a UI (#45)', () => {
|
||||
// Headless: a LAN address but no declared UI → Website.
|
||||
const headless = makePkg('some-backend', 'Some Backend', 'other')
|
||||
|
||||
@@ -249,7 +249,9 @@ export function resolveAppIcon(id: string, pkg: PackageDataEntry, curatedIcon?:
|
||||
curatedIcon ||
|
||||
APP_ICON_FALLBACKS[id] ||
|
||||
serviceParentIcon(id) ||
|
||||
`/assets/img/app-icons/${id}.png`
|
||||
// Never guess `${id}.png` — an unmapped id 404s (strfry did, 2026-08-07).
|
||||
// The A mark is the honest unknown-app tile.
|
||||
DEFAULT_APP_ICON
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAIPermissionsStore, AI_PERMISSION_CATEGORIES } from '@/stores/aiPermissions'
|
||||
import ToggleSwitch from '@/components/ToggleSwitch.vue'
|
||||
@@ -7,6 +7,12 @@ import ToggleSwitch from '@/components/ToggleSwitch.vue'
|
||||
const { t } = useI18n()
|
||||
const aiPermissions = useAIPermissionsStore()
|
||||
|
||||
// Grants live on the node, not in this browser's localStorage — reconcile on
|
||||
// open so the switches show the node's truth rather than whatever this origin
|
||||
// happens to remember. Without this the same node shows different settings at
|
||||
// its LAN address and its Tailscale address.
|
||||
onMounted(() => { void aiPermissions.hydrate() })
|
||||
|
||||
const aiCategoryGroups = computed(() => {
|
||||
const groups: { label: string; items: typeof AI_PERMISSION_CATEGORIES }[] = []
|
||||
for (const cat of AI_PERMISSION_CATEGORIES) {
|
||||
@@ -22,8 +28,8 @@ const aiCategoryGroups = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- AI Data Access Section -->
|
||||
<div class="glass-card px-6 py-6 mb-6">
|
||||
<!-- AI Data Access Section — id is the banner's #ai-data-access hash target -->
|
||||
<div id="ai-data-access" class="glass-card px-6 py-6 mb-6 scroll-mt-4">
|
||||
<div class="mb-2">
|
||||
<h2 class="text-xl font-semibold text-white/96">{{ t('settings.aiDataAccess') }}</h2>
|
||||
</div>
|
||||
|
||||
@@ -36,12 +36,28 @@ let poll: ReturnType<typeof setInterval> | null = null
|
||||
///
|
||||
/// Bounded rather than a plain flag, so a request the node accepted but never
|
||||
/// acted on stops polling instead of hammering it forever.
|
||||
let awaitUntil = 0
|
||||
const awaitUntil = ref(0)
|
||||
const AWAIT_START_MS = 120_000
|
||||
|
||||
const rotation = computed<LndRotationProgress | null>(() => status.value?.rotation ?? null)
|
||||
const isRunning = computed(() => rotation.value?.running === true)
|
||||
|
||||
/// Ticks while a rotation is being awaited, so `rotationInFlight` re-evaluates
|
||||
/// as the await window expires instead of holding a stale value until the next
|
||||
/// poll happens to touch a reactive dependency.
|
||||
const now = ref(Date.now())
|
||||
|
||||
/// Is a rotation happening, INCLUDING the gap between asking for one and the
|
||||
/// node reporting it?
|
||||
///
|
||||
/// Rotation restarts LND, so `status.installed` goes false for a moment
|
||||
/// mid-rotation. Read literally that says "Lightning is not set up on this
|
||||
/// node" — which the screen then told the operator, seconds after they
|
||||
/// rotated, on a node with a working Lightning wallet. The container being
|
||||
/// briefly absent is what rotating LOOKS like, not evidence it was never
|
||||
/// there.
|
||||
const rotationInFlight = computed(() => isRunning.value || now.value < awaitUntil.value)
|
||||
|
||||
/** A finished rotation, successful or not. `ok` is null while running. */
|
||||
const finished = computed(
|
||||
() => rotation.value !== null && !rotation.value.running && rotation.value.ok !== null,
|
||||
@@ -68,8 +84,9 @@ async function load() {
|
||||
/// waking the node every few seconds.
|
||||
function syncPolling() {
|
||||
const running = status.value?.rotation.running === true
|
||||
if (running) awaitUntil = 0
|
||||
if (running || Date.now() < awaitUntil) startPolling()
|
||||
if (running) awaitUntil.value = 0
|
||||
now.value = Date.now()
|
||||
if (running || Date.now() < awaitUntil.value) startPolling()
|
||||
else stopPolling()
|
||||
}
|
||||
|
||||
@@ -103,7 +120,8 @@ async function rotate() {
|
||||
try {
|
||||
await rpcClient.lndRotateMacaroons(password.value)
|
||||
closeConfirm()
|
||||
awaitUntil = Date.now() + AWAIT_START_MS
|
||||
awaitUntil.value = Date.now() + AWAIT_START_MS
|
||||
now.value = Date.now()
|
||||
startPolling()
|
||||
await load()
|
||||
} catch (e) {
|
||||
@@ -155,8 +173,16 @@ onUnmounted(stopPolling)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mb-6">
|
||||
<h3 class="text-base font-medium text-white/90 mb-1">Lightning credentials</h3>
|
||||
<div class="glass-card px-6 py-6 mb-6">
|
||||
<!-- glass-card, like every other Settings section (AccountSection,
|
||||
AIDataAccessSection, NodeCertificateSection, BackupSection …). This
|
||||
rendered as bare text on the Settings page twice, because a new
|
||||
section carries its own wrapper and nothing about adding it to
|
||||
SystemSection.vue's list reminds you. Heading is h2/text-xl to match
|
||||
those siblings. Kept INSIDE the root: a leading comment makes the
|
||||
component a fragment, which drops the root class and breaks attribute
|
||||
inheritance. -->
|
||||
<h2 class="text-xl font-semibold text-white/96 mb-1">Lightning credentials</h2>
|
||||
<p class="text-sm text-white/60 mb-4">
|
||||
Wallet apps like Zeus connect to this node using a Lightning credential — a
|
||||
token that lets them spend. Rotating replaces every one of them, so anything
|
||||
@@ -173,14 +199,29 @@ onUnmounted(stopPolling)
|
||||
Could not read the Lightning credential state: {{ loadError }}
|
||||
</div>
|
||||
|
||||
<!-- `&& !rotationInFlight`: rotating restarts LND, so `installed` reads
|
||||
false for a moment mid-rotation. Without the guard this told the
|
||||
operator "Lightning is not set up on this node yet" seconds after they
|
||||
rotated on a node with a working wallet — and it replaced the progress
|
||||
they were watching. A container briefly absent is what rotating looks
|
||||
like, not proof Lightning was never installed. -->
|
||||
<div
|
||||
v-else-if="!status?.installed"
|
||||
v-else-if="!status?.installed && !rotationInFlight"
|
||||
class="p-3 bg-white/5 border border-white/10 rounded-lg text-sm text-white/70"
|
||||
>
|
||||
Lightning is not set up on this node yet, so there are no credentials to
|
||||
rotate. Install the Lightning app first.
|
||||
</div>
|
||||
|
||||
<!-- Mid-rotation with no status to render yet: say what is happening
|
||||
rather than falling through to the details block with empty fields. -->
|
||||
<div
|
||||
v-else-if="!status?.installed"
|
||||
class="p-3 bg-white/5 border border-white/10 rounded-lg text-sm text-white/70"
|
||||
>
|
||||
Rotating credentials — Lightning is restarting. This takes a moment.
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<!-- What exists right now -->
|
||||
<dl class="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
|
||||
|
||||
@@ -52,9 +52,12 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mb-6">
|
||||
<h3 class="text-base font-medium text-white/90 mb-1">Node certificate</h3>
|
||||
<p class="text-sm text-white/60 mb-4">
|
||||
<!-- Node Certificate Section -->
|
||||
<div class="glass-card px-6 py-6 mb-6">
|
||||
<div class="mb-2">
|
||||
<h2 class="text-xl font-semibold text-white/96">Node certificate</h2>
|
||||
</div>
|
||||
<p class="text-sm text-white/60 mb-6">
|
||||
Install this node's certificate on a device and it stops warning you about
|
||||
this node — on every port, not just the dashboard. Apps that open inside
|
||||
the dashboard need this: a certificate warning cannot be accepted inside an
|
||||
|
||||
@@ -289,4 +289,65 @@ describe('LightningCredentialsSection', () => {
|
||||
await flushPromises()
|
||||
expect(vi.mocked(rpcClient.lndMacaroonStatus).mock.calls.length).toBe(callsAfterLoad)
|
||||
})
|
||||
|
||||
it('renders inside a card, like every other Settings section', () => {
|
||||
// Operator-reported twice: the section rendered as bare text on the
|
||||
// Settings page. A new section carries its own wrapper, and nothing about
|
||||
// adding it to SystemSection.vue's list reminds you it needs one.
|
||||
// `wrapper.element` is not the div: the confirm modal is a second root
|
||||
// node, so the component is a fragment. Assert on the first div.
|
||||
const wrapper = mountSection()
|
||||
expect(wrapper.find('div').classes()).toContain('glass-card')
|
||||
})
|
||||
|
||||
it('does not claim Lightning is missing while a rotation is running', async () => {
|
||||
// Rotation restarts LND, so `installed` goes false for a moment. The
|
||||
// screen used to read that literally and tell the operator "Lightning is
|
||||
// not set up on this node yet" — seconds after they rotated, on a node
|
||||
// with a working wallet — replacing the progress they were watching.
|
||||
vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue(
|
||||
status({ installed: false, rotation: { ...idleRotation(), running: true } }),
|
||||
)
|
||||
|
||||
const wrapper = mountSection()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).not.toContain('Lightning is not set up on this node yet')
|
||||
expect(wrapper.text()).toContain('Lightning is restarting')
|
||||
})
|
||||
|
||||
it('still tells a node with no Lightning that there is nothing to rotate', async () => {
|
||||
// The other half: the message must survive for its real audience, or the
|
||||
// fix above has just hidden a true statement.
|
||||
vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue(status({ installed: false }))
|
||||
|
||||
const wrapper = mountSection()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Lightning is not set up on this node yet')
|
||||
})
|
||||
|
||||
it('does not claim Lightning is missing in the gap before the node reports the rotation', async () => {
|
||||
// The window `awaitUntil` exists for: the rotate RPC has been accepted but
|
||||
// the node has not yet reported `running: true`. `installed` can already be
|
||||
// false there, so the guard has to cover the await window too, not just
|
||||
// `running`.
|
||||
vi.mocked(rpcClient.lndMacaroonStatus)
|
||||
.mockResolvedValueOnce(status())
|
||||
.mockResolvedValue(status({ installed: false }))
|
||||
vi.mocked(rpcClient.lndRotateMacaroons).mockResolvedValue(undefined as never)
|
||||
|
||||
const wrapper = mountSection()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('button').trigger('click')
|
||||
await flushPromises()
|
||||
const confirm = wrapper.findAll('button').find((b) => /rotate/i.test(b.text()))
|
||||
if (confirm) {
|
||||
await confirm.trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
expect(wrapper.text()).not.toContain('Lightning is not set up on this node yet')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user