teqo-backup/src/lib/sollinhaChatSession.ts
Francisco Solla 5380154dca fix(B188): simplify — settle não reabre chat fechado no reload desktop; prune com cabeça de pergunta; node fora do Link; mock SSE v7 no B167
- P1: B167 settle forçava open=true no load desktop mesmo com sessão restaurada
  open:false (storage auto-curava para true); restore agora vence quando existe
- prune: cabeça assistant quebra convertToModelMessages; drop até ancorar em user
- MarkdownLink: node do react-markdown não vaza para <Link>/<a>
- isUIMessage: parts com shape inválido falham-closed no restore
- B167 mock: chunk legado {"type":"text"} falha schema v7 (status preso em erro);
  agora usa start/text-start/text-delta/text-end/finish
2026-08-10 01:34:53 -03:00

100 lines
3.5 KiB
TypeScript

/**
* Per-tab Sollinha chat session (B188): the conversation plus the open state
* live in `sessionStorage`, so a reload of the same tab restores the chat, a
* new tab starts clean, and closing the tab erases everything. Nothing is
* written to the server and nothing crosses tabs.
*/
import type { UIMessage } from 'ai'
export const SOLLINHA_CHAT_SESSION_STORAGE_KEY = 'teqo:campaign:sollinha-chat-session'
export const SOLLINHA_CHAT_SESSION_VERSION = 1
/** Count guardrail — oldest messages are dropped at write time, no UI warning. */
export const SOLLINHA_CHAT_MAX_MESSAGES = 50
/** Byte guardrail (serialized JSON) — oldest messages are dropped at write time. */
export const SOLLINHA_CHAT_MAX_BYTES = 256 * 1024
export type SollinhaChatSession = {
version: typeof SOLLINHA_CHAT_SESSION_VERSION
messages: UIMessage[]
open: boolean
}
const MESSAGE_ROLES = new Set(['system', 'user', 'assistant'])
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value)
const isUIMessage = (value: unknown): value is UIMessage => {
if (!isRecord(value)) return false
return (
typeof value.id === 'string' &&
typeof value.role === 'string' &&
MESSAGE_ROLES.has(value.role) &&
Array.isArray(value.parts) &&
value.parts.every((part) => isRecord(part) && typeof part.type === 'string')
)
}
/** Fail-closed: any shape drift (or a future version bump) yields a fresh chat. */
const isSollinhaChatSession = (value: unknown): value is SollinhaChatSession => {
if (!isRecord(value)) return false
return (
value.version === SOLLINHA_CHAT_SESSION_VERSION &&
Array.isArray(value.messages) &&
value.messages.every(isUIMessage) &&
typeof value.open === 'boolean'
)
}
export const readSollinhaChatSession = (): SollinhaChatSession | null => {
if (typeof window === 'undefined') return null
try {
const raw = window.sessionStorage.getItem(SOLLINHA_CHAT_SESSION_STORAGE_KEY)
if (!raw) return null
const parsed: unknown = JSON.parse(raw)
return isSollinhaChatSession(parsed) ? parsed : null
} catch {
return null
}
}
/**
* Drops oldest messages until the count and byte guardrails hold, then ensures
* the head is a user message: `convertToModelMessages` (server) rejects a
* conversation that starts with an assistant turn, and pruning from the front
* can leave one. Tool outputs are kept intact: complete tool parts turn back
* into `tool-call` + `tool-result` model messages, so the model keeps the full
* context when the restored conversation continues.
*/
export const pruneSollinhaChatMessages = (messages: UIMessage[]): UIMessage[] => {
let pruned = messages
if (pruned.length > SOLLINHA_CHAT_MAX_MESSAGES) {
pruned = pruned.slice(-SOLLINHA_CHAT_MAX_MESSAGES)
}
while (pruned.length > 0) {
if (JSON.stringify(pruned).length <= SOLLINHA_CHAT_MAX_BYTES) break
pruned = pruned.slice(1)
}
while (pruned.length > 1 && pruned[0]?.role !== 'user') {
pruned = pruned.slice(1)
}
return pruned
}
/** Writes the pruned session. Fail-open: storage failures never break the chat. */
export const writeSollinhaChatSession = (messages: UIMessage[], open: boolean): void => {
if (typeof window === 'undefined') return
const session: SollinhaChatSession = {
version: SOLLINHA_CHAT_SESSION_VERSION,
messages: pruneSollinhaChatMessages(messages),
open,
}
try {
window.sessionStorage.setItem(SOLLINHA_CHAT_SESSION_STORAGE_KEY, JSON.stringify(session))
} catch {
// Ignore quota / private-mode failures.
}
}