diff --git a/apps/noter/app/api/memory/remember-one/route.ts b/apps/noter/app/api/memory/remember-one/route.ts new file mode 100644 index 00000000..8126455a --- /dev/null +++ b/apps/noter/app/api/memory/remember-one/route.ts @@ -0,0 +1,74 @@ +/** + * Memory Remember One API — saves a single approved memory text to MemWal. + * Used by the memory panel's "Approve" flow (individual memory saves). + * Returns { id, blob_id, owner, namespace } for blockchain data display. + */ + +import { rememberText } from "@/feature/note/lib/pdw-client"; +import { db } from "@/shared/lib/db"; +import { zkLoginSessions, walletSessions, users } from "@/shared/db/schema"; +import { eq } from "drizzle-orm"; + +async function resolveUserKey(req: Request) { + const sessionId = req.headers.get("x-session-id"); + if (!sessionId) return { key: null, accountId: null }; + + const [session] = await db + .select() + .from(walletSessions) + .where(eq(walletSessions.id, sessionId)) + .limit(1); + + if (!session?.userId || session.expiresAt < new Date()) { + const [zkSession] = await db + .select() + .from(zkLoginSessions) + .where(eq(zkLoginSessions.id, sessionId)) + .limit(1); + if (!zkSession?.userId || zkSession.expiresAt < new Date()) { + return { key: null, accountId: null }; + } + const [user] = await db.select().from(users).where(eq(users.id, zkSession.userId)).limit(1); + return { + key: user?.delegatePrivateKey ?? null, + accountId: user?.delegateAccountId ?? null, + }; + } + + const [user] = await db.select().from(users).where(eq(users.id, session.userId)).limit(1); + return { + key: user?.delegatePrivateKey ?? null, + accountId: user?.delegateAccountId ?? null, + }; +} + +export async function POST(req: Request) { + try { + const { text } = await req.json(); + + if (!text || typeof text !== "string") { + return Response.json({ error: "text is required" }, { status: 400 }); + } + + if (text.trim().length < 10) { + return Response.json({ error: "Text too short to remember" }, { status: 400 }); + } + + const { key, accountId } = await resolveUserKey(req); + if ((!key || !accountId) && (!process.env.MEMWAL_KEY || !process.env.MEMWAL_ACCOUNT_ID)) { + return Response.json( + { error: "[MemWal] No accountId configured — sign in with Enoki or set MEMWAL_ACCOUNT_ID in .env" }, + { status: 401 }, + ); + } + + const result = await rememberText(text, key, accountId); + return Response.json(result); + } catch (error) { + console.error("[memory/remember-one] Error:", error); + return Response.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500 }, + ); + } +} diff --git a/apps/noter/package/feature/note/hook/use-note-memory-save.ts b/apps/noter/package/feature/note/hook/use-note-memory-save.ts index 5586ae2e..0c106145 100644 --- a/apps/noter/package/feature/note/hook/use-note-memory-save.ts +++ b/apps/noter/package/feature/note/hook/use-note-memory-save.ts @@ -13,8 +13,20 @@ import { useState } from "react"; import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; -import { $getMemoryById } from "../plugins/MemoryHighlightPlugin"; -import { UPDATE_MEMORY_STATUS_COMMAND } from "../plugins/MemoryHighlightPlugin"; +import { $getMemoryById, UPDATE_MEMORY_STATUS_COMMAND } from "../plugins/MemoryHighlightPlugin"; +import { STORAGE_KEYS } from "@/feature/auth/constant"; +import type { SessionData } from "@/feature/auth/domain/type"; + +function getSessionId(): string | null { + if (typeof window === "undefined") return null; + try { + const raw = sessionStorage.getItem(STORAGE_KEYS.sessionId); + if (!raw) return null; + return (JSON.parse(raw) as SessionData | null)?.sessionId ?? null; + } catch { + return null; + } +} export function useNoteMemorySave() { const [editor] = useLexicalComposerContext(); @@ -47,10 +59,13 @@ export function useNoteMemorySave() { status: "uploading", }); - // Call server-side API to remember (v2 SDK) - const response = await fetch("/api/memory/remember", { + const sessionId = getSessionId(); + const response = await fetch("/api/memory/remember-one", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + ...(sessionId ? { "x-session-id": sessionId } : {}), + }, body: JSON.stringify({ text: memoryText }), }); diff --git a/apps/noter/package/feature/note/plugins/MemoryHighlightPlugin.tsx b/apps/noter/package/feature/note/plugins/MemoryHighlightPlugin.tsx index aeb6900a..8e99416a 100644 --- a/apps/noter/package/feature/note/plugins/MemoryHighlightPlugin.tsx +++ b/apps/noter/package/feature/note/plugins/MemoryHighlightPlugin.tsx @@ -154,7 +154,42 @@ export function MemoryHighlightPlugin({ if (index === -1) { index = findUnusedOccurrence(trimmedMemoryText); if (index !== -1) { - matchedText = trimmedMemoryText; // We matched the trimmed version! + matchedText = trimmedMemoryText; + } + } + + // If that fails, try fuzzy match: case-insensitive search, + // then find the best overlapping substring between LLM text and editor text + if (index === -1) { + const lowerFull = fullText.toLowerCase(); + const lowerMemory = trimmedMemoryText.toLowerCase(); + + // Strategy 1: case-insensitive exact match + const ciIndex = lowerFull.indexOf(lowerMemory); + if (ciIndex !== -1 && !usedPositions.has(ciIndex)) { + index = ciIndex; + matchedText = fullText.slice(ciIndex, ciIndex + trimmedMemoryText.length); + } + + // Strategy 2: find longest common substring (min 10 chars) + if (index === -1) { + let best = { start: -1, len: 0 }; + for (let i = 0; i < lowerFull.length; i++) { + for (let j = 0; j < lowerMemory.length; j++) { + if (lowerFull[i] !== lowerMemory[j]) continue; + let k = 0; + while (i + k < lowerFull.length && j + k < lowerMemory.length && lowerFull[i + k] === lowerMemory[j + k]) { + k++; + } + if (k > best.len) { + best = { start: i, len: k }; + } + } + } + if (best.len >= 10 && !usedPositions.has(best.start)) { + index = best.start; + matchedText = fullText.slice(best.start, best.start + best.len); + } } } diff --git a/apps/noter/package/feature/note/ui/memory-hover-preview.tsx b/apps/noter/package/feature/note/ui/memory-hover-preview.tsx index 465f612e..8d16b7b4 100644 --- a/apps/noter/package/feature/note/ui/memory-hover-preview.tsx +++ b/apps/noter/package/feature/note/ui/memory-hover-preview.tsx @@ -12,6 +12,7 @@ import { Card, CardContent } from "@/shared/components/ui/card"; import { Separator } from "@/shared/components/ui/separator"; import type { MemoryData } from "@/shared/db/type"; import { cn } from "@/shared/lib/utils"; +import { ZKLOGIN_CONFIG } from "@/feature/auth/constant"; import { AlertCircle, CheckCircle2, @@ -205,16 +206,17 @@ export function MemoryHoverPreview({ {memory.memwalMemoryId.slice(0, 16)}... - + {memory.memwalBlobId && ( + e.stopPropagation()} + className="flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 hover:underline" + > + + + )} {/* Blob ID */} diff --git a/apps/noter/package/feature/note/ui/memory-panel-enhanced.tsx b/apps/noter/package/feature/note/ui/memory-panel-enhanced.tsx index 6e6e5f33..527fe2bb 100644 --- a/apps/noter/package/feature/note/ui/memory-panel-enhanced.tsx +++ b/apps/noter/package/feature/note/ui/memory-panel-enhanced.tsx @@ -36,6 +36,7 @@ import { import { forwardRef, useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { useNoteMemorySave } from "../hook/use-note-memory-save"; +import { ZKLOGIN_CONFIG } from "@/feature/auth/constant"; import { $getAllMemories, REMOVE_MEMORY_HIGHLIGHT_COMMAND, UPDATE_MEMORY_STATUS_COMMAND } from "../plugins/MemoryHighlightPlugin"; const STATUS_CONFIGS = { @@ -433,18 +434,17 @@ const MemoryCard = forwardRef(
Memory: {memory.memwalMemoryId.slice(0, 12)}... - + {memory.memwalBlobId && ( + e.stopPropagation()} + className="shrink-0 p-0.5 text-blue-600 dark:text-blue-400 hover:underline" + > + + + )}
{memory.memwalBlobId && (
Blob: {memory.memwalBlobId.slice(0, 12)}...
diff --git a/apps/noter/package/feature/note/ui/note-editor.tsx b/apps/noter/package/feature/note/ui/note-editor.tsx index 6b3b5272..93b5d213 100644 --- a/apps/noter/package/feature/note/ui/note-editor.tsx +++ b/apps/noter/package/feature/note/ui/note-editor.tsx @@ -36,6 +36,10 @@ import { MarketingBorder } from "@/package/shared/components/border"; import { Button } from "@/shared/components/ui/button"; import { Check, Loader2, Save, XCircle } from "lucide-react"; import { useNote } from "../hook/use-note"; +import { MemoryHighlightNode } from "../nodes/MemoryHighlightNode"; +import { MemoryHighlightPlugin } from "../plugins/MemoryHighlightPlugin"; +import { MemoryPanelEnhanced } from "./memory-panel-enhanced"; +import { MemoryDetectButton } from "./memory-detect-button"; // ════════════════════════════════════════════════════════════════════════════ // EDITOR CONFIG @@ -56,6 +60,7 @@ const editorConfig = { TableCellNode, TableRowNode, HorizontalRuleNode, + MemoryHighlightNode, ], onError(error: Error) { console.error("Lexical error:", error); @@ -249,6 +254,7 @@ export function NoteEditor({ noteId }: NoteEditorProps) { {/* Toolbar */}
+
{/* Editor */} @@ -275,10 +281,11 @@ export function NoteEditor({ noteId }: NoteEditorProps) { - +
+ );