Transmitir uma conversa persistente

CloudflareBeginner
Pratique Agora

Introdução

Um assistente de suporte parece responsivo quando as palavras chegam enquanto o modelo ainda está gerando a resposta. Ele também transmite confiança quando atualizar a página não apaga a conversa. Essas são necessidades de engenharia diferentes: o streaming entrega partes incrementais da resposta, enquanto a persistência salva as mensagens concluídas para que a mesma conversa nomeada possa ser restaurada mais tarde.

Neste laboratório, você adicionará os dois comportamentos com a integração de chat compatível com o Cloudflare:

  1. AIChatAgent armazena as mensagens do chat e os dados do streaming retomável no Durable Object do Agent, baseado em SQLite.
  2. streamText() produz uma resposta limitada do Workers AI em vez de esperar pela resposta completa.
  3. useAgentChat() transforma esses trechos em uma lista de mensagens React e restaura o histórico salvo.
  4. Um token assinado de curta duração limita cada solicitação de WebSocket e de histórico a uma única conversa nomeada.

O cliente do navegador é fornecido como um pequeno fixture, portanto React não é um pré-requisito oculto. Você editará somente as chamadas atuais dos hooks e a renderização das mensagens necessárias para este conceito do Agents SDK. O cenário usa texto sintético de suporte, uma resposta curta do modelo e recursos descartáveis. As alocações gratuitas são compartilhadas com outras atividades da conta; se não houver mais alocação do Workers AI disponível, pare em vez de ativar um plano pago.

Antes de entrar diretamente neste curso, conclua Connect LabEx to Your Cloudflare Account. Cada nova VM do LabEx precisa da própria autorização do Wrangler. S01 e S02 são recomendados porque este laboratório usa identidade nomeada de Agent, estado SQLite e clientes WebSocket, mas as VMs e os recursos desses laboratórios não são reutilizados aqui.

Autorizar a VM e configurar o Worker do chat

Nesta etapa, você autorizará a VM recém-criada e definirá os três bindings do Cloudflare necessários para o chat.

Cada chat nomeado é respaldado por uma instância de Durable Object com SQLite. O Worker também precisa de um binding do Workers AI para inferência e de um binding secreto para delimitar a sessão.

Abra um terminal e entre no projeto preparado:

cd /home/labex/project/persistent-support-chat

Autorize esta VM recém-criada:

npx wrangler login

Abra o link exibido, aprove as permissões do Wrangler descritas para sua conta de aprendizagem dedicada e retorne ao terminal. Confirme o resultado estruturado:

npx wrangler whoami --json

Procure por "loggedIn": true, confirme o nome da conta e copie o ID real dessa conta. Salve-o explicitamente junto com um nome exclusivo para o Worker descartável:

ACCOUNT_ID="paste-your-confirmed-account-id"
RUN="labex-c11-s03-$(openssl rand -hex 6)"
cat > wrangler.jsonc <<JSON
{
  "\$schema": "./node_modules/wrangler/config-schema.json",
  "name": "$RUN",
  "account_id": "$ACCOUNT_ID",
  "main": "src/server.ts",
  "compatibility_date": "2026-09-18",
  "compatibility_flags": ["nodejs_compat"],
  "workers_dev": true,
  "preview_urls": false,
  "observability": { "enabled": true },
  "ai": { "binding": "AI", "remote": true },
  "durable_objects": {
    "bindings": [
      { "name": "SupportChatAgent", "class_name": "SupportChatAgent" }
    ]
  },
  "migrations": [
    { "tag": "v1", "new_sqlite_classes": ["SupportChatAgent"] }
  ]
}
JSON

O binding AI dá ao Worker acesso ao Workers AI sem incorporar uma chave de API. O Workers AI sempre usa um modelo hospedado pelo Cloudflare, inclusive durante o desenvolvimento local; remote: true torna esse comportamento explícito. O binding do Durable Object associa um nome de classe; mais tarde, o navegador fornecerá o nome separado da instância, planning. Nada foi implantado ainda.

Implementar um AIChatAgent com limites

Nesta etapa, você implementará a classe de chat no servidor, a inferência limitada e o limite de roteamento assinado.

AIChatAgent especializa o Agent base com uma transcrição de chat persistente e armazenamento retomável do streaming. Você fornece a chamada do modelo; a integração cuida do protocolo de chat e da persistência.

Crie src/server.ts:

cat > src/server.ts <<'TS'
import { AIChatAgent, type OnChatMessageOptions } from "@cloudflare/ai-chat";
import { convertToModelMessages, streamText } from "ai";
import { routeAgentRequest } from "agents";
import { createWorkersAI } from "workers-ai-provider";
import { verifySessionRequest } from "./session-auth";

interface Env {
  AI: Ai;
  SupportChatAgent: DurableObjectNamespace<SupportChatAgent>;
  SESSION_SIGNING_KEY: string;
}

export class SupportChatAgent extends AIChatAgent<Env> {
  maxPersistedMessages = 12;

  async onChatMessage(_onFinish: unknown, options?: OnChatMessageOptions) {
    console.log(JSON.stringify({
      event: "support_chat_turn_started",
      requestId: options?.requestId ?? "unknown",
      messageCount: this.messages.length,
      continuation: Boolean(options?.continuation)
    }));

    const workersai = createWorkersAI({ binding: this.env.AI });
    const result = streamText({
      model: workersai("@cf/zai-org/glm-4.7-flash", {
        reasoning_effort: null,
        chat_template_kwargs: { enable_thinking: false }
      }),
      system: "You are a concise support assistant. Answer synthetic questions in one sentence and never request credentials.",
      messages: await convertToModelMessages(this.messages),
      maxOutputTokens: 64,
      temperature: 0,
      abortSignal: options?.abortSignal
    });

    return result.toUIMessageStreamResponse();
  }
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const authorize = (candidate: Request, route: { name: string }) =>
      verifySessionRequest(candidate, route.name, env.SESSION_SIGNING_KEY);
    return (await routeAgentRequest(request, env, {
      onBeforeConnect: authorize,
      onBeforeRequest: authorize
    })) ?? new Response("Not found", { status: 404 });
  }
};
TS

Três limites são importantes aqui. maxPersistedMessages limita o crescimento da transcrição armazenada, maxOutputTokens limita cada resposta do modelo e o prompt do sistema solicita uma única frase. O GLM 4.7 Flash pode usar o orçamento de tokens para raciocínio interno antes de produzir texto visível. Por isso, este fluxo curto de suporte desativa explicitamente o pensamento; o aluno vê uma resposta concisa em vez de um balão vazio do assistente. Encaminhar abortSignal permite que o SDK cancele a inferência upstream quando uma execução é interrompida explicitamente.

Os dois hooks de roteamento usam o verificador HMAC fornecido. onBeforeConnect protege o handshake do WebSocket; onBeforeRequest também protege helpers HTTP, como /get-messages. O navegador recebe uma declaração assinada, nunca o segredo usado para assinar. O log registra um ID de solicitação e uma contagem, mas exclui deliberadamente o texto do suporte.

Conectar os hooks de chat React compatíveis

Nesta etapa, você conectará a estrutura de página fornecida aos hooks React compatíveis atuais.

O HTML e os estilos preparados são apenas uma estrutura. Agora conecte essa estrutura ao Agent nomeado. Crie a configuração do TypeScript e do Vite:

cat > tsconfig.json <<'JSON'
{
  "extends": "agents/tsconfig",
  "compilerOptions": {
    "jsx": "react-jsx",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "types": ["@cloudflare/workers-types", "vite/client", "node"]
  },
  "include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts", "worker-configuration.d.ts"]
}
JSON

cat > vite.config.ts <<'TS'
import { cloudflare } from "@cloudflare/vite-plugin";
import react from "@vitejs/plugin-react";
import agents from "agents/vite";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [react(), agents(), cloudflare()]
});
TS

Crie src/client.tsx:

cat > src/client.tsx <<'TSX'
import { useAgentChat } from "@cloudflare/ai-chat/react";
import { useAgent } from "agents/react";
import { Suspense } from "react";
import { createRoot } from "react-dom/client";

function SupportChat() {
  const parameters = new URLSearchParams(window.location.search);
  const session = parameters.get("session") ?? "";
  const token = parameters.get("token") ?? "";

  if (!session || !token) {
    return <main><h1>Signed session required</h1><p className="help">Open the complete URL printed by the token command.</p></main>;
  }

  const agent = useAgent({
    agent: "SupportChatAgent",
    name: session,
    host: window.location.host,
    query: { token }
  });
  const { messages, sendMessage, status, error } = useAgentChat({ agent });

  return (
    <main>
      <p className="eyebrow">Cloudflare Agents SDK</p>
      <h1>Persistent Support Chat</h1>
      <p className="session">Conversation: <strong>{session}</strong></p>
      <p className="status">Status: <strong>{status}</strong></p>
      <section className="messages" aria-live="polite">
        {messages.length === 0 && <p className="empty">No saved messages in this conversation.</p>}
        {messages.map((message) => (
          <article className={`message ${message.role}`} key={message.id}>
            <span className="role">{message.role}</span>
            {message.parts.map((part, index) =>
              part.type === "text" ? <span key={index}>{part.text}</span> : null
            )}
          </article>
        ))}
      </section>
      <form => {
        event.preventDefault();
        const input = event.currentTarget.elements.namedItem("message") as HTMLInputElement;
        const text = input.value.trim();
        if (!text) return;
        sendMessage({ text });
        input.value = "";
      }}>
        <input name="message" defaultValue="What does pending invoice status mean?" maxLength={160} aria-label="Support question" />
        <button type="submit" disabled={status === "streaming" || status === "submitted"}>Send</button>
      </form>
      {error && <p className="error" role="alert">{error.message}</p>}
    </main>
  );
}

createRoot(document.getElementById("root")!).render(
  <Suspense fallback={<main><p>Restoring the signed conversation…</p></main>}>
    <SupportChat />
  </Suspense>
);
TSX

useAgent() gerencia a conexão WebSocket assinada com SupportChatAgent:<session>. useAgentChat() adiciona o protocolo de chat de IA a essa conexão: mensagens, status do streaming, envio e restauração inicial do histórico. O token é enviado na URL da conexão porque os handshakes de WebSocket do navegador não podem adicionar um cabeçalho de autorização personalizado; ele expira após dez minutos e é limitado a uma conversa sintética.

Gerar os tipos e compilar os dois lados

Nesta etapa, você gerará os tipos exatos do ambiente e compilará as duas partes antes de iniciar um runtime.

O Wrangler pode gerar tipos exatos dos bindings a partir da configuração. Execute-o antes das compilações normais do TypeScript e do Vite:

npx wrangler types
npm run check
npm run build

A verificação de tipos associa this.env.AI, o namespace do Durable Object e o binding secreto ao Env declarado. A compilação do Vite produz um bundle do Worker e um bundle do navegador; a saída bem-sucedida deve incluir dist/client/index.html.

Testar localmente o limite assinado

Nesta etapa, você iniciará o runtime local e testará o controle de acesso sem consumir uma chamada do modelo.

O Workers AI é um binding remoto, portanto o runtime local do Vite precisa do acesso OAuth já armazenado pelo Wrangler. Leia esse acesso diretamente em uma variável de shell de curta duração, passe-o somente ao processo filho e limpe imediatamente a cópia no shell:

DEV_PROXY_TOKEN="$(npx wrangler auth token --json | node -e 'let data="";process.stdin.on("data",chunk=>data+=chunk).on("end",()=>process.stdout.write(JSON.parse(data).token))')"
CLOUDFLARE_API_TOKEN="$DEV_PROXY_TOKEN" CI=true npm run dev > .labex/dev.log 2>&1 < /dev/null &
echo $! > .labex/dev.pid
unset DEV_PROXY_TOKEN

Não imprima esse valor nem o salve em .dev.vars. Trata-se do acesso OAuth temporário existente do Wrangler, não de um novo API Token. CI=true e o redirecionamento da entrada padrão mantêm o processo do Vite separado depois que o terminal retornar.

Aguarde até que a URL apareça:

until curl -fsS http://127.0.0.1:5173/ >/dev/null; do sleep 1; done
tail -n 12 .labex/dev.log

Execute a verificação local independente:

python3 .labex/verify.py local

Essa verificação não consome intencionalmente uma chamada do modelo. Ela comprova que uma nova sessão assinada corretamente pode ler seu histórico vazio, enquanto uma solicitação sem assinatura e um token válido limitado a outro nome recebem HTTP 401. O Miniflare local usa os mesmos hooks de roteamento e o mesmo segredo de .dev.vars.

Implantar e observar o streaming persistente

Nesta etapa, você fará a implantação, observará uma resposta real transmitida em fluxo, restaurará essa resposta após atualizar a página e comprovará o isolamento entre sessões.

Implante a build de produção e envie a chave de assinatura gerada como um segredo do Worker:

npm run deploy
npx wrangler secret bulk .dev.vars

O comando de segredo envia o valor ao Cloudflare sem colocá-lo em wrangler.jsonc nem no bundle. Não imprima .dev.vars.

Salve a origem workers.dev exata exibida pela implantação bem-sucedida e crie um token de dez minutos para a conversa planning:

WORKER_URL="https://paste-the-workers-dev-origin-printed-by-deploy"
TOKEN="$(node scripts/create-session-token.mjs planning)"
printf '%s/?session=planning&token=%s\n' "${WORKER_URL%/}" "$TOKEN"

WORKER_URL contém somente a origem, sem barra final ou caminho. Mantenha o token nesta sessão do terminal e não o cole em anotações ou capturas de tela.

Abra a URL completa. O status inicial deve chegar a ready, e a página deve informar que não há mensagens salvas. Envie a pergunta sintética preparada. Observe submitted mudar para streaming e depois voltar a ready enquanto o texto chega.

A conversa planning após uma resposta de suporte transmitida em fluxo

O recurso e a resposta exibidos são exemplos da execução descartável testada. O texto exato pode variar porque a saída do modelo é não determinística.

Atualize a mesma URL. As mensagens concluídas do usuário e do assistente devem retornar do SQLite, em vez de começar novamente:

A mesma conversa planning restaurada após atualizar a página

Agora comprove o isolamento por nome. Gere e abra uma URL assinada separadamente:

PRIVATE_TOKEN="$(node scripts/create-session-token.mjs private)"
printf '%s/?session=private&token=%s\n' "${WORKER_URL%/}" "$PRIVATE_TOKEN"

A página private está autorizada, mas pertence a uma instância de Agent nomeada diferente. Por isso, seu histórico está vazio:

Uma conversa private autorizada separadamente e com histórico vazio

Por fim, execute uma verificação remota independente e exclusiva desta execução. Ela faz uma chamada adicional limitada ao modelo, confirma vários trechos do streaming, busca as mensagens armazenadas do usuário e do assistente após a reconexão, verifica uma segunda sessão autorizada vazia e rejeita o acesso entre sessões:

python3 .labex/verify.py deployed

Inspecionar e remover os recursos do chat

Nesta etapa, você associará o comportamento do runtime às evidências do Dashboard e removerá somente os recursos deste laboratório.

No Cloudflare Dashboard, abra Workers & Pages, selecione o Worker exato labex-c11-s03-... e inspecione seus bindings. Você deverá ver o binding AI e o binding do Durable Object SupportChatAgent:

O Worker implantado com os bindings AI e SupportChatAgent

Abra Durable Objects e selecione o namespace baseado em SQL pertencente a este Worker. O namespace é a visão do recurso no Cloudflare; planning, private e os nomes usados pelo verificador são instâncias isoladas dentro dele:

O namespace SupportChatAgent baseado em SQL

Abra os logs ou a visão de observabilidade do Worker e encontre support_chat_turn_started. O evento mostra metadados limitados, como a contagem de mensagens, mas não o prompt do aluno nem a resposta do modelo:

Um log estruturado de chat com privacidade limitada

Depois da inspeção, crie uma migração de exclusão que remova somente o namespace de classe deste laboratório:

python3 - <<'PY'
import json
from pathlib import Path
path = Path('wrangler.jsonc')
data = json.loads(path.read_text())
data.pop('durable_objects', None)
data['migrations'].append({'tag': 'v2', 'deleted_classes': ['SupportChatAgent']})
Path('wrangler.cleanup.jsonc').write_text(json.dumps(data, indent=2) + '\n')
PY
npx wrangler deploy --config wrangler.cleanup.jsonc
npx wrangler delete --config wrangler.cleanup.jsonc --force

Confirme que o Worker não aparece mais em Workers & Pages:

O Worker descartável de chat removido

Em seguida, confirme que o namespace SupportChatAgent pertencente a este laboratório não aparece mais em Durable Objects:

O namespace descartável do chat removido

Execute a verificação autenticada de ausência enquanto esta VM ainda estiver autorizada:

python3 .labex/verify.py deleted

Excluir somente o Worker não é suficiente: a migração explícita deleted_classes torna o ciclo de vida do namespace com estado verificável e evita que o histórico sintético armazenado por este laboratório permaneça no ambiente.

Revogar a autorização desta VM

Nesta etapa, você revogará a autorização temporária desta VM depois de comprovar a limpeza na nuvem.

Os recursos na nuvem já foram removidos. Agora revogue a autorização OAuth armazenada nesta VM temporária:

npx wrangler logout
npx wrangler whoami --json || true

O resultado estruturado deve informar "loggedIn": false (ou o Wrangler pode retornar um resultado não autenticado com código diferente de zero). Esta etapa fica intencionalmente por último: a verificação da limpeza precisa de uma autorização válida, enquanto o logout protege a VM descartada depois.

Resumo

Você criou uma conversa de suporte persistente com streaming usando a integração de chat atual do Cloudflare. Você:

  • estendeu AIChatAgent e usou uma chamada limitada de streamText() do Workers AI;
  • conectou a estrutura React fornecida com useAgent() e useAgentChat();
  • protegeu as rotas de histórico por WebSocket e HTTP com uma assinatura que expira e é limitada à sessão;
  • observou o status incremental, atualizou a página para restaurar o histórico baseado em SQLite e comprovou que outra conversa nomeada permaneceu isolada;
  • inspecionou evidências do Cloudflare com privacidade limitada; e
  • excluiu o namespace exato da classe do Agent e o Worker antes de revogar a autorização da VM.

O próximo laboratório usa a mesma identidade persistente do Agent para acompanhamentos de suporte agendados. O agendamento é uma preocupação diferente de ciclo de vida: ele permite que o trabalho seja executado mais tarde, mesmo quando nenhum navegador continua conectado.