ai: persist agent-history + per-message attribution to localStorage
Reloading the page mid-conversation used to keep the messages (via
LIVE_KEY) but lose the two agent-tracking maps, so:
- row signatures snapped back to whoever was currently active
- the next turn after reload didn't include the PRIOR HAND-OFF
block, even though the transcript clearly had multiple personas
Both maps are now stored alongside the live snapshot:
AGENTS_KEY crema.ai.agent-history set of Agent
MSG_AGENTS_KEY crema.ai.message-agents index -> Agent
Stored as JSON arrays since Maps don't serialize. Hydrated on mount
via useState lazy initializer, persisted on every change via two
useEffects, cleared in lockstep with LIVE_KEY when the operator
hits Clear conversation.
Reload mid-thread now reads identically to the pre-reload state:
- atlas» turns 1-3 stay attributed to atlas
- pythia» turn 4 stays attributed to pythia
- next turn after reload still carries the hand-off note
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -115,6 +115,65 @@ function saveLive(msgs: LLMMessage[]) {
|
||||
// Quota exceeded or similar — silently drop persistence.
|
||||
}
|
||||
}
|
||||
/* Per-message agent attribution + the set of agents that have produced
|
||||
* turns in the current conversation. Persisted alongside LIVE_KEY so a
|
||||
* reload mid-thread preserves both who-said-what and the hand-off note
|
||||
* the next turn carries.
|
||||
*
|
||||
* Stored as plain JSON shapes (Maps don't serialize):
|
||||
* AGENTS_KEY: Array<[agentId, Agent]> ← agentHistory
|
||||
* MSG_AGENTS_KEY: Array<[index, Agent]> ← messageAgents
|
||||
*/
|
||||
const AGENTS_KEY = "crema.ai.agent-history"
|
||||
const MSG_AGENTS_KEY = "crema.ai.message-agents"
|
||||
|
||||
function loadAgentHistory(): Map<string, Agent> {
|
||||
if (typeof window === "undefined") return new Map()
|
||||
try {
|
||||
const raw = localStorage.getItem(AGENTS_KEY)
|
||||
if (!raw) return new Map()
|
||||
const parsed = JSON.parse(raw)
|
||||
if (Array.isArray(parsed)) return new Map(parsed as [string, Agent][])
|
||||
} catch {}
|
||||
return new Map()
|
||||
}
|
||||
function saveAgentHistory(m: Map<string, Agent>) {
|
||||
if (typeof window === "undefined") return
|
||||
if (m.size === 0) {
|
||||
localStorage.removeItem(AGENTS_KEY)
|
||||
return
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(AGENTS_KEY, JSON.stringify([...m.entries()]))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function loadMessageAgents(): Map<number, Agent> {
|
||||
if (typeof window === "undefined") return new Map()
|
||||
try {
|
||||
const raw = localStorage.getItem(MSG_AGENTS_KEY)
|
||||
if (!raw) return new Map()
|
||||
const parsed = JSON.parse(raw)
|
||||
if (Array.isArray(parsed)) return new Map(parsed as [number, Agent][])
|
||||
} catch {}
|
||||
return new Map()
|
||||
}
|
||||
function saveMessageAgents(m: Map<number, Agent>) {
|
||||
if (typeof window === "undefined") return
|
||||
if (m.size === 0) {
|
||||
localStorage.removeItem(MSG_AGENTS_KEY)
|
||||
return
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(MSG_AGENTS_KEY, JSON.stringify([...m.entries()]))
|
||||
} catch {}
|
||||
}
|
||||
function clearAgentMaps() {
|
||||
if (typeof window === "undefined") return
|
||||
localStorage.removeItem(AGENTS_KEY)
|
||||
localStorage.removeItem(MSG_AGENTS_KEY)
|
||||
}
|
||||
|
||||
function clearLive() {
|
||||
if (typeof window === "undefined") return
|
||||
localStorage.removeItem(LIVE_KEY)
|
||||
@@ -369,19 +428,29 @@ function ChatSurface({
|
||||
// the new agent knows it's stepping into a transcript started by another
|
||||
// persona — without that note it answers as if it produced every prior
|
||||
// turn itself, which is jarring.
|
||||
// Both maps are seeded from localStorage so a reload mid-thread keeps
|
||||
// attribution + hand-off context intact. Persisted on every change via
|
||||
// the effect lower down, cleared together with the live snapshot.
|
||||
const [agentHistory, setAgentHistory] = useState<Map<string, Agent>>(
|
||||
() => new Map(),
|
||||
() => loadAgentHistory(),
|
||||
)
|
||||
const prevAgentRef = useRef<Agent | undefined>(activeAgent)
|
||||
|
||||
// Per-message agent attribution: which agent produced the assistant
|
||||
// message at each index. Populated when a turn finishes streaming, used
|
||||
// in MessageRow to show the right name in the signature line. Survives
|
||||
// until the conversation is cleared.
|
||||
// in MessageRow to show the right name in the signature line.
|
||||
const [messageAgents, setMessageAgents] = useState<Map<number, Agent>>(
|
||||
() => new Map(),
|
||||
() => loadMessageAgents(),
|
||||
)
|
||||
|
||||
// Persist whenever either map changes.
|
||||
useEffect(() => {
|
||||
saveAgentHistory(agentHistory)
|
||||
}, [agentHistory])
|
||||
useEffect(() => {
|
||||
saveMessageAgents(messageAgents)
|
||||
}, [messageAgents])
|
||||
|
||||
// Hand-off prompt — only emitted when this conversation has been touched
|
||||
// by more than one agent. Lists prior personas the new agent might see in
|
||||
// the transcript.
|
||||
@@ -439,6 +508,7 @@ function ChatSurface({
|
||||
const resetAndClear = useCallback(() => {
|
||||
initialLive.current = []
|
||||
clearLive()
|
||||
clearAgentMaps()
|
||||
setMessages([])
|
||||
setAgentHistory(new Map())
|
||||
setMessageAgents(new Map())
|
||||
|
||||
Reference in New Issue
Block a user