Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/api/narrator/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ export async function POST(request: NextRequest) {
const tone = toneInstructions[world.narrator_tone ?? "enigmatic"] ?? toneInstructions["enigmatic"]

const loreInput = typeof body.lore === "string" ? body.lore.trim() : ""
const recentNarratives = await getRecentNarrativesForCollection(collectionId, 2)
const recentNarratives = await getRecentNarrativesForCollection(collectionId, 5)
const previousContext =
recentNarratives.length > 0
? `\n\nPrevious narrative connections in this world:\n${recentNarratives.map((n, i) => `${i + 1}. ${n.narrative}`).join("\n")}`
Expand Down
17 changes: 2 additions & 15 deletions app/api/world/narrative/[token_id]/route.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,7 @@
import { NextRequest, NextResponse } from "next/server"
import { getNarrativeForTokenCached } from "@/lib/narrative-world-store"
import { getNarrativeForTokenCached } from "/lib/narrative-world-store"

export const runtime = "nodejs"
export const dynamic = "force-dynamic"

export async function GET(
request: NextRequest,
context: { params: Promise<{ token_id: string }> },
) {
const { token_id } = await context.params
const tokenId = Number(token_id)
if (!Number.isInteger(tokenId) || tokenId <= 0) {
return NextResponse.json({ error: "token_id inválido" }, { status: 400 })
}

const lang = request.nextUrl.searchParams.get("lang")?.trim() || "en"
const narrative = await getNarrativeForTokenCached(tokenId, lang)
return NextResponse.json({ narrative })
}
export async function GET(\n request: NextRequest,\n context: { params: Promise< { token_id: string }> },\n) {\n const { token_id } = await context.params\n const tokenId = Number(token_id)\n if (!Number.isInteger(tokenId) || tokenId <= 0) {\n return NextResponse.json({ error: "token_id inválido" }, { status: 400 })\n }\n\n const lang = request.nextUrl.searchParams.get("lang")?.trim() || "en"\n const toneParam = request.nextUrl.searchParams.get("tone")?.trim() || "enIGMATIC"\n const tone: Tone = VALID_TONES.includes(toneParam as Tone) ? (toneParam as Tone) : "enigmatic"\n\n const narrative = await getNarrativeForTokenCached(tokenId, { lang, tone })\n return NextResponse.json({ narrative })\n}\n"}
65 changes: 38 additions & 27 deletions app/world/[collection_id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,38 @@ type WorldNFT = {
narrativeTimestamp: number | null
}

function StoryTimelineVisualizer({
narratives,
}: {
narratives: Array<WorldNFT & { narrative: string }>
}) {
const sorted = [...narratives].sort(
(a, b) => (a.narrativeTimestamp ?? 0) - (b.narrativeTimestamp ?? 0),
)

return (
<ol className="relative space-y-6 border-l border-violet-400/30 pl-6">
{sorted.map((nft) => (
<li key={nft.tokenId} className="relative">
<span className="absolute -left-[31px] top-1 h-2.5 w-2.5 rounded-full border border-violet-400/60 bg-violet-950" />
<span className="mb-1 block text-[9px] uppercase tracking-widest text-violet-500/70">
TOKEN_#{nft.tokenId} ·{" "}
{nft.narrativeTimestamp
? `${new Date(nft.narrativeTimestamp)
.toISOString()
.replace("T", " ")
.slice(0, 19)} UTC`
: "UNKNOWN_TIME"}
</span>
<p className="text-[12px] italic leading-relaxed text-violet-200/80">
{nft.narrative}
</p>
</li>
))}
</ol>
)
}

export default async function WorldGalleryPage({ params }: Props) {
const { collection_id } = await params
const collectionId = parseInt(collection_id, 10)
Expand Down Expand Up @@ -258,39 +290,18 @@ export default async function WorldGalleryPage({ params }: Props) {
{/* ── Section 3: Narrator feed ── */}
<div className="px-6 py-6 md:px-8">
<h2 className="mb-5 text-[11px] font-bold uppercase tracking-[0.28em] text-violet-400">
◈ [ NARRATOR_FEED ]
◈ [ NARRATIVE_TIMELINE ]
</h2>
{nfts.filter((n) => n.narrative !== null).length === 0 ? (
<p className="py-10 text-center text-[10px] uppercase tracking-widest text-violet-500/50">
[ AWAITING_FIRST_MINT ]
</p>
) : (
<ol className="flex flex-col divide-y divide-violet-400/10">
{nfts
.filter((n): n is WorldNFT & { narrative: string } => n.narrative !== null)
.sort((a, b) => (b.narrativeTimestamp ?? 0) - (a.narrativeTimestamp ?? 0))
.map((nft) => (
<li key={nft.tokenId} className="flex flex-col gap-2 py-4 first:pt-0 last:pb-0">
<div className="flex flex-wrap items-center gap-3">
{nft.narrativeTimestamp && (
<span className="text-[9px] uppercase tracking-widest text-violet-500/60">
{new Date(nft.narrativeTimestamp)
.toISOString()
.replace("T", " ")
.slice(0, 19)}{" "}
UTC
</span>
)}
<span className="border border-violet-500/30 bg-violet-950/40 px-1.5 py-0.5 text-[8px] uppercase tracking-widest text-violet-400">
TOKEN_#{nft.tokenId}
</span>
</div>
<p className="text-[12px] italic leading-relaxed text-violet-200/80">
{nft.narrative}
</p>
</li>
))}
</ol>
<StoryTimelineVisualizer
narratives={nfts.filter(
(n): n is WorldNFT & { narrative: string } => n.narrative !== null,
)}
/>
)}
</div>

Expand Down
68 changes: 68 additions & 0 deletions app/world/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@ const copy = {
lastNarrative: "LAST NARRATIVE",
none: "—",
artifactsLabel: "ARTIFACTS",
timelineTitle: "◈ [ STORY_TIMELINE ]",
timelineLoading: "[ LOADING_TIMELINE ]",
timelineEmpty: "[ NO_LORE_NODES ]",
timelineNode: "LORE_NODE",
},
es: {
title: "◈ WORLD_STUDIO",
Expand Down Expand Up @@ -155,6 +159,10 @@ const copy = {
lastNarrative: "ÚLTIMA NARRATIVA",
none: "—",
artifactsLabel: "ARTEFACTOS",
timelineTitle: "◈ [ LÍNEA_TEMPORAL ]",
timelineLoading: "[ CARGANDO_LÍNEA ]",
timelineEmpty: "[ SIN_NODOS_DE_LORE ]",
timelineNode: "NODO_DE_LORE",
},
} as const

Expand All @@ -181,6 +189,12 @@ type WorldsApiResponse = {
globalStats: WorldsGlobalStats
}

type StoryTimelineNode = {
token_id: number
narrative: string
created_at: number
}

// ── Main page component ────────────────────────────────────────────────────────
function WorldStudioInner() {
const { lang } = useLang()
Expand Down Expand Up @@ -460,6 +474,60 @@ function WorldCard({ world, t }: { world: WorldsListItem; t: CopyT }) {
)
}

export function StoryTimeline({
nodes = [],
loading = false,
limit = 50,
t,
}: {
nodes?: StoryTimelineNode[]
loading?: boolean
limit?: number
t: CopyT
}) {
if (loading) {
return (
<div className="py-6 text-center text-[9px] uppercase tracking-widest text-violet-500/40">
{t.timelineLoading}
</div>
)
}

if (nodes.length === 0) {
return (
<div className="py-6 text-center text-[9px] uppercase tracking-widest text-violet-500/40">
{t.timelineEmpty}
</div>
)
}

const sorted = [...nodes].sort((a, b) => Number(a.created_at) - Number(b.created_at))
const visible = limit > 0 ? sorted.slice(-limit) : sorted

return (
<div className="mt-4 space-y-3 border-t border-violet-500/20 pt-3">
<p className="text-[9px] font-bold uppercase tracking-[0.22em] text-violet-500/60">
{t.timelineTitle}
</p>
<ol className="relative ml-1 flex flex-col gap-4 border-l border-violet-500/30 pl-4">
{visible.map((node) => (
<li key={node.token_id} className="relative">
<span className="absolute -left-[21px] top-1.5 h-2 w-2 rotate-45 border border-violet-400 bg-violet-950" />
<div className="flex flex-wrap items-center gap-2">
<span className="border border-violet-500/30 bg-violet-950/40 px-1.5 py-0.5 text-[8px] uppercase tracking-widest text-violet-400">
{t.timelineNode} #{node.token_id}
</span>
</div>
<p className="mt-1.5 text-[12px] italic leading-relaxed text-violet-200/75">
{node.narrative}
</p>
</li>
))}
</ol>
</div>
)
}

// ── Tab 2: CREATE ──────────────────────────────────────────────────────────────
function CreateTab({
worlds,
Expand Down
73 changes: 21 additions & 52 deletions lib/lore-versioning.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
/**
* SPIKE: lore versioning with word-level diffing — phase-106
*
*
* Proof-of-concept only, scoped to this SPIKE's acceptance criteria. Today
* `saveNarrativeForToken` overwrites the prior narrative with no history —
* edits to a token's lore are destructive. This module adds an additive
* version-history sidecar and a lightweight word-level diff so authors can
* see what changed between two narrative versions.
*
*
* "Semantic diffing" here means diffing at the token/word level (so the
* output reads as meaningful phrase-level changes) rather than a raw
* character diff — it is not NLP/embedding-based meaning comparison. A
* character diff — it is not NLP\/embedding-based meaning comparison. A
* fuller semantic-embedding diff would need its own design doc and is out
* of scope for this spike.
*
Expand Down Expand Up @@ -84,54 +84,23 @@ export type WordDiffOp = { op: "equal" | "add" | "remove"; words: string[] }
*/
export function diffNarrativeText(from: string, to: string): WordDiffOp[] {
const a = from.split(/\s+/).filter(Boolean)
const b = to.split(/\s+/).filter(Boolean)
const n = a.length
const m = b.length

const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0))
for (let i = n - 1; i >= 0; i--) {
for (let j = m - 1; j >= 0; j--) {
lcs[i]![j] = a[i] === b[j] ? lcs[i + 1]![j + 1]! + 1 : Math.max(lcs[i + 1]![j]!, lcs[i]![j + 1]!)
}
}

const ops: WordDiffOp[] = []
const pushWord = (op: WordDiffOp["op"], word: string) => {
const last = ops[ops.length - 1]
if (last && last.op === op) last.words.push(word)
else ops.push({ op, words: [word] })
}

let i = 0
let j = 0
while (i < n && j < m) {
if (a[i] === b[j]) {
pushWord("equal", a[i]!)
i++
j++
} else if (lcs[i + 1]![j]! >= lcs[i]![j + 1]!) {
pushWord("remove", a[i]!)
i++
} else {
pushWord("add", b[j]!)
j++
}
}
while (i < n) pushWord("remove", a[i++]!)
while (j < m) pushWord("add", b[j++]!)

return ops
const b = to.split(/\s+/).subt)*// running over large collections.
// Keep the most recent 50 arcs per collection to bound storage; 5 is enough
// for the prompt context, but keep 50 for timeline visualization.
store[key] = [...existing, entry].slice(-50)
await writeArcStore(store)
return entry
}

/** Diffs two recorded versions for a token. Returns null if either version is missing. */
export async function diffLoreVersions(
tokenId: number,
fromVersion: number,
toVersion: number,
): Promise<{ from: LoreVersionEntry; to: LoreVersionEntry; diff: WordDiffOp[] } | null> {
const versions = await getLoreVersions(tokenId)
const from = versions.find((v) => v.version === fromVersion)
const to = versions.find((v) => v.version === toVersion)
if (!from || !to) return null
return { from, to, diff: diffNarrativeText(from.narrative, to.narrative) }
}
/**
* Returns the most recent narrative arcs for a collection, newest last.
* If `limit` provided, returns at most that many entries (from the tail).
*/
export async function getNarrativeArc(
collectionId: string,
limit?: number,
): Promise<NarrativeArcEntry[]> {
const store = await readArcStore()
const arcs = store[String(collectionId)] ?? []
return limit ? arcs.slice(-limit) : arcs
}