mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
fix(extension): discard stale recall responses
This commit is contained in:
parent
e651045ac5
commit
f6e333cf60
8 changed files with 890 additions and 238 deletions
|
|
@ -24,13 +24,16 @@ import {
|
|||
showMemorySuggestion,
|
||||
syncAcceptedSupermemoryState,
|
||||
} from "./memory-suggestion"
|
||||
import { createRecallRequestFreshnessGuard } from "./recall-request-freshness"
|
||||
|
||||
let chatGPTDebounceTimeout: NodeJS.Timeout | null = null
|
||||
let chatGPTRouteObserver: MutationObserver | null = null
|
||||
let chatGPTUrlCheckInterval: NodeJS.Timeout | null = null
|
||||
let chatGPTObserverThrottle: NodeJS.Timeout | null = null
|
||||
let chatGPTRecallInput: HTMLElement | null = null
|
||||
const CHATGPT_DEBUG = false
|
||||
const CHATGPT_LOG_PREFIX = "[supermemory:chatgpt]"
|
||||
const chatGPTRecallRequests = createRecallRequestFreshnessGuard<HTMLElement>()
|
||||
|
||||
export function initializeChatGPT() {
|
||||
debugChatGPT("initializeChatGPT called", {
|
||||
|
|
@ -89,6 +92,9 @@ function setupChatGPTRouteChangeDetection() {
|
|||
|
||||
const checkForRouteChange = () => {
|
||||
if (window.location.href !== currentUrl) {
|
||||
invalidateChatGPTRecallRequests()
|
||||
const input = getChatGPTRecallInput()
|
||||
if (input) clearPendingChatGPTRecall(input, false)
|
||||
currentUrl = window.location.href
|
||||
debugChatGPT("route changed, re-adding supermemory elements", currentUrl)
|
||||
setTimeout(() => {
|
||||
|
|
@ -102,6 +108,7 @@ function setupChatGPTRouteChangeDetection() {
|
|||
chatGPTUrlCheckInterval = setInterval(checkForRouteChange, 2000)
|
||||
|
||||
chatGPTRouteObserver = new MutationObserver((mutations) => {
|
||||
trackChatGPTRecallInput()
|
||||
if (chatGPTObserverThrottle) {
|
||||
return
|
||||
}
|
||||
|
|
@ -158,11 +165,12 @@ function setupChatGPTRouteChangeDetection() {
|
|||
}
|
||||
|
||||
async function getRelatedMemoriesForChatGPT(actionSource: string) {
|
||||
let request: ReturnType<typeof chatGPTRecallRequests.begin> | null = null
|
||||
try {
|
||||
const isAutoSearch =
|
||||
actionSource === POSTHOG_EVENT_KEY.CHATGPT_CHAT_MEMORIES_AUTO_SEARCHED
|
||||
const userQuery =
|
||||
document.getElementById("prompt-textarea")?.textContent || ""
|
||||
const state = getChatGPTRecallState()
|
||||
const userQuery = state.query
|
||||
|
||||
const icon = document.querySelectorAll(
|
||||
'[id*="sm-chatgpt-input-bar-element-before-composer"]',
|
||||
|
|
@ -175,6 +183,8 @@ async function getRelatedMemoriesForChatGPT(actionSource: string) {
|
|||
return
|
||||
}
|
||||
|
||||
request = chatGPTRecallRequests.begin(state)
|
||||
|
||||
if (isAutoSearch) {
|
||||
const promptElement = document.getElementById("prompt-textarea")
|
||||
if (promptElement) {
|
||||
|
|
@ -201,6 +211,10 @@ async function getRelatedMemoriesForChatGPT(actionSource: string) {
|
|||
timeoutPromise,
|
||||
])
|
||||
|
||||
if (!chatGPTRecallRequests.isCurrent(request, getChatGPTRecallState())) {
|
||||
return
|
||||
}
|
||||
|
||||
if (response?.success && response?.data) {
|
||||
const promptElement = document.getElementById("prompt-textarea")
|
||||
if (promptElement) {
|
||||
|
|
@ -241,6 +255,12 @@ async function getRelatedMemoriesForChatGPT(actionSource: string) {
|
|||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
request &&
|
||||
!chatGPTRecallRequests.isCurrent(request, getChatGPTRecallState())
|
||||
) {
|
||||
return
|
||||
}
|
||||
console.error("Error getting related memories:", error)
|
||||
try {
|
||||
const icon = document.querySelectorAll(
|
||||
|
|
@ -487,6 +507,79 @@ function getChatGPTPromptInput(): HTMLElement | null {
|
|||
) as HTMLElement | null
|
||||
}
|
||||
|
||||
function getChatGPTRecallInput(): HTMLElement | null {
|
||||
return document.getElementById("prompt-textarea") || getChatGPTPromptInput()
|
||||
}
|
||||
|
||||
function invalidateChatGPTRecallRequests() {
|
||||
chatGPTRecallRequests.invalidate()
|
||||
if (chatGPTDebounceTimeout) {
|
||||
clearTimeout(chatGPTDebounceTimeout)
|
||||
chatGPTDebounceTimeout = null
|
||||
}
|
||||
}
|
||||
|
||||
function trackChatGPTRecallInput() {
|
||||
setChatGPTRecallInput(getChatGPTRecallInput())
|
||||
}
|
||||
|
||||
function setChatGPTRecallInput(input: HTMLElement | null) {
|
||||
if (input === chatGPTRecallInput) return
|
||||
const previousInput = chatGPTRecallInput
|
||||
chatGPTRecallInput = input
|
||||
invalidateChatGPTRecallRequests()
|
||||
if (previousInput) clearPendingChatGPTRecall(previousInput, false)
|
||||
}
|
||||
|
||||
function clearPendingChatGPTRecall(
|
||||
input: HTMLElement,
|
||||
preserveAcceptedIcon = true,
|
||||
) {
|
||||
const hadAcceptedMarker = input.dataset.supermemoriesInjected === "true"
|
||||
syncAcceptedSupermemoryState(input)
|
||||
const hasAcceptedContext = hasAcceptedSupermemoryContext(input)
|
||||
|
||||
clearMemorySuggestion("chatgpt", input)
|
||||
if (hasAcceptedContext) input.dataset.supermemoriesInjected = "true"
|
||||
document
|
||||
.querySelectorAll('[id*="sm-chatgpt-input-bar-element-before-composer"]')
|
||||
.forEach((icon) => {
|
||||
const iconElement = icon as HTMLElement
|
||||
iconElement.querySelector("[data-supermemory-marker-popover]")?.remove()
|
||||
if (
|
||||
preserveAcceptedIcon &&
|
||||
hadAcceptedMarker &&
|
||||
hasAcceptedContext &&
|
||||
iconElement.dataset.memoriesData
|
||||
) {
|
||||
setMemoryMarkerStatus(iconElement, "found")
|
||||
return
|
||||
}
|
||||
delete iconElement.dataset.memoriesData
|
||||
setMemoryMarkerStatus(iconElement, "neutral")
|
||||
})
|
||||
}
|
||||
|
||||
function attachChatGPTRecallFreshness(input: HTMLElement) {
|
||||
if (input.hasAttribute("data-supermemory-recall-freshness")) return
|
||||
input.setAttribute("data-supermemory-recall-freshness", "true")
|
||||
setChatGPTRecallInput(input)
|
||||
|
||||
input.addEventListener("input", () => {
|
||||
invalidateChatGPTRecallRequests()
|
||||
clearPendingChatGPTRecall(input)
|
||||
})
|
||||
}
|
||||
|
||||
function getChatGPTRecallState() {
|
||||
const input = getChatGPTRecallInput()
|
||||
return {
|
||||
input,
|
||||
query: input?.textContent || "",
|
||||
url: window.location.href,
|
||||
}
|
||||
}
|
||||
|
||||
function findChatGPTComposerRoot(input: HTMLElement): HTMLElement {
|
||||
const form = input.closest("form") as HTMLElement | null
|
||||
if (form) return form
|
||||
|
|
@ -611,16 +704,14 @@ function getChatGPTDomSnapshot() {
|
|||
}
|
||||
}
|
||||
|
||||
async function setupChatGPTAutoFetch() {
|
||||
export async function setupChatGPTAutoFetch() {
|
||||
const promptTextarea = getChatGPTRecallInput()
|
||||
if (!promptTextarea) return
|
||||
attachChatGPTRecallFreshness(promptTextarea)
|
||||
|
||||
const autoSearch = (await autoSearchEnabled.getValue()) ?? false
|
||||
|
||||
if (!autoSearch) {
|
||||
return
|
||||
}
|
||||
|
||||
const promptTextarea = document.getElementById("prompt-textarea")
|
||||
if (
|
||||
!promptTextarea ||
|
||||
!autoSearch ||
|
||||
promptTextarea.hasAttribute("data-supermemory-auto-fetch")
|
||||
) {
|
||||
return
|
||||
|
|
@ -630,26 +721,25 @@ async function setupChatGPTAutoFetch() {
|
|||
|
||||
const handleInput = () => {
|
||||
const content = promptTextarea.textContent?.trim() || ""
|
||||
syncAcceptedSupermemoryState(promptTextarea)
|
||||
|
||||
if (content.length === 0) {
|
||||
clearMemorySuggestion("chatgpt", promptTextarea)
|
||||
document
|
||||
.querySelectorAll(
|
||||
'[id*="sm-chatgpt-input-bar-element-before-composer"]',
|
||||
)
|
||||
.forEach((icon) => {
|
||||
setMemoryMarkerStatus(icon as HTMLElement, "neutral")
|
||||
})
|
||||
}
|
||||
|
||||
if (chatGPTDebounceTimeout) {
|
||||
clearTimeout(chatGPTDebounceTimeout)
|
||||
}
|
||||
const scheduledRequest = chatGPTRecallRequests.begin(
|
||||
getChatGPTRecallState(),
|
||||
)
|
||||
|
||||
chatGPTDebounceTimeout = setTimeout(async () => {
|
||||
chatGPTDebounceTimeout = null
|
||||
if (
|
||||
!chatGPTRecallRequests.isCurrent(
|
||||
scheduledRequest,
|
||||
getChatGPTRecallState(),
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (hasAcceptedSupermemoryContext(promptTextarea)) {
|
||||
clearMemorySuggestion("chatgpt", promptTextarea)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -657,24 +747,6 @@ async function setupChatGPTAutoFetch() {
|
|||
await getRelatedMemoriesForChatGPT(
|
||||
POSTHOG_EVENT_KEY.CHATGPT_CHAT_MEMORIES_AUTO_SEARCHED,
|
||||
)
|
||||
} else if (content.length === 0) {
|
||||
const icons = document.querySelectorAll(
|
||||
'[id*="sm-chatgpt-input-bar-element-before-composer"]',
|
||||
)
|
||||
|
||||
icons.forEach((icon) => {
|
||||
const iconElement = icon as HTMLElement
|
||||
setMemoryMarkerStatus(iconElement, "neutral")
|
||||
if (iconElement.dataset.originalHtml) {
|
||||
iconElement.innerHTML = iconElement.dataset.originalHtml
|
||||
delete iconElement.dataset.originalHtml
|
||||
delete iconElement.dataset.memoriesData
|
||||
}
|
||||
})
|
||||
|
||||
if (promptTextarea.dataset.supermemories) {
|
||||
clearMemorySuggestion("chatgpt", promptTextarea)
|
||||
}
|
||||
}
|
||||
}, UI_CONFIG.AUTO_SEARCH_DEBOUNCE_DELAY)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,13 +24,16 @@ import {
|
|||
showMemorySuggestion,
|
||||
syncAcceptedSupermemoryState,
|
||||
} from "./memory-suggestion"
|
||||
import { createRecallRequestFreshnessGuard } from "./recall-request-freshness"
|
||||
|
||||
let claudeDebounceTimeout: NodeJS.Timeout | null = null
|
||||
let claudeRouteObserver: MutationObserver | null = null
|
||||
let claudeUrlCheckInterval: NodeJS.Timeout | null = null
|
||||
let claudeObserverThrottle: NodeJS.Timeout | null = null
|
||||
let claudeRecallInput: HTMLElement | null = null
|
||||
const CLAUDE_DEBUG = false
|
||||
const CLAUDE_LOG_PREFIX = "[supermemory:claude]"
|
||||
const claudeRecallRequests = createRecallRequestFreshnessGuard<HTMLElement>()
|
||||
|
||||
export function initializeClaude() {
|
||||
debugClaude("initializeClaude called", {
|
||||
|
|
@ -89,6 +92,9 @@ function setupClaudeRouteChangeDetection() {
|
|||
|
||||
const checkForRouteChange = () => {
|
||||
if (window.location.href !== currentUrl) {
|
||||
invalidateClaudeRecallRequests()
|
||||
const input = getClaudePromptInput()
|
||||
if (input) clearPendingClaudeRecall(input, false)
|
||||
currentUrl = window.location.href
|
||||
debugClaude("route changed, re-adding supermemory icon", currentUrl)
|
||||
setTimeout(() => {
|
||||
|
|
@ -102,6 +108,7 @@ function setupClaudeRouteChangeDetection() {
|
|||
claudeUrlCheckInterval = setInterval(checkForRouteChange, 2000)
|
||||
|
||||
claudeRouteObserver = new MutationObserver((mutations) => {
|
||||
trackClaudeRecallInput()
|
||||
if (claudeObserverThrottle) {
|
||||
return
|
||||
}
|
||||
|
|
@ -243,6 +250,113 @@ function getClaudePromptInput(): HTMLElement | null {
|
|||
) as HTMLElement | null
|
||||
}
|
||||
|
||||
function invalidateClaudeRecallRequests() {
|
||||
claudeRecallRequests.invalidate()
|
||||
if (claudeDebounceTimeout) {
|
||||
clearTimeout(claudeDebounceTimeout)
|
||||
claudeDebounceTimeout = null
|
||||
}
|
||||
}
|
||||
|
||||
function trackClaudeRecallInput() {
|
||||
setClaudeRecallInput(getClaudePromptInput())
|
||||
}
|
||||
|
||||
function setClaudeRecallInput(input: HTMLElement | null) {
|
||||
if (input === claudeRecallInput) return
|
||||
const previousInput = claudeRecallInput
|
||||
claudeRecallInput = input
|
||||
invalidateClaudeRecallRequests()
|
||||
if (previousInput) clearPendingClaudeRecall(previousInput, false)
|
||||
}
|
||||
|
||||
function getClaudeInputText(input: HTMLElement | null): string {
|
||||
if (!input) return ""
|
||||
if (
|
||||
input instanceof HTMLTextAreaElement ||
|
||||
input instanceof HTMLInputElement
|
||||
) {
|
||||
return input.value || ""
|
||||
}
|
||||
return input.innerText || input.textContent || ""
|
||||
}
|
||||
|
||||
function clearPendingClaudeRecall(
|
||||
input: HTMLElement,
|
||||
preserveAcceptedIcon = true,
|
||||
) {
|
||||
const hadAcceptedMarker = input.dataset.supermemoriesInjected === "true"
|
||||
syncAcceptedSupermemoryState(input)
|
||||
const hasAcceptedContext = hasAcceptedSupermemoryContext(input)
|
||||
|
||||
clearMemorySuggestion("claude", input)
|
||||
if (hasAcceptedContext) input.dataset.supermemoriesInjected = "true"
|
||||
document
|
||||
.querySelectorAll('[id*="sm-claude-input-bar-element"]')
|
||||
.forEach((icon) => {
|
||||
const iconElement = icon as HTMLElement
|
||||
iconElement.querySelector("[data-supermemory-marker-popover]")?.remove()
|
||||
if (
|
||||
preserveAcceptedIcon &&
|
||||
hadAcceptedMarker &&
|
||||
hasAcceptedContext &&
|
||||
iconElement.dataset.memoriesData
|
||||
) {
|
||||
setMemoryMarkerStatus(iconElement, "found")
|
||||
return
|
||||
}
|
||||
delete iconElement.dataset.memoriesData
|
||||
setMemoryMarkerStatus(iconElement, "neutral")
|
||||
})
|
||||
}
|
||||
|
||||
function attachClaudeRecallFreshness(input: HTMLElement) {
|
||||
if (input.hasAttribute("data-supermemory-recall-freshness")) return
|
||||
input.setAttribute("data-supermemory-recall-freshness", "true")
|
||||
setClaudeRecallInput(input)
|
||||
|
||||
input.addEventListener("input", () => {
|
||||
invalidateClaudeRecallRequests()
|
||||
clearPendingClaudeRecall(input)
|
||||
})
|
||||
}
|
||||
|
||||
function getClaudeRecallState() {
|
||||
let input = getClaudePromptInput()
|
||||
let query = ""
|
||||
const supermemoryContainer = document.querySelector(
|
||||
'[data-supermemory-icon-added="true"]',
|
||||
)
|
||||
|
||||
if (supermemoryContainer?.parentElement?.previousElementSibling) {
|
||||
const paragraph =
|
||||
supermemoryContainer.parentElement.previousElementSibling.querySelector(
|
||||
"p",
|
||||
)
|
||||
query = paragraph?.innerText || paragraph?.textContent || ""
|
||||
}
|
||||
|
||||
if (!query.trim()) {
|
||||
query = getClaudeInputText(input)
|
||||
}
|
||||
|
||||
if (!query.trim()) {
|
||||
const inputElements = document.querySelectorAll<HTMLElement>(
|
||||
'div[contenteditable="true"], textarea, input[type="text"]',
|
||||
)
|
||||
for (const candidate of inputElements) {
|
||||
const text = getClaudeInputText(candidate)
|
||||
if (text.trim()) {
|
||||
input = candidate
|
||||
query = text.trim()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { input, query, url: window.location.href }
|
||||
}
|
||||
|
||||
function findComposerRoot(input: HTMLElement): HTMLElement {
|
||||
return (
|
||||
(input.closest("form") as HTMLElement | null) ||
|
||||
|
|
@ -358,44 +472,12 @@ function getClaudeDomSnapshot() {
|
|||
}
|
||||
|
||||
async function getRelatedMemoriesForClaude(actionSource: string) {
|
||||
let request: ReturnType<typeof claudeRecallRequests.begin> | null = null
|
||||
try {
|
||||
const isAutoSearch =
|
||||
actionSource === POSTHOG_EVENT_KEY.CLAUDE_CHAT_MEMORIES_AUTO_SEARCHED
|
||||
let userQuery = ""
|
||||
|
||||
const supermemoryContainer = document.querySelector(
|
||||
'[data-supermemory-icon-added="true"]',
|
||||
)
|
||||
if (supermemoryContainer?.parentElement?.previousElementSibling) {
|
||||
const pTag =
|
||||
supermemoryContainer.parentElement.previousElementSibling.querySelector(
|
||||
"p",
|
||||
)
|
||||
userQuery = pTag?.innerText || pTag?.textContent || ""
|
||||
}
|
||||
|
||||
if (!userQuery.trim()) {
|
||||
const textareaElement = document.querySelector(
|
||||
'div[contenteditable="true"]',
|
||||
) as HTMLElement
|
||||
userQuery =
|
||||
textareaElement?.innerText || textareaElement?.textContent || ""
|
||||
}
|
||||
|
||||
if (!userQuery.trim()) {
|
||||
const inputElements = document.querySelectorAll(
|
||||
'div[contenteditable="true"], textarea, input[type="text"]',
|
||||
)
|
||||
for (const element of inputElements) {
|
||||
const text =
|
||||
(element as HTMLElement).innerText ||
|
||||
(element as HTMLInputElement).value
|
||||
if (text?.trim()) {
|
||||
userQuery = text.trim()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
const state = getClaudeRecallState()
|
||||
const userQuery = state.query
|
||||
|
||||
debugClaude("query extracted", {
|
||||
queryLength: userQuery.length,
|
||||
|
|
@ -415,6 +497,8 @@ async function getRelatedMemoriesForClaude(actionSource: string) {
|
|||
return
|
||||
}
|
||||
|
||||
request = claudeRecallRequests.begin(state)
|
||||
|
||||
if (isAutoSearch) {
|
||||
const input = getClaudePromptInput()
|
||||
if (input) {
|
||||
|
|
@ -441,14 +525,16 @@ async function getRelatedMemoriesForClaude(actionSource: string) {
|
|||
timeoutPromise,
|
||||
])
|
||||
|
||||
if (!claudeRecallRequests.isCurrent(request, getClaudeRecallState())) {
|
||||
return
|
||||
}
|
||||
|
||||
debugClaude("memory search response", {
|
||||
success: response?.success,
|
||||
})
|
||||
|
||||
if (response?.success && response?.data) {
|
||||
const textareaElement = document.querySelector(
|
||||
'div[contenteditable="true"]',
|
||||
) as HTMLElement
|
||||
const textareaElement = state.input
|
||||
|
||||
if (textareaElement) {
|
||||
const memoryText = showMemorySuggestion(
|
||||
|
|
@ -488,6 +574,12 @@ async function getRelatedMemoriesForClaude(actionSource: string) {
|
|||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
request &&
|
||||
!claudeRecallRequests.isCurrent(request, getClaudeRecallState())
|
||||
) {
|
||||
return
|
||||
}
|
||||
console.error("Error getting related memories for Claude:", error)
|
||||
try {
|
||||
const icon = document.querySelector(
|
||||
|
|
@ -838,17 +930,13 @@ function setupClaudePromptCapture() {
|
|||
}
|
||||
|
||||
async function setupClaudeAutoFetch() {
|
||||
const textareaElement = getClaudePromptInput()
|
||||
if (!textareaElement) return
|
||||
attachClaudeRecallFreshness(textareaElement)
|
||||
|
||||
const autoSearch = (await autoSearchEnabled.getValue()) ?? false
|
||||
if (!autoSearch) {
|
||||
return
|
||||
}
|
||||
|
||||
const textareaElement = document.querySelector(
|
||||
'div[contenteditable="true"]',
|
||||
) as HTMLElement
|
||||
|
||||
if (
|
||||
!textareaElement ||
|
||||
!autoSearch ||
|
||||
textareaElement.hasAttribute("data-supermemory-auto-fetch")
|
||||
) {
|
||||
return
|
||||
|
|
@ -857,25 +945,24 @@ async function setupClaudeAutoFetch() {
|
|||
textareaElement.setAttribute("data-supermemory-auto-fetch", "true")
|
||||
|
||||
const handleInput = () => {
|
||||
const content = textareaElement.textContent?.trim() || ""
|
||||
syncAcceptedSupermemoryState(textareaElement)
|
||||
|
||||
if (content.length === 0) {
|
||||
clearMemorySuggestion("claude", textareaElement)
|
||||
document
|
||||
.querySelectorAll('[id*="sm-claude-input-bar-element"]')
|
||||
.forEach((icon) => {
|
||||
setMemoryMarkerStatus(icon as HTMLElement, "neutral")
|
||||
})
|
||||
}
|
||||
const content = getClaudeInputText(textareaElement).trim()
|
||||
|
||||
if (claudeDebounceTimeout) {
|
||||
clearTimeout(claudeDebounceTimeout)
|
||||
}
|
||||
const scheduledRequest = claudeRecallRequests.begin(getClaudeRecallState())
|
||||
|
||||
claudeDebounceTimeout = setTimeout(async () => {
|
||||
claudeDebounceTimeout = null
|
||||
if (
|
||||
!claudeRecallRequests.isCurrent(
|
||||
scheduledRequest,
|
||||
getClaudeRecallState(),
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (hasAcceptedSupermemoryContext(textareaElement)) {
|
||||
clearMemorySuggestion("claude", textareaElement)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -883,24 +970,6 @@ async function setupClaudeAutoFetch() {
|
|||
await getRelatedMemoriesForClaude(
|
||||
POSTHOG_EVENT_KEY.CLAUDE_CHAT_MEMORIES_AUTO_SEARCHED,
|
||||
)
|
||||
} else if (content.length === 0) {
|
||||
const icons = document.querySelectorAll(
|
||||
'[id*="sm-claude-input-bar-element"]',
|
||||
)
|
||||
|
||||
icons.forEach((icon) => {
|
||||
const iconElement = icon as HTMLElement
|
||||
setMemoryMarkerStatus(iconElement, "neutral")
|
||||
if (iconElement.dataset.originalHtml) {
|
||||
iconElement.innerHTML = iconElement.dataset.originalHtml
|
||||
delete iconElement.dataset.originalHtml
|
||||
delete iconElement.dataset.memoriesData
|
||||
}
|
||||
})
|
||||
|
||||
if (textareaElement.dataset.supermemories) {
|
||||
clearMemorySuggestion("claude", textareaElement)
|
||||
}
|
||||
}
|
||||
}, UI_CONFIG.AUTO_SEARCH_DEBOUNCE_DELAY)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,13 +24,16 @@ import {
|
|||
showMemorySuggestion,
|
||||
syncAcceptedSupermemoryState,
|
||||
} from "./memory-suggestion"
|
||||
import { createRecallRequestFreshnessGuard } from "./recall-request-freshness"
|
||||
|
||||
let geminiDebounceTimeout: NodeJS.Timeout | null = null
|
||||
let geminiRouteObserver: MutationObserver | null = null
|
||||
let geminiUrlCheckInterval: NodeJS.Timeout | null = null
|
||||
let geminiObserverThrottle: NodeJS.Timeout | null = null
|
||||
let geminiRecallInput: GeminiInput | null = null
|
||||
const GEMINI_DEBUG = false
|
||||
const GEMINI_LOG_PREFIX = "[supermemory:gemini]"
|
||||
const geminiRecallRequests = createRecallRequestFreshnessGuard<GeminiInput>()
|
||||
|
||||
type GeminiInput = HTMLElement | HTMLTextAreaElement
|
||||
|
||||
|
|
@ -94,6 +97,9 @@ function setupGeminiRouteChangeDetection() {
|
|||
|
||||
const checkForRouteChange = () => {
|
||||
if (window.location.href !== currentUrl) {
|
||||
invalidateGeminiRecallRequests()
|
||||
const input = getGeminiPromptInput()
|
||||
if (input) clearPendingGeminiRecall(input, false)
|
||||
currentUrl = window.location.href
|
||||
debugGemini("route changed, rechecking UI", currentUrl)
|
||||
setTimeout(recheckGeminiUI, 1000)
|
||||
|
|
@ -103,6 +109,7 @@ function setupGeminiRouteChangeDetection() {
|
|||
geminiUrlCheckInterval = setInterval(checkForRouteChange, 2000)
|
||||
|
||||
geminiRouteObserver = new MutationObserver((mutations) => {
|
||||
trackGeminiRecallInput()
|
||||
if (geminiObserverThrottle) {
|
||||
return
|
||||
}
|
||||
|
|
@ -123,7 +130,6 @@ function setupGeminiRouteChangeDetection() {
|
|||
)
|
||||
}),
|
||||
)
|
||||
|
||||
if (shouldRecheck) {
|
||||
geminiObserverThrottle = setTimeout(() => {
|
||||
geminiObserverThrottle = null
|
||||
|
|
@ -364,12 +370,83 @@ function getInputText(input: GeminiInput | null): string {
|
|||
return input.innerText || input.textContent || ""
|
||||
}
|
||||
|
||||
function invalidateGeminiRecallRequests() {
|
||||
geminiRecallRequests.invalidate()
|
||||
if (geminiDebounceTimeout) {
|
||||
clearTimeout(geminiDebounceTimeout)
|
||||
geminiDebounceTimeout = null
|
||||
}
|
||||
}
|
||||
|
||||
function trackGeminiRecallInput() {
|
||||
setGeminiRecallInput(getGeminiPromptInput())
|
||||
}
|
||||
|
||||
function setGeminiRecallInput(input: GeminiInput | null) {
|
||||
if (input === geminiRecallInput) return
|
||||
const previousInput = geminiRecallInput
|
||||
geminiRecallInput = input
|
||||
invalidateGeminiRecallRequests()
|
||||
if (previousInput) clearPendingGeminiRecall(previousInput, false)
|
||||
}
|
||||
|
||||
function clearPendingGeminiRecall(
|
||||
input: GeminiInput,
|
||||
preserveAcceptedIcon = true,
|
||||
) {
|
||||
const hadAcceptedMarker = input.dataset.supermemoriesInjected === "true"
|
||||
syncAcceptedSupermemoryState(input)
|
||||
const hasAcceptedContext = hasAcceptedSupermemoryContext(input)
|
||||
|
||||
clearMemorySuggestion("gemini", input)
|
||||
if (hasAcceptedContext) input.dataset.supermemoriesInjected = "true"
|
||||
document
|
||||
.querySelectorAll(`[id*="${ELEMENT_IDS.GEMINI_INPUT_BAR_ELEMENT}"]`)
|
||||
.forEach((icon) => {
|
||||
const iconElement = icon as HTMLElement
|
||||
iconElement.querySelector("[data-supermemory-marker-popover]")?.remove()
|
||||
if (
|
||||
preserveAcceptedIcon &&
|
||||
hadAcceptedMarker &&
|
||||
hasAcceptedContext &&
|
||||
iconElement.dataset.memoriesData
|
||||
) {
|
||||
setMemoryMarkerStatus(iconElement, "found")
|
||||
return
|
||||
}
|
||||
delete iconElement.dataset.memoriesData
|
||||
delete iconElement.dataset.supermemories
|
||||
setMemoryMarkerStatus(iconElement, "neutral")
|
||||
})
|
||||
}
|
||||
|
||||
function attachGeminiRecallFreshness(input: GeminiInput) {
|
||||
if (input.hasAttribute("data-supermemory-recall-freshness")) return
|
||||
input.setAttribute("data-supermemory-recall-freshness", "true")
|
||||
setGeminiRecallInput(input)
|
||||
|
||||
input.addEventListener("input", () => {
|
||||
invalidateGeminiRecallRequests()
|
||||
clearPendingGeminiRecall(input)
|
||||
})
|
||||
}
|
||||
|
||||
function getGeminiRecallState() {
|
||||
const input = getGeminiPromptInput()
|
||||
return {
|
||||
input,
|
||||
query: getInputText(input).trim(),
|
||||
url: window.location.href,
|
||||
}
|
||||
}
|
||||
|
||||
async function getRelatedMemoriesForGemini(actionSource: string) {
|
||||
let request: ReturnType<typeof geminiRecallRequests.begin> | null = null
|
||||
try {
|
||||
const isAutoSearch =
|
||||
actionSource === POSTHOG_EVENT_KEY.GEMINI_CHAT_MEMORIES_AUTO_SEARCHED
|
||||
const input = getGeminiPromptInput()
|
||||
const userQuery = getInputText(input).trim()
|
||||
const state = getGeminiRecallState()
|
||||
const { input, query: userQuery } = state
|
||||
debugGemini("manual/auto memory search requested", {
|
||||
actionSource,
|
||||
hasInput: !!input,
|
||||
|
|
@ -390,6 +467,8 @@ async function getRelatedMemoriesForGemini(actionSource: string) {
|
|||
return
|
||||
}
|
||||
|
||||
request = geminiRecallRequests.begin(state)
|
||||
|
||||
if (input && isAutoSearch) {
|
||||
showLoadingSuggestion("gemini", input)
|
||||
}
|
||||
|
|
@ -414,6 +493,10 @@ async function getRelatedMemoriesForGemini(actionSource: string) {
|
|||
timeoutPromise,
|
||||
])) as { success?: boolean; data?: string }
|
||||
|
||||
if (!geminiRecallRequests.isCurrent(request, getGeminiRecallState())) {
|
||||
return
|
||||
}
|
||||
|
||||
debugGemini("memory search response", response)
|
||||
|
||||
if (response?.success && response?.data && input) {
|
||||
|
|
@ -436,6 +519,12 @@ async function getRelatedMemoriesForGemini(actionSource: string) {
|
|||
updateGeminiIconFeedback("No memories found", iconElement, 1800)
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
request &&
|
||||
!geminiRecallRequests.isCurrent(request, getGeminiRecallState())
|
||||
) {
|
||||
return
|
||||
}
|
||||
console.error("Error getting related memories for Gemini:", error)
|
||||
const iconElement = document.querySelector(
|
||||
`[id*="${ELEMENT_IDS.GEMINI_INPUT_BAR_ELEMENT}"]`,
|
||||
|
|
@ -592,18 +681,18 @@ function setupGeminiPromptCapture() {
|
|||
}
|
||||
|
||||
async function setupGeminiAutoFetch() {
|
||||
const autoSearch = (await autoSearchEnabled.getValue()) ?? false
|
||||
debugGemini("setup auto fetch", { autoSearch })
|
||||
if (!autoSearch) {
|
||||
const input = getGeminiPromptInput()
|
||||
if (!input) {
|
||||
debugGemini("auto fetch skipped", {
|
||||
hasInput: false,
|
||||
})
|
||||
return
|
||||
}
|
||||
attachGeminiRecallFreshness(input)
|
||||
|
||||
const input = getGeminiPromptInput()
|
||||
if (!input || input.hasAttribute("data-supermemory-auto-fetch")) {
|
||||
debugGemini("auto fetch skipped", {
|
||||
hasInput: !!input,
|
||||
alreadyAttached: input?.hasAttribute("data-supermemory-auto-fetch"),
|
||||
})
|
||||
const autoSearch = (await autoSearchEnabled.getValue()) ?? false
|
||||
debugGemini("setup auto fetch", { autoSearch })
|
||||
if (!autoSearch || input.hasAttribute("data-supermemory-auto-fetch")) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -612,24 +701,23 @@ async function setupGeminiAutoFetch() {
|
|||
|
||||
const handleInput = () => {
|
||||
const content = getInputText(input).trim()
|
||||
syncAcceptedSupermemoryState(input)
|
||||
|
||||
if (content.length === 0) {
|
||||
clearMemorySuggestion("gemini", input)
|
||||
document
|
||||
.querySelectorAll(`[id*="${ELEMENT_IDS.GEMINI_INPUT_BAR_ELEMENT}"]`)
|
||||
.forEach((icon) => {
|
||||
setMemoryMarkerStatus(icon as HTMLElement, "neutral")
|
||||
})
|
||||
}
|
||||
|
||||
if (geminiDebounceTimeout) {
|
||||
clearTimeout(geminiDebounceTimeout)
|
||||
}
|
||||
const scheduledRequest = geminiRecallRequests.begin(getGeminiRecallState())
|
||||
|
||||
geminiDebounceTimeout = setTimeout(async () => {
|
||||
geminiDebounceTimeout = null
|
||||
if (
|
||||
!geminiRecallRequests.isCurrent(
|
||||
scheduledRequest,
|
||||
getGeminiRecallState(),
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (hasAcceptedSupermemoryContext(input)) {
|
||||
clearMemorySuggestion("gemini", input)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -637,25 +725,6 @@ async function setupGeminiAutoFetch() {
|
|||
await getRelatedMemoriesForGemini(
|
||||
POSTHOG_EVENT_KEY.GEMINI_CHAT_MEMORIES_AUTO_SEARCHED,
|
||||
)
|
||||
} else if (content.length === 0) {
|
||||
const icons = document.querySelectorAll(
|
||||
`[id*="${ELEMENT_IDS.GEMINI_INPUT_BAR_ELEMENT}"]`,
|
||||
)
|
||||
|
||||
icons.forEach((icon) => {
|
||||
const iconElement = icon as HTMLElement
|
||||
iconElement.querySelector("[data-supermemory-status-badge]")?.remove()
|
||||
delete iconElement.dataset.supermemoryStatus
|
||||
delete iconElement.dataset.memoriesData
|
||||
if (iconElement.dataset.originalHtml) {
|
||||
iconElement.innerHTML = iconElement.dataset.originalHtml
|
||||
delete iconElement.dataset.originalHtml
|
||||
}
|
||||
})
|
||||
|
||||
if (input.dataset.supermemories) {
|
||||
clearMemorySuggestion("gemini", input)
|
||||
}
|
||||
}
|
||||
}, UI_CONFIG.AUTO_SEARCH_DEBOUNCE_DELAY)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -198,9 +198,9 @@ export function acceptMemorySuggestion(
|
|||
event.stopPropagation()
|
||||
|
||||
const text = input.dataset.supermemories
|
||||
appendTextToInput(input, text)
|
||||
delete input.dataset.supermemories
|
||||
input.dataset.supermemoriesInjected = "true"
|
||||
appendTextToInput(input, text)
|
||||
removeMemorySuggestion(platform)
|
||||
|
||||
return true
|
||||
|
|
|
|||
|
|
@ -0,0 +1,245 @@
|
|||
import { afterEach, describe, expect, it, mock } from "bun:test"
|
||||
import { GlobalWindow } from "happy-dom"
|
||||
|
||||
mock.module("#imports", () => ({
|
||||
storage: {
|
||||
defineItem: () => ({
|
||||
getValue: async () => false,
|
||||
setValue: async () => {},
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
const { setupChatGPTAutoFetch } = await import("./chatgpt")
|
||||
const { acceptMemorySuggestion } = await import("./memory-suggestion")
|
||||
const { getRelatedMemoriesForT3, setupT3AutoFetch } = await import("./t3")
|
||||
|
||||
const installedGlobals = [
|
||||
"browser",
|
||||
"document",
|
||||
"Event",
|
||||
"HTMLElement",
|
||||
"HTMLInputElement",
|
||||
"HTMLTextAreaElement",
|
||||
"KeyboardEvent",
|
||||
"Node",
|
||||
"window",
|
||||
] as const
|
||||
const originalGlobals = new Map(
|
||||
installedGlobals.map((name) => [
|
||||
name,
|
||||
Object.getOwnPropertyDescriptor(globalThis, name),
|
||||
]),
|
||||
)
|
||||
|
||||
let page: GlobalWindow | null = null
|
||||
|
||||
function installDom() {
|
||||
page = new GlobalWindow({ url: "https://t3.chat/chat/test" })
|
||||
const values = {
|
||||
browser: undefined,
|
||||
document: page.document,
|
||||
Event: page.Event,
|
||||
HTMLElement: page.HTMLElement,
|
||||
HTMLInputElement: page.HTMLInputElement,
|
||||
HTMLTextAreaElement: page.HTMLTextAreaElement,
|
||||
KeyboardEvent: page.KeyboardEvent,
|
||||
Node: page.Node,
|
||||
window: page,
|
||||
}
|
||||
|
||||
for (const name of installedGlobals) {
|
||||
if (name === "browser") continue
|
||||
Object.defineProperty(globalThis, name, {
|
||||
configurable: true,
|
||||
value: values[name],
|
||||
writable: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
page?.happyDOM.close()
|
||||
page = null
|
||||
for (const name of installedGlobals) {
|
||||
const descriptor = originalGlobals.get(name)
|
||||
if (descriptor) {
|
||||
Object.defineProperty(globalThis, name, descriptor)
|
||||
} else {
|
||||
Reflect.deleteProperty(globalThis, name)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((resolvePromise) => {
|
||||
resolve = resolvePromise
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe("T3 recall freshness handler", () => {
|
||||
it("clears stale auto-send data on edit even when auto-search is off", async () => {
|
||||
installDom()
|
||||
const input = document.createElement("textarea")
|
||||
input.dataset.supermemories = "stale memory A"
|
||||
const icon = document.createElement("div")
|
||||
icon.id = "sm-t3-input-bar-element-test"
|
||||
icon.innerHTML = "<span>Included Memories</span>"
|
||||
icon.dataset.originalHtml = "<button>Search memories</button>"
|
||||
icon.dataset.memoriesData = '["stale memory A"]'
|
||||
document.body.append(input, icon)
|
||||
const responseA = deferred<unknown>()
|
||||
Object.defineProperty(globalThis, "browser", {
|
||||
configurable: true,
|
||||
value: {
|
||||
runtime: {
|
||||
sendMessage: () => responseA.promise,
|
||||
},
|
||||
},
|
||||
writable: true,
|
||||
})
|
||||
|
||||
await setupT3AutoFetch()
|
||||
input.value = "prompt A"
|
||||
const inFlightA = getRelatedMemoriesForT3("test")
|
||||
input.value = "prompt B"
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }))
|
||||
|
||||
expect(input.dataset.supermemories).toBeUndefined()
|
||||
expect(icon.dataset.memoriesData).toBeUndefined()
|
||||
expect(icon.dataset.originalHtml).toBeUndefined()
|
||||
expect(icon.textContent).toBe("Search memories")
|
||||
expect(input.dataset.supermemoryRecallFreshness).toBe("true")
|
||||
expect(input.dataset.supermemoryAutoFetch).toBeUndefined()
|
||||
|
||||
responseA.resolve({ success: true, data: ["memory A"] })
|
||||
await inFlightA
|
||||
expect(input.dataset.supermemories).toBeUndefined()
|
||||
expect(icon.dataset.memoriesData).toBeUndefined()
|
||||
})
|
||||
|
||||
it("keeps fast B when slow A resolves last", async () => {
|
||||
installDom()
|
||||
const prompt = document.createElement("div")
|
||||
const input = document.createElement("textarea")
|
||||
input.value = "prompt A"
|
||||
prompt.appendChild(input)
|
||||
const controls = document.createElement("div")
|
||||
const iconContainer = document.createElement("div")
|
||||
iconContainer.dataset.supermemoryIconAdded = "true"
|
||||
const icon = document.createElement("div")
|
||||
icon.id = "sm-t3-input-bar-element-test"
|
||||
icon.innerHTML = "<button>Search memories</button>"
|
||||
iconContainer.appendChild(icon)
|
||||
controls.appendChild(iconContainer)
|
||||
document.body.append(prompt, controls)
|
||||
|
||||
const pending = new Map<string, ReturnType<typeof deferred<unknown>>>()
|
||||
Object.defineProperty(globalThis, "browser", {
|
||||
configurable: true,
|
||||
value: {
|
||||
runtime: {
|
||||
sendMessage: ({ data }: { data: string }) => {
|
||||
const request = deferred<unknown>()
|
||||
pending.set(data, request)
|
||||
return request.promise
|
||||
},
|
||||
},
|
||||
},
|
||||
writable: true,
|
||||
})
|
||||
|
||||
const slowA = getRelatedMemoriesForT3("test")
|
||||
input.value = "prompt B"
|
||||
const fastB = getRelatedMemoriesForT3("test")
|
||||
pending.get("prompt B")?.resolve({ success: true, data: ["memory B"] })
|
||||
await fastB
|
||||
pending.get("prompt A")?.resolve({ success: true, data: ["memory A"] })
|
||||
await slowA
|
||||
|
||||
expect(input.dataset.supermemories).toContain("memory B")
|
||||
expect(input.dataset.supermemories).not.toContain("memory A")
|
||||
expect(icon.dataset.memoriesData).toBe('["memory B"]')
|
||||
})
|
||||
|
||||
it("clears replacement-composer loading state", async () => {
|
||||
installDom()
|
||||
const firstInput = document.createElement("textarea")
|
||||
const icon = document.createElement("div")
|
||||
icon.id = "sm-t3-input-bar-element-test"
|
||||
icon.innerHTML = "<span>Searching memories</span>"
|
||||
icon.dataset.originalHtml = "<button>Search memories</button>"
|
||||
icon.dataset.memoriesData = '["memory A"]'
|
||||
document.body.append(firstInput, icon)
|
||||
|
||||
await setupT3AutoFetch()
|
||||
const replacementInput = document.createElement("textarea")
|
||||
firstInput.replaceWith(replacementInput)
|
||||
await setupT3AutoFetch()
|
||||
|
||||
expect(icon.textContent).toBe("Search memories")
|
||||
expect(icon.dataset.originalHtml).toBeUndefined()
|
||||
expect(icon.dataset.memoriesData).toBeUndefined()
|
||||
expect(replacementInput.dataset.supermemoryRecallFreshness).toBe("true")
|
||||
})
|
||||
})
|
||||
|
||||
describe("accepted recall context", () => {
|
||||
it("preserves included memories through the synchronous acceptance input", async () => {
|
||||
installDom()
|
||||
const form = document.createElement("form")
|
||||
const input = document.createElement("textarea")
|
||||
input.dataset.testid = "prompt-textarea"
|
||||
input.value = "prompt"
|
||||
input.dataset.supermemories =
|
||||
"\n\nSupermemories of user (only for the reference): 1. memory M1"
|
||||
form.appendChild(input)
|
||||
const icon = document.createElement("div")
|
||||
icon.id = "sm-chatgpt-input-bar-element-before-composer-test"
|
||||
icon.dataset.memoriesData = '["memory M1"]'
|
||||
icon.dataset.supermemoryStatus = "searching"
|
||||
document.body.append(form, icon)
|
||||
|
||||
await setupChatGPTAutoFetch()
|
||||
const accepted = acceptMemorySuggestion(
|
||||
new KeyboardEvent("keydown", {
|
||||
cancelable: true,
|
||||
key: "Tab",
|
||||
}),
|
||||
"chatgpt",
|
||||
input,
|
||||
)
|
||||
|
||||
expect(accepted).toBe(true)
|
||||
expect(input.value).toContain("memory M1")
|
||||
expect(input.dataset.supermemories).toBeUndefined()
|
||||
expect(input.dataset.supermemoriesInjected).toBe("true")
|
||||
expect(icon.dataset.memoriesData).toBe('["memory M1"]')
|
||||
expect(icon.dataset.supermemoryStatus).toBe("found")
|
||||
expect(icon.querySelector("[data-supermemory-status-badge]")).not.toBeNull()
|
||||
|
||||
icon.dataset.supermemoryStatus = "searching"
|
||||
input.value = input.value.replace("prompt", "edited prompt")
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }))
|
||||
expect(icon.dataset.memoriesData).toBe('["memory M1"]')
|
||||
expect(icon.dataset.supermemoryStatus).toBe("found")
|
||||
|
||||
input.dataset.supermemories =
|
||||
"\n\nSupermemories of user (only for the reference): 1. memory M2"
|
||||
delete input.dataset.supermemoriesInjected
|
||||
icon.dataset.memoriesData = '["memory M2"]'
|
||||
input.value = input.value.replace("edited", "edited again")
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }))
|
||||
expect(input.value).toContain("memory M1")
|
||||
expect(input.dataset.supermemories).toBeUndefined()
|
||||
expect(input.dataset.supermemoriesInjected).toBe("true")
|
||||
expect(icon.dataset.memoriesData).toBeUndefined()
|
||||
expect(icon.dataset.supermemoryStatus).toBeUndefined()
|
||||
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }))
|
||||
expect(icon.dataset.supermemoryStatus).toBeUndefined()
|
||||
expect(icon.querySelector("[data-supermemory-status-badge]")).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
import { describe, expect, it } from "bun:test"
|
||||
import { createRecallRequestFreshnessGuard } from "./recall-request-freshness"
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason: unknown) => void
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise
|
||||
reject = rejectPromise
|
||||
})
|
||||
return { promise, reject, resolve }
|
||||
}
|
||||
|
||||
describe("recall request freshness", () => {
|
||||
it("lets fast B commit and discards slow A", async () => {
|
||||
const guard = createRecallRequestFreshnessGuard<{ id: string }>()
|
||||
const input = { id: "composer" }
|
||||
let state = { input, query: "prompt A", url: "/chat" }
|
||||
const commits: string[] = []
|
||||
const slowA = deferred<string>()
|
||||
const fastB = deferred<string>()
|
||||
|
||||
const requestA = guard.begin(state)
|
||||
const settleA = slowA.promise.then((value) => {
|
||||
if (guard.isCurrent(requestA, state)) commits.push(value)
|
||||
})
|
||||
|
||||
state = { ...state, query: "prompt B" }
|
||||
guard.invalidate()
|
||||
const requestB = guard.begin(state)
|
||||
const settleB = fastB.promise.then((value) => {
|
||||
if (guard.isCurrent(requestB, state)) commits.push(value)
|
||||
})
|
||||
|
||||
fastB.resolve("memory B")
|
||||
await settleB
|
||||
slowA.resolve("memory A")
|
||||
await settleA
|
||||
|
||||
expect(commits).toEqual(["memory B"])
|
||||
})
|
||||
|
||||
it("keeps a T3 edit-gap from auto-sending stale memory", async () => {
|
||||
const guard = createRecallRequestFreshnessGuard<{
|
||||
dataset: { supermemories?: string }
|
||||
}>()
|
||||
const input: { dataset: { supermemories?: string } } = { dataset: {} }
|
||||
let state = { input, query: "prompt A", url: "/chat" }
|
||||
const result = deferred<string>()
|
||||
const request = guard.begin(state)
|
||||
const settle = result.promise.then((value) => {
|
||||
if (guard.isCurrent(request, state)) {
|
||||
input.dataset.supermemories = value
|
||||
}
|
||||
})
|
||||
|
||||
state = { ...state, query: "prompt B" }
|
||||
guard.invalidate()
|
||||
result.resolve("stale memory A")
|
||||
await settle
|
||||
const promptSent = `${state.query}${input.dataset.supermemories || ""}`
|
||||
|
||||
expect(promptSent).toBe("prompt B")
|
||||
})
|
||||
|
||||
it("keeps an edit invalidated even when the user restores the same text", async () => {
|
||||
const guard = createRecallRequestFreshnessGuard<{ dataset: string }>()
|
||||
const input = { dataset: "" }
|
||||
let state = { input, query: "prompt A", url: "/chat" }
|
||||
const result = deferred<string>()
|
||||
const request = guard.begin(state)
|
||||
const settle = result.promise.then((value) => {
|
||||
if (guard.isCurrent(request, state)) input.dataset = value
|
||||
})
|
||||
|
||||
state = { ...state, query: "temporary edit" }
|
||||
guard.invalidate()
|
||||
state = { ...state, query: "prompt A" }
|
||||
result.resolve("stale memory A")
|
||||
await settle
|
||||
|
||||
expect(input.dataset).toBe("")
|
||||
})
|
||||
|
||||
it("rejects stale errors, route changes, and replaced composers", async () => {
|
||||
const guard = createRecallRequestFreshnessGuard<{ id: string }>()
|
||||
const oldInput = { id: "old" }
|
||||
let state = { input: oldInput, query: "prompt", url: "/chat/one" }
|
||||
const failure = deferred<never>()
|
||||
const errors: unknown[] = []
|
||||
const request = guard.begin(state)
|
||||
const settle = failure.promise.catch((error) => {
|
||||
if (guard.isCurrent(request, state)) errors.push(error)
|
||||
})
|
||||
|
||||
state = {
|
||||
input: { id: "new" },
|
||||
query: "prompt",
|
||||
url: "/chat/two",
|
||||
}
|
||||
guard.invalidate()
|
||||
failure.reject(new Error("stale failure"))
|
||||
await settle
|
||||
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
export interface RecallRequestState<TInput extends object> {
|
||||
input: TInput | null
|
||||
query: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface RecallRequestToken<TInput extends object>
|
||||
extends RecallRequestState<TInput> {
|
||||
generation: number
|
||||
}
|
||||
|
||||
export function createRecallRequestFreshnessGuard<TInput extends object>() {
|
||||
let generation = 0
|
||||
|
||||
return {
|
||||
invalidate() {
|
||||
generation += 1
|
||||
},
|
||||
|
||||
begin(state: RecallRequestState<TInput>): RecallRequestToken<TInput> {
|
||||
generation += 1
|
||||
return { ...state, generation }
|
||||
},
|
||||
|
||||
isCurrent(
|
||||
request: RecallRequestToken<TInput>,
|
||||
state: RecallRequestState<TInput>,
|
||||
) {
|
||||
return (
|
||||
request.generation === generation &&
|
||||
request.input !== null &&
|
||||
request.input === state.input &&
|
||||
request.query === state.query &&
|
||||
request.url === state.url
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -16,11 +16,14 @@ import {
|
|||
renumberIncludedMemories,
|
||||
serializeMemoriesForDataset,
|
||||
} from "./memory-suggestion"
|
||||
import { createRecallRequestFreshnessGuard } from "./recall-request-freshness"
|
||||
|
||||
let t3DebounceTimeout: NodeJS.Timeout | null = null
|
||||
let t3RouteObserver: MutationObserver | null = null
|
||||
let t3UrlCheckInterval: NodeJS.Timeout | null = null
|
||||
let t3ObserverThrottle: NodeJS.Timeout | null = null
|
||||
let t3RecallInput: HTMLElement | null = null
|
||||
const t3RecallRequests = createRecallRequestFreshnessGuard<HTMLElement>()
|
||||
let t3IncludedPopup: {
|
||||
el: HTMLElement
|
||||
onClick: (event: MouseEvent) => void
|
||||
|
|
@ -72,7 +75,9 @@ function setupT3RouteChangeDetection() {
|
|||
|
||||
const checkForRouteChange = () => {
|
||||
if (window.location.href !== currentUrl) {
|
||||
disposeT3IncludedPopup()
|
||||
invalidateT3RecallRequests()
|
||||
const input = getT3PromptInput()
|
||||
if (input) clearT3Recall(input)
|
||||
currentUrl = window.location.href
|
||||
setTimeout(() => {
|
||||
addSupermemoryIconToT3Input()
|
||||
|
|
@ -84,6 +89,7 @@ function setupT3RouteChangeDetection() {
|
|||
t3UrlCheckInterval = setInterval(checkForRouteChange, 2000)
|
||||
|
||||
t3RouteObserver = new MutationObserver((mutations) => {
|
||||
trackT3RecallInput()
|
||||
if (t3ObserverThrottle) {
|
||||
return
|
||||
}
|
||||
|
|
@ -167,39 +173,112 @@ function addSupermemoryIconToT3Input() {
|
|||
container.insertBefore(supermemoryIcon, container.firstChild)
|
||||
}
|
||||
|
||||
async function getRelatedMemoriesForT3(actionSource: string) {
|
||||
try {
|
||||
let userQuery = ""
|
||||
function getT3InputText(input: HTMLElement | null): string {
|
||||
if (!input) return ""
|
||||
if (input instanceof HTMLTextAreaElement) return input.value || ""
|
||||
return input.innerText || input.textContent || ""
|
||||
}
|
||||
|
||||
const supermemoryContainer = document.querySelector(
|
||||
'[data-supermemory-icon-added="true"]',
|
||||
)
|
||||
if (supermemoryContainer?.parentElement?.previousElementSibling) {
|
||||
const textareaElement =
|
||||
supermemoryContainer.parentElement.previousElementSibling.querySelector(
|
||||
"textarea",
|
||||
)
|
||||
userQuery = textareaElement?.value || ""
|
||||
function getT3PromptInput(): HTMLElement | null {
|
||||
return (
|
||||
(document.querySelector("textarea") as HTMLTextAreaElement | null) ||
|
||||
(document.querySelector(
|
||||
'div[contenteditable="true"]',
|
||||
) as HTMLElement | null)
|
||||
)
|
||||
}
|
||||
|
||||
function invalidateT3RecallRequests() {
|
||||
t3RecallRequests.invalidate()
|
||||
if (t3DebounceTimeout) {
|
||||
clearTimeout(t3DebounceTimeout)
|
||||
t3DebounceTimeout = null
|
||||
}
|
||||
}
|
||||
|
||||
function trackT3RecallInput() {
|
||||
setT3RecallInput(getT3PromptInput())
|
||||
}
|
||||
|
||||
function setT3RecallInput(input: HTMLElement | null) {
|
||||
if (input === t3RecallInput) return
|
||||
const previousInput = t3RecallInput
|
||||
t3RecallInput = input
|
||||
invalidateT3RecallRequests()
|
||||
if (previousInput) clearT3Recall(previousInput)
|
||||
}
|
||||
|
||||
function clearT3Recall(input: HTMLElement) {
|
||||
delete input.dataset.supermemories
|
||||
disposeT3IncludedPopup()
|
||||
document
|
||||
.querySelectorAll('[id*="sm-t3-input-bar-element"]')
|
||||
.forEach((icon) => {
|
||||
const iconElement = icon as HTMLElement
|
||||
if (iconElement.dataset.originalHtml) {
|
||||
iconElement.innerHTML = iconElement.dataset.originalHtml
|
||||
delete iconElement.dataset.originalHtml
|
||||
}
|
||||
delete iconElement.dataset.memoriesData
|
||||
})
|
||||
}
|
||||
|
||||
export function attachT3RecallFreshnessHandler(input: HTMLElement) {
|
||||
if (input.hasAttribute("data-supermemory-recall-freshness")) return
|
||||
input.setAttribute("data-supermemory-recall-freshness", "true")
|
||||
setT3RecallInput(input)
|
||||
|
||||
input.addEventListener("input", () => {
|
||||
invalidateT3RecallRequests()
|
||||
clearT3Recall(input)
|
||||
})
|
||||
}
|
||||
|
||||
function getT3RecallState() {
|
||||
let input: HTMLElement | null = null
|
||||
let query = ""
|
||||
const supermemoryContainer = document.querySelector(
|
||||
'[data-supermemory-icon-added="true"]',
|
||||
)
|
||||
|
||||
if (supermemoryContainer?.parentElement?.previousElementSibling) {
|
||||
input =
|
||||
supermemoryContainer.parentElement.previousElementSibling.querySelector(
|
||||
"textarea",
|
||||
)
|
||||
query = getT3InputText(input)
|
||||
}
|
||||
|
||||
if (!query.trim()) {
|
||||
const contentEditable = document.querySelector(
|
||||
'div[contenteditable="true"]',
|
||||
) as HTMLElement | null
|
||||
const contentEditableText = getT3InputText(contentEditable)
|
||||
if (contentEditableText.trim()) {
|
||||
input = contentEditable
|
||||
query = contentEditableText
|
||||
}
|
||||
}
|
||||
|
||||
if (!userQuery.trim()) {
|
||||
const textareaElement = document.querySelector(
|
||||
'div[contenteditable="true"]',
|
||||
) as HTMLElement
|
||||
userQuery =
|
||||
textareaElement?.innerText || textareaElement?.textContent || ""
|
||||
}
|
||||
|
||||
if (!userQuery.trim()) {
|
||||
const textareas = document.querySelectorAll("textarea")
|
||||
for (const textarea of textareas) {
|
||||
const text = (textarea as HTMLTextAreaElement).value
|
||||
if (text?.trim()) {
|
||||
userQuery = text.trim()
|
||||
break
|
||||
}
|
||||
if (!query.trim()) {
|
||||
for (const textarea of document.querySelectorAll("textarea")) {
|
||||
const text = textarea.value || ""
|
||||
if (text.trim()) {
|
||||
input = textarea
|
||||
query = text.trim()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { input, query, url: window.location.href }
|
||||
}
|
||||
|
||||
export async function getRelatedMemoriesForT3(actionSource: string) {
|
||||
let request: ReturnType<typeof t3RecallRequests.begin> | null = null
|
||||
try {
|
||||
const state = getT3RecallState()
|
||||
const userQuery = state.query
|
||||
|
||||
if (!userQuery.trim()) {
|
||||
return
|
||||
|
|
@ -214,6 +293,8 @@ async function getRelatedMemoriesForT3(actionSource: string) {
|
|||
return
|
||||
}
|
||||
|
||||
request = t3RecallRequests.begin(state)
|
||||
|
||||
updateT3IconFeedback("Searching memories...", iconElement)
|
||||
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
|
|
@ -232,23 +313,12 @@ async function getRelatedMemoriesForT3(actionSource: string) {
|
|||
timeoutPromise,
|
||||
])
|
||||
|
||||
if (response?.success && response?.data) {
|
||||
let textareaElement = null
|
||||
const supermemoryContainer = document.querySelector(
|
||||
'[data-supermemory-icon-added="true"]',
|
||||
)
|
||||
if (supermemoryContainer?.parentElement?.previousElementSibling) {
|
||||
textareaElement =
|
||||
supermemoryContainer.parentElement.previousElementSibling.querySelector(
|
||||
"textarea",
|
||||
)
|
||||
}
|
||||
if (!t3RecallRequests.isCurrent(request, getT3RecallState())) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!textareaElement) {
|
||||
textareaElement = document.querySelector(
|
||||
'div[contenteditable="true"]',
|
||||
) as HTMLElement
|
||||
}
|
||||
if (response?.success && response?.data) {
|
||||
const textareaElement = state.input
|
||||
|
||||
if (textareaElement) {
|
||||
textareaElement.dataset.supermemories = buildSupermemoryText(
|
||||
|
|
@ -269,6 +339,9 @@ async function getRelatedMemoriesForT3(actionSource: string) {
|
|||
updateT3IconFeedback("No memories found", iconElement)
|
||||
}
|
||||
} catch (error) {
|
||||
if (request && !t3RecallRequests.isCurrent(request, getT3RecallState())) {
|
||||
return
|
||||
}
|
||||
console.error("Error getting related memories for T3:", error)
|
||||
try {
|
||||
const icon = document.querySelector(
|
||||
|
|
@ -681,19 +754,16 @@ function setupT3PromptCapture() {
|
|||
document.addEventListener("keydown", handleT3EnterKey, true)
|
||||
}
|
||||
|
||||
async function setupT3AutoFetch() {
|
||||
const autoSearch = (await autoSearchEnabled.getValue()) ?? false
|
||||
|
||||
if (!autoSearch) {
|
||||
export async function setupT3AutoFetch() {
|
||||
const textareaElement = getT3PromptInput()
|
||||
if (!textareaElement) {
|
||||
return
|
||||
}
|
||||
attachT3RecallFreshnessHandler(textareaElement)
|
||||
|
||||
const textareaElement =
|
||||
(document.querySelector("textarea") as HTMLTextAreaElement) ||
|
||||
(document.querySelector('div[contenteditable="true"]') as HTMLElement)
|
||||
|
||||
const autoSearch = (await autoSearchEnabled.getValue()) ?? false
|
||||
if (
|
||||
!textareaElement ||
|
||||
!autoSearch ||
|
||||
textareaElement.hasAttribute("data-supermemory-auto-fetch")
|
||||
) {
|
||||
return
|
||||
|
|
@ -705,37 +775,19 @@ async function setupT3AutoFetch() {
|
|||
if (t3DebounceTimeout) {
|
||||
clearTimeout(t3DebounceTimeout)
|
||||
}
|
||||
const content = getT3InputText(textareaElement).trim()
|
||||
const scheduledRequest = t3RecallRequests.begin(getT3RecallState())
|
||||
|
||||
t3DebounceTimeout = setTimeout(async () => {
|
||||
let content = ""
|
||||
if (textareaElement.tagName === "TEXTAREA") {
|
||||
content = (textareaElement as HTMLTextAreaElement).value?.trim() || ""
|
||||
} else {
|
||||
content = textareaElement.textContent?.trim() || ""
|
||||
t3DebounceTimeout = null
|
||||
if (!t3RecallRequests.isCurrent(scheduledRequest, getT3RecallState())) {
|
||||
return
|
||||
}
|
||||
|
||||
if (content.length > 2) {
|
||||
await getRelatedMemoriesForT3(
|
||||
POSTHOG_EVENT_KEY.T3_CHAT_MEMORIES_AUTO_SEARCHED,
|
||||
)
|
||||
} else if (content.length === 0) {
|
||||
const icons = document.querySelectorAll(
|
||||
'[id*="sm-t3-input-bar-element"]',
|
||||
)
|
||||
|
||||
icons.forEach((icon) => {
|
||||
const iconElement = icon as HTMLElement
|
||||
if (iconElement.dataset.originalHtml) {
|
||||
iconElement.innerHTML = iconElement.dataset.originalHtml
|
||||
delete iconElement.dataset.originalHtml
|
||||
delete iconElement.dataset.memoriesData
|
||||
}
|
||||
})
|
||||
|
||||
if (textareaElement.dataset.supermemories) {
|
||||
delete textareaElement.dataset.supermemories
|
||||
}
|
||||
disposeT3IncludedPopup()
|
||||
}
|
||||
}, UI_CONFIG.AUTO_SEARCH_DEBOUNCE_DELAY)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue