fix: handle local spells and add comprehensive logging for spell tabs

- Parse command directly for local spells without event objects
- Add proper relay resolution for each parameter type:
  - $pubkey: Use NIP-65 outbox selection with fallback
  - $event: Use relay hints from target event with fallback
  - $relay: Use target relay directly
- Add extensive console logging throughout spell application pipeline
- Update applySpellParameters to accept Pick<ParsedSpell> for flexibility
- Display helpful error messages with console check hints
This commit is contained in:
Claude
2026-01-22 13:57:01 +00:00
parent ba9b441c75
commit 3ea7151a57
5 changed files with 443 additions and 70 deletions

View File

@@ -26,6 +26,8 @@ import { useUserParameterizedSpells } from "@/hooks/useParameterizedSpells";
import { EventFeed } from "./nostr/EventFeed";
import { useReqTimelineEnhanced } from "@/hooks/useReqTimelineEnhanced";
import { applySpellParameters, decodeSpell } from "@/lib/spell-conversion";
import { parseReqCommand } from "@/lib/req-parser";
import { AGGREGATOR_RELAYS } from "@/services/loaders";
export interface EventDetailViewerProps {
pointer: EventPointer | AddressPointer;
@@ -51,39 +53,149 @@ function SpellTabContent({
spellId,
spell,
targetEventId,
}: SpellTabContentProps) {
// Decode spell and apply parameters
const { appliedFilter, relays } = useMemo(() => {
if (!targetEventId || !spell.event) {
return { appliedFilter: null, relays: [] };
targetEvent,
}: SpellTabContentProps & { targetEvent: any }) {
// Parse spell and get filter - handle both published (with event) and local (command-only) spells
const parsed = useMemo(() => {
if (!targetEventId) {
console.log(`[EventSpell:${spell.name || spellId}] No target event ID`);
return null;
}
try {
const parsed = decodeSpell(spell.event);
const applied = applySpellParameters(parsed, [targetEventId]);
return {
appliedFilter: applied,
relays: parsed.relays || [],
console.log(`[EventSpell:${spell.name || spellId}] Parsing spell:`, {
hasEvent: !!spell.event,
command: spell.command,
parameterType: spell.parameterType,
});
// If we have a published event, decode it
if (spell.event) {
const decoded = decodeSpell(spell.event);
console.log(
`[EventSpell:${spell.name || spellId}] Decoded from event:`,
{
filter: decoded.filter,
relays: decoded.relays,
parameter: decoded.parameter,
},
);
return decoded;
}
// For local spells, parse the command directly
console.log(
`[EventSpell:${spell.name || spellId}] Parsing local spell command`,
);
const commandWithoutPrefix = spell.command
.replace(/^\s*(req|count)\s+/i, "")
.trim();
const tokens = commandWithoutPrefix.split(/\s+/);
const commandParsed = parseReqCommand(tokens);
// Create a ParsedSpell-like object for local spells
const localParsed = {
command: spell.command,
filter: commandParsed.filter,
relays: commandParsed.relays,
closeOnEose: commandParsed.closeOnEose,
parameter: spell.parameterType
? {
type: spell.parameterType,
default: spell.parameterDefault,
}
: undefined,
};
console.log(`[EventSpell:${spell.name || spellId}] Parsed local spell:`, {
filter: localParsed.filter,
relays: localParsed.relays,
parameter: localParsed.parameter,
});
return localParsed;
} catch (error) {
console.error("Failed to apply spell parameters:", error);
return { appliedFilter: null, relays: [] };
console.error(
`[EventSpell:${spell.name || spellId}] Failed to parse spell:`,
error,
);
return null;
}
}, [spell.event, targetEventId]);
}, [spell, targetEventId, spellId]);
// Apply parameters to get final filter
const appliedFilter = useMemo(() => {
if (!parsed || !targetEventId) return null;
try {
const applied = applySpellParameters(parsed, [targetEventId]);
console.log(`[EventSpell:${spell.name || spellId}] Applied parameters:`, {
input: targetEventId,
result: applied,
});
return applied;
} catch (error) {
console.error(
`[EventSpell:${spell.name || spellId}] Failed to apply parameters:`,
error,
);
return null;
}
}, [parsed, targetEventId, spell.name, spellId]);
// Resolve relays - use explicit relays from spell, or use relay hints from target event
const finalRelays = useMemo(() => {
// Use explicit relays from spell if provided
if (parsed?.relays && parsed.relays.length > 0) {
console.log(
`[EventSpell:${spell.name || spellId}] Using explicit relays:`,
parsed.relays,
);
return parsed.relays;
}
// Use relay hints from the target event
if (targetEvent) {
const seenRelaysSet = getSeenRelays(targetEvent);
if (seenRelaysSet && seenRelaysSet.size > 0) {
const eventRelays = Array.from(seenRelaysSet);
console.log(
`[EventSpell:${spell.name || spellId}] Using target event relays:`,
eventRelays,
);
return eventRelays;
}
}
// Fallback to aggregator relays
console.log(
`[EventSpell:${spell.name || spellId}] Using fallback AGGREGATOR_RELAYS`,
);
return AGGREGATOR_RELAYS;
}, [parsed?.relays, targetEvent, spell.name, spellId]);
// Fetch events using the applied filter
const { events, loading, eoseReceived } = appliedFilter
? useReqTimelineEnhanced(
`spell-${spellId}-${targetEventId}`,
appliedFilter,
relays,
{ limit: appliedFilter.limit || 50, stream: true },
)
: {
events: [],
loading: false,
eoseReceived: false,
};
const { events, loading, eoseReceived } =
appliedFilter && finalRelays.length > 0
? useReqTimelineEnhanced(
`spell-${spellId}-${targetEventId}`,
appliedFilter,
finalRelays,
{ limit: appliedFilter.limit || 50, stream: true },
)
: {
events: [],
loading: false,
eoseReceived: false,
};
console.log(`[EventSpell:${spell.name || spellId}] Render state:`, {
hasFilter: !!appliedFilter,
relayCount: finalRelays.length,
eventCount: events.length,
loading,
eoseReceived,
});
return (
<TabsContent value={spellId} className="flex-1 overflow-hidden m-0">
@@ -91,6 +203,7 @@ function SpellTabContent({
<div className="flex items-center justify-center h-full p-8 text-center text-muted-foreground">
<div>
<p className="text-sm">Unable to apply spell to this event</p>
<p className="text-xs mt-2">Check console for details</p>
</div>
</div>
) : (
@@ -296,6 +409,7 @@ export function EventDetailViewer({ pointer }: EventDetailViewerProps) {
spellId={spell.id}
spell={spell}
targetEventId={event.id}
targetEvent={event}
/>
))}
</Tabs>

View File

@@ -39,6 +39,9 @@ import { useUserParameterizedSpells } from "@/hooks/useParameterizedSpells";
import { EventFeed } from "./nostr/EventFeed";
import { useReqTimelineEnhanced } from "@/hooks/useReqTimelineEnhanced";
import { applySpellParameters, decodeSpell } from "@/lib/spell-conversion";
import { parseReqCommand } from "@/lib/req-parser";
import { useOutboxRelays } from "@/hooks/useOutboxRelays";
import { AGGREGATOR_RELAYS } from "@/services/loaders";
export interface ProfileViewerProps {
pubkey: string;
@@ -65,38 +68,178 @@ function SpellTabContent({
spell,
targetPubkey,
}: SpellTabContentProps) {
// Decode spell and apply parameters
const { appliedFilter, relays } = useMemo(() => {
if (!targetPubkey || !spell.event) {
return { appliedFilter: null, relays: [] };
const { state } = useGrimoire();
// Parse spell and get filter - handle both published (with event) and local (command-only) spells
const parsed = useMemo(() => {
if (!targetPubkey) {
console.log(
`[SpellTabContent:${spell.name || spellId}] No target pubkey`,
);
return null;
}
try {
const parsed = decodeSpell(spell.event);
const applied = applySpellParameters(parsed, [targetPubkey]);
return {
appliedFilter: applied,
relays: parsed.relays || [],
console.log(`[SpellTabContent:${spell.name || spellId}] Parsing spell:`, {
hasEvent: !!spell.event,
command: spell.command,
parameterType: spell.parameterType,
});
// If we have a published event, decode it
if (spell.event) {
const decoded = decodeSpell(spell.event);
console.log(
`[SpellTabContent:${spell.name || spellId}] Decoded from event:`,
{
filter: decoded.filter,
relays: decoded.relays,
parameter: decoded.parameter,
},
);
return decoded;
}
// For local spells, parse the command directly
console.log(
`[SpellTabContent:${spell.name || spellId}] Parsing local spell command`,
);
const commandWithoutPrefix = spell.command
.replace(/^\s*(req|count)\s+/i, "")
.trim();
const tokens = commandWithoutPrefix.split(/\s+/);
const commandParsed = parseReqCommand(tokens);
// Create a ParsedSpell-like object for local spells
const localParsed = {
command: spell.command,
filter: commandParsed.filter,
relays: commandParsed.relays,
closeOnEose: commandParsed.closeOnEose,
parameter: spell.parameterType
? {
type: spell.parameterType,
default: spell.parameterDefault,
}
: undefined,
};
console.log(
`[SpellTabContent:${spell.name || spellId}] Parsed local spell:`,
{
filter: localParsed.filter,
relays: localParsed.relays,
parameter: localParsed.parameter,
},
);
return localParsed;
} catch (error) {
console.error("Failed to apply spell parameters:", error);
return { appliedFilter: null, relays: [] };
console.error(
`[SpellTabContent:${spell.name || spellId}] Failed to parse spell:`,
error,
);
return null;
}
}, [spell.event, targetPubkey]);
}, [spell, targetPubkey, spellId]);
// Apply parameters to get final filter
const appliedFilter = useMemo(() => {
if (!parsed || !targetPubkey) return null;
try {
const applied = applySpellParameters(parsed, [targetPubkey]);
console.log(
`[SpellTabContent:${spell.name || spellId}] Applied parameters:`,
{
input: targetPubkey,
result: applied,
},
);
return applied;
} catch (error) {
console.error(
`[SpellTabContent:${spell.name || spellId}] Failed to apply parameters:`,
error,
);
return null;
}
}, [parsed, targetPubkey, spell.name, spellId]);
// Resolve relays - use explicit relays from spell, or use NIP-65 outbox selection
const fallbackRelays = useMemo(
() =>
state.activeAccount?.relays?.filter((r) => r.read).map((r) => r.url) ||
AGGREGATOR_RELAYS,
[state.activeAccount?.relays],
);
const outboxOptions = useMemo(
() => ({
fallbackRelays,
timeout: 1000,
maxRelays: 42,
}),
[fallbackRelays],
);
// Use outbox relay selection if no explicit relays provided in spell
const { relays: selectedRelays, phase: relaySelectionPhase } =
useOutboxRelays(appliedFilter || {}, outboxOptions);
const finalRelays = useMemo(() => {
// Use explicit relays from spell if provided
if (parsed?.relays && parsed.relays.length > 0) {
console.log(
`[SpellTabContent:${spell.name || spellId}] Using explicit relays:`,
parsed.relays,
);
return parsed.relays;
}
// Wait for outbox relay selection to complete
if (relaySelectionPhase !== "ready") {
console.log(
`[SpellTabContent:${spell.name || spellId}] Waiting for relay selection (phase: ${relaySelectionPhase})`,
);
return [];
}
console.log(
`[SpellTabContent:${spell.name || spellId}] Using outbox-selected relays:`,
selectedRelays,
);
return selectedRelays;
}, [
parsed?.relays,
relaySelectionPhase,
selectedRelays,
spell.name,
spellId,
]);
// Fetch events using the applied filter
const { events, loading, eoseReceived } = appliedFilter
? useReqTimelineEnhanced(
`spell-${spellId}-${targetPubkey}`,
appliedFilter,
relays,
{ limit: appliedFilter.limit || 50, stream: true },
)
: {
events: [],
loading: false,
eoseReceived: false,
};
const { events, loading, eoseReceived } =
appliedFilter && finalRelays.length > 0
? useReqTimelineEnhanced(
`spell-${spellId}-${targetPubkey}`,
appliedFilter,
finalRelays,
{ limit: appliedFilter.limit || 50, stream: true },
)
: {
events: [],
loading: false,
eoseReceived: false,
};
console.log(`[SpellTabContent:${spell.name || spellId}] Render state:`, {
hasFilter: !!appliedFilter,
relayCount: finalRelays.length,
eventCount: events.length,
loading,
eoseReceived,
});
return (
<TabsContent value={spellId} className="flex-1 overflow-hidden m-0">
@@ -104,6 +247,13 @@ function SpellTabContent({
<div className="flex items-center justify-center h-full p-8 text-center text-muted-foreground">
<div>
<p className="text-sm">Unable to apply spell to this profile</p>
<p className="text-xs mt-2">Check console for details</p>
</div>
</div>
) : finalRelays.length === 0 ? (
<div className="flex items-center justify-center h-full p-8 text-center text-muted-foreground">
<div>
<p className="text-sm">Selecting relays...</p>
</div>
</div>
) : (

View File

@@ -10,9 +10,8 @@ import { EventFeed } from "./nostr/EventFeed";
import { useReqTimelineEnhanced } from "@/hooks/useReqTimelineEnhanced";
import { applySpellParameters, decodeSpell } from "@/lib/spell-conversion";
import { parseReqCommand } from "@/lib/req-parser";
import { useMemo, useState } from "react";
import { KindBadge } from "./KindBadge";
import { CreateParameterizedSpellDialog } from "./CreateParameterizedSpellDialog";
import { useMemo } from "react";
import { NIPBadge } from "./NIPBadge";
import { SpellHeader } from "./timeline/SpellHeader";
export interface RelayViewerProps {
@@ -42,32 +41,119 @@ function SpellTabContent({
}: SpellTabContentProps) {
const { addWindow } = useGrimoire();
// Decode spell and apply parameters
const { appliedFilter, relays } = useMemo(() => {
if (!targetRelay || !spell.event) {
return { appliedFilter: null, relays: [] };
// Parse spell and get filter - handle both published (with event) and local (command-only) spells
const parsed = useMemo(() => {
if (!targetRelay) {
console.log(`[RelaySpell:${spell.name || spellId}] No target relay`);
return null;
}
try {
const parsed = decodeSpell(spell.event);
const applied = applySpellParameters(parsed, [targetRelay]);
return {
appliedFilter: applied,
relays: parsed.relays || [],
console.log(`[RelaySpell:${spell.name || spellId}] Parsing spell:`, {
hasEvent: !!spell.event,
command: spell.command,
parameterType: spell.parameterType,
});
// If we have a published event, decode it
if (spell.event) {
const decoded = decodeSpell(spell.event);
console.log(
`[RelaySpell:${spell.name || spellId}] Decoded from event:`,
{
filter: decoded.filter,
relays: decoded.relays,
parameter: decoded.parameter,
},
);
return decoded;
}
// For local spells, parse the command directly
console.log(
`[RelaySpell:${spell.name || spellId}] Parsing local spell command`,
);
const commandWithoutPrefix = spell.command
.replace(/^\s*(req|count)\s+/i, "")
.trim();
const tokens = commandWithoutPrefix.split(/\s+/);
const commandParsed = parseReqCommand(tokens);
// Create a ParsedSpell-like object for local spells
const localParsed = {
command: spell.command,
filter: commandParsed.filter,
relays: commandParsed.relays,
closeOnEose: commandParsed.closeOnEose,
parameter: spell.parameterType
? {
type: spell.parameterType,
default: spell.parameterDefault,
}
: undefined,
};
console.log(`[RelaySpell:${spell.name || spellId}] Parsed local spell:`, {
filter: localParsed.filter,
relays: localParsed.relays,
parameter: localParsed.parameter,
});
return localParsed;
} catch (error) {
console.error("Failed to apply spell parameters:", error);
return { appliedFilter: null, relays: [] };
console.error(
`[RelaySpell:${spell.name || spellId}] Failed to parse spell:`,
error,
);
return null;
}
}, [spell.event, targetRelay]);
}, [spell, targetRelay, spellId]);
// Apply parameters to get final filter
const appliedFilter = useMemo(() => {
if (!parsed || !targetRelay) return null;
try {
const applied = applySpellParameters(parsed, [targetRelay]);
console.log(`[RelaySpell:${spell.name || spellId}] Applied parameters:`, {
input: targetRelay,
result: applied,
});
return applied;
} catch (error) {
console.error(
`[RelaySpell:${spell.name || spellId}] Failed to apply parameters:`,
error,
);
return null;
}
}, [parsed, targetRelay, spell.name, spellId]);
// Resolve relays - for $relay spells, we query FROM the target relay itself
const finalRelays = useMemo(() => {
// Use explicit relays from spell if provided
if (parsed?.relays && parsed.relays.length > 0) {
console.log(
`[RelaySpell:${spell.name || spellId}] Using explicit relays:`,
parsed.relays,
);
return parsed.relays;
}
// For $relay spells, query FROM the target relay
console.log(`[RelaySpell:${spell.name || spellId}] Using target relay:`, [
targetRelay,
]);
return [targetRelay];
}, [parsed?.relays, targetRelay, spell.name, spellId]);
// Fetch events using the applied filter
const { events, loading, eoseReceived, relayStates, overallState } =
appliedFilter
appliedFilter && finalRelays.length > 0
? useReqTimelineEnhanced(
`spell-${spellId}-${targetRelay}`,
appliedFilter,
relays,
finalRelays,
{ limit: appliedFilter.limit || 50, stream: true },
)
: {
@@ -90,6 +176,14 @@ function SpellTabContent({
return map;
}, [relayStates]);
console.log(`[RelaySpell:${spell.name || spellId}] Render state:`, {
hasFilter: !!appliedFilter,
relayCount: finalRelays.length,
eventCount: events.length,
loading,
eoseReceived,
});
return (
<TabsContent
value={spellId}
@@ -99,6 +193,7 @@ function SpellTabContent({
<div className="flex items-center justify-center h-full p-8 text-center text-muted-foreground">
<div>
<p className="text-sm">Unable to apply spell to this relay</p>
<p className="text-xs mt-2">Check console for details</p>
</div>
</div>
) : (
@@ -109,7 +204,7 @@ function SpellTabContent({
loading={loading}
overallState={overallState}
events={events}
relays={relays}
relays={finalRelays}
filter={appliedFilter}
spellEvent={spell.event}
reqRelayStates={reqRelayStatesMap}

View File

@@ -105,8 +105,22 @@ export function useParameterizedSpells(
// Include spells with $me or $contacts that could be parameterized
if (!spell.parameterType) {
const cmd = spell.command.toLowerCase();
// Check if command uses $me or $contacts (single arg that can become $pubkey)
return cmd.includes("$me") || cmd.includes("$contacts");
// Check if command uses ONLY $me or ONLY $contacts (not both, not mixed with other pubkeys)
const hasMeOrContacts =
cmd.includes("$me") || cmd.includes("$contacts");
if (!hasMeOrContacts) return false;
// Make sure it doesn't use multiple different variables
const hasBothMeAndContacts =
cmd.includes("$me") && cmd.includes("$contacts");
if (hasBothMeAndContacts) return false; // Can't have both
// Check if it has hex pubkeys mixed with $me/$contacts
// This is a rough check - looking for hex strings in -a or #p tags
const hasHexPubkey = /(?:-a|#p)\s+[a-f0-9]{64}/.test(cmd);
if (hasHexPubkey) return false; // Mixed pubkeys, not eligible
return true;
}
return false;

View File

@@ -518,7 +518,7 @@ export function reconstructCommand(
* @returns Filter with parameters applied
*/
export function applySpellParameters(
parsed: ParsedSpell,
parsed: Pick<ParsedSpell, "filter" | "parameter">,
args: string[] = [],
): NostrFilter {
if (!parsed.parameter) {