mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
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
This commit is contained in:
parent
ca1dbb80f7
commit
5380154dca
6 changed files with 56 additions and 20 deletions
|
|
@ -29,7 +29,7 @@ O Sollinha foi pensado para trabalhar **em colaboração** com o usuário, mas h
|
|||
## Objetivo e aceite
|
||||
|
||||
- Clicar num link do Sollinha navega **sem recarregar a página** (navegação por cliente do app), e a conversa continua presente ao reabrir o chat.
|
||||
- Recarregar a página na mesma aba restaura a conversa (mensagens e estado em andamento).
|
||||
- Recarregar a página na mesma aba restaura a conversa (mensagens e estado em andamento). _Nota de impl (B188): a recarga no meio de uma resposta perde apenas aquele turno em voo — o storage só guarda o último estado settle; o chat recomeça `ready` e o usuário repete a pergunta._
|
||||
- Fechar a aba apaga tudo; outra aba inicia sem histórico.
|
||||
- Nada é gravado no servidor/banco; nada cruza abas ou dispositivos.
|
||||
- Se o chat estava aberto ao recarregar, ele volta aberto na mesma superfície (painel/drawer conforme o viewport).
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ const APP_INTERNAL_LINK = /^\/campanha(?:\/|$)/
|
|||
|
||||
const MarkdownLink = ({
|
||||
href,
|
||||
node: _node,
|
||||
children,
|
||||
...props
|
||||
}: React.AnchorHTMLAttributes<HTMLAnchorElement> & { node?: unknown }) => {
|
||||
|
|
|
|||
|
|
@ -49,8 +49,6 @@ export const CampaignAISidebarProvider = ({
|
|||
id: 'campaign-sollinha',
|
||||
transport: useMemo(() => new DefaultChatTransport({ api: '/campanha/api/ai-chat' }), []),
|
||||
})
|
||||
// `setMessages` is a stable `useCallback` over the chat instance — safe as
|
||||
// the single dependency of the mount-only restore effect.
|
||||
const { messages, setMessages, status } = chat
|
||||
|
||||
// B188: restore the conversation and the open state from the tab's
|
||||
|
|
@ -59,19 +57,23 @@ export const CampaignAISidebarProvider = ({
|
|||
// `sessionRestored` gates the persist effect so the mount commit (empty
|
||||
// pre-restore values) cannot overwrite a stored session.
|
||||
const [sessionRestored, setSessionRestored] = useState(false)
|
||||
const restoredSessionRef = useRef(false)
|
||||
useEffect(() => {
|
||||
const session = readSollinhaChatSession()
|
||||
if (session) {
|
||||
setMessages(session.messages)
|
||||
setOpen(session.open)
|
||||
}
|
||||
restoredSessionRef.current = session !== null
|
||||
setSessionRestored(true)
|
||||
}, [setMessages])
|
||||
|
||||
// B188: persist the conversation only once the chat has settled — writing
|
||||
// during streaming would freeze a half-received message in the session
|
||||
// (a reload mid-stream loses only that in-flight turn). `open` changes made
|
||||
// mid-stream are picked up by the settle that follows.
|
||||
// mid-stream are picked up by the settle that follows. Empty state is still
|
||||
// written: closing an empty chat must overwrite a stored `open: true`,
|
||||
// otherwise a reload would reopen a drawer the user just closed.
|
||||
useEffect(() => {
|
||||
if (!sessionRestored || status !== 'ready') return
|
||||
writeSollinhaChatSession(messages, open)
|
||||
|
|
@ -84,12 +86,14 @@ export const CampaignAISidebarProvider = ({
|
|||
// on the desktop settle the panel starts at its RRP default (25%) while `open`
|
||||
// begins false — reconcile `open` to the panel's real visibility so a visible
|
||||
// chat is "open" (makes close work and the drawer open on resize). A fresh
|
||||
// mobile visit keeps `open` false (no spontaneous chat).
|
||||
// mobile visit keeps `open` false (no spontaneous chat). When a session was
|
||||
// restored, the restored `open` wins instead — a chat closed before the
|
||||
// reload must not be forced back open by the settle (B188).
|
||||
const settledRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (settledRef.current) return
|
||||
settledRef.current = measured
|
||||
if (measured && !isMobile && !panelRef.current?.isCollapsed()) {
|
||||
if (measured && !isMobile && !restoredSessionRef.current && !panelRef.current?.isCollapsed()) {
|
||||
setOpen(true)
|
||||
}
|
||||
}, [measured, isMobile, panelRef])
|
||||
|
|
@ -101,13 +105,13 @@ export const CampaignAISidebarProvider = ({
|
|||
open,
|
||||
isMobile,
|
||||
measured,
|
||||
messages: chat.messages,
|
||||
status: chat.status,
|
||||
messages,
|
||||
status,
|
||||
sendMessage: chat.sendMessage,
|
||||
setOpen,
|
||||
toggle,
|
||||
}),
|
||||
[open, isMobile, measured, chat.messages, chat.status, chat.sendMessage, setOpen, toggle],
|
||||
[open, isMobile, measured, messages, status, chat.sendMessage, setOpen, toggle],
|
||||
)
|
||||
|
||||
return <AISidebarContext.Provider value={value}>{children}</AISidebarContext.Provider>
|
||||
|
|
|
|||
|
|
@ -33,7 +33,8 @@ const isUIMessage = (value: unknown): value is UIMessage => {
|
|||
typeof value.id === 'string' &&
|
||||
typeof value.role === 'string' &&
|
||||
MESSAGE_ROLES.has(value.role) &&
|
||||
Array.isArray(value.parts)
|
||||
Array.isArray(value.parts) &&
|
||||
value.parts.every((part) => isRecord(part) && typeof part.type === 'string')
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -61,9 +62,11 @@ export const readSollinhaChatSession = (): SollinhaChatSession | null => {
|
|||
}
|
||||
|
||||
/**
|
||||
* Drops oldest messages until the count and byte guardrails hold. Tool outputs
|
||||
* are kept intact: `convertToModelMessages` turns complete tool parts back into
|
||||
* `tool-call` + `tool-result` model messages, so the model keeps the full
|
||||
* 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[] => {
|
||||
|
|
@ -75,6 +78,9 @@ export const pruneSollinhaChatMessages = (messages: UIMessage[]): UIMessage[] =>
|
|||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,14 +5,23 @@ import { expect, test } from './fixtures/campaignE2EFixtures.js'
|
|||
/**
|
||||
* Mock the AI endpoint with a minimal SSE stream so sending a message neither
|
||||
* needs a DeepSeek API key nor hits the real rate limiter — the user message is
|
||||
* still recorded client-side, which is all these assertions need.
|
||||
* still recorded client-side, which is all these assertions need. The chunks
|
||||
* follow the SDK v7 UI-message-stream wire format (start / text-start /
|
||||
* text-delta / text-end / finish): a bare legacy `{"type":"text"}` chunk fails
|
||||
* the client schema and leaves the chat stuck in an error status.
|
||||
*/
|
||||
const mockAiChat = (page: Page) =>
|
||||
page.route('**/campanha/api/ai-chat', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
body: 'data: {"type":"text","text":"Resposta mockada da Sollinha."}\n\n',
|
||||
body: [
|
||||
'data: {"type":"start"}\n\n',
|
||||
'data: {"type":"text-start","id":"t1"}\n\n',
|
||||
'data: {"type":"text-delta","id":"t1","delta":"Resposta mockada da Sollinha."}\n\n',
|
||||
'data: {"type":"text-end","id":"t1"}\n\n',
|
||||
'data: {"type":"finish","finishReason":"stop"}\n\n',
|
||||
].join(''),
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -143,7 +152,6 @@ test.describe('B167 — chat Sollinha migra entre painel e drawer ao redimension
|
|||
await campaign.login(page, user.email!, user.password)
|
||||
|
||||
// Carga direto em mobile: sem drawer espontâneo, só o FAB.
|
||||
await page.setViewportSize({ width: 500, height: 800 })
|
||||
await page.goto('/campanha')
|
||||
await expect(page.getByRole('dialog', { name: 'Sollinha — Assistente virtual' })).toHaveCount(0)
|
||||
await expect(page.getByRole('button', { name: 'Sollinha — Assistente virtual' })).toBeVisible({
|
||||
|
|
|
|||
|
|
@ -61,11 +61,27 @@ describe('sollinhaChatSession storage', () => {
|
|||
makeMessage(String(index), index % 2 === 0 ? 'user' : 'assistant'),
|
||||
)
|
||||
const pruned = pruneSollinhaChatMessages(messages)
|
||||
expect(pruned).toHaveLength(SOLLINHA_CHAT_MAX_MESSAGES)
|
||||
expect(pruned[0]?.id).toBe('5')
|
||||
// Dropping the 5 oldest turns leaves an assistant head (id "5"); the head
|
||||
// rule then drops it too, so the conversation still opens with a question.
|
||||
expect(pruned).toHaveLength(SOLLINHA_CHAT_MAX_MESSAGES - 1)
|
||||
expect(pruned[0]?.id).toBe('6')
|
||||
expect(pruned[0]?.role).toBe('user')
|
||||
expect(pruned.at(-1)?.id).toBe(String(SOLLINHA_CHAT_MAX_MESSAGES + 4))
|
||||
})
|
||||
|
||||
it('drops leading assistant turns after pruning so the model history opens with a question', () => {
|
||||
const messages = [
|
||||
makeMessage('0', 'assistant'),
|
||||
makeMessage('1', 'user'),
|
||||
makeMessage('2', 'assistant'),
|
||||
]
|
||||
const pruned = pruneSollinhaChatMessages(messages)
|
||||
expect(pruned).toHaveLength(2)
|
||||
expect(pruned[0]?.role).toBe('user')
|
||||
// A single assistant message survives (a user turn would be invented).
|
||||
expect(pruneSollinhaChatMessages([makeMessage('0', 'assistant')])).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('prunes to the byte guardrail dropping the oldest until it fits', () => {
|
||||
const big = 'x'.repeat(SOLLINHA_CHAT_MAX_BYTES)
|
||||
const pruned = pruneSollinhaChatMessages([
|
||||
|
|
@ -83,8 +99,9 @@ describe('sollinhaChatSession storage', () => {
|
|||
)
|
||||
writeSollinhaChatSession(messages, false)
|
||||
const session = readSollinhaChatSession()
|
||||
expect(session?.messages).toHaveLength(SOLLINHA_CHAT_MAX_MESSAGES)
|
||||
expect(session?.messages[0]?.id).toBe('5')
|
||||
expect(session?.messages).toHaveLength(SOLLINHA_CHAT_MAX_MESSAGES - 1)
|
||||
expect(session?.messages[0]?.id).toBe('6')
|
||||
expect(session?.messages[0]?.role).toBe('user')
|
||||
expect(session?.open).toBe(false)
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue