Потоковая передача постоянного диалога

CloudflareBeginner
Практиковаться сейчас

Введение

Помощник поддержки кажется отзывчивым, когда текст появляется ещё во время генерации ответа моделью. Он также вызывает больше доверия, если обновление страницы не стирает диалог. Это две разные инженерные задачи: потоковая передача доставляет части ответа по мере их готовности, а сохранение записывает завершённые сообщения, чтобы позже восстановить тот же именованный диалог.

В этой лабораторной работе вы добавите обе возможности с помощью поддерживаемой интеграции чата Cloudflare:

  1. AIChatAgent сохраняет сообщения чата и данные возобновляемого потока в Durable Object агента с хранилищем SQLite.
  2. streamText() создаёт ограниченный ответ Workers AI, не дожидаясь полной генерации.
  3. useAgentChat() превращает эти части в список сообщений React и восстанавливает сохранённую историю.
  4. Краткоживущий подписанный токен ограничивает каждый запрос WebSocket и истории одним именованным диалогом.

Клиент браузера предоставлен как небольшой готовый компонент, поэтому React не является скрытой предварительной зависимостью. Вы измените только текущие вызовы хуков и отображение сообщений, необходимые для изучения этой концепции Agents SDK. В сценарии используются синтетический текст поддержки, один короткий ответ модели и временные ресурсы. Бесплатные квоты используются совместно с другими операциями аккаунта; если в аккаунте больше не осталось квоты Workers AI, остановитесь и не подключайте платный план.

Перед непосредственным началом этого курса выполните лабораторную работу Подключение LabEx к вашему аккаунту Cloudflare. Для каждой новой виртуальной машины LabEx требуется отдельная авторизация Wrangler. Лабораторные работы S01 и S02 рекомендуются, поскольку эта работа использует именованную идентичность агента, состояние SQLite и клиентов WebSocket, однако виртуальные машины и ресурсы из них здесь не используются повторно.

Авторизуйте виртуальную машину и настройте Worker чата

На этом шаге вы авторизуете новую виртуальную машину и опишете три привязки Cloudflare, необходимые чату.

Каждый именованный чат работает в отдельном экземпляре SQLite Durable Object. Worker также нужны привязка Workers AI для выполнения запросов к модели и секретная привязка для ограничения сессии.

Откройте терминал и перейдите в подготовленный проект:

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

Авторизуйте новую виртуальную машину:

npx wrangler login

Откройте показанную ссылку, подтвердите описанные разрешения Wrangler для выделенного учебного аккаунта, затем вернитесь в терминал. Проверьте структурированный результат:

npx wrangler whoami --json

Найдите "loggedIn": true, подтвердите имя аккаунта и скопируйте фактический ID этого аккаунта. Сохраните его явно и задайте уникальное имя временного Worker:

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

Привязка AI предоставляет Worker доступ к Workers AI без встраивания API-ключа. Workers AI всегда использует модель, размещённую в Cloudflare, в том числе при локальной разработке; параметр remote: true явно задаёт это поведение. Привязка Durable Object сопоставляет имя класса, а браузер позже передаст отдельное имя экземпляра planning. Пока ничего не развёрнуто.

Реализуйте ограниченный AIChatAgent

На этом шаге вы реализуете серверный класс чата, ограниченный запрос к модели и границу маршрутизации с подписанными запросами.

AIChatAgent расширяет базовый Agent, добавляя постоянную историю чата и сохранение данных возобновляемого потока. Вы задаёте вызов модели, а интеграция обрабатывает протокол чата и сохранение данных.

Создайте 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

Здесь важны три ограничения. maxPersistedMessages ограничивает рост сохранённой истории, maxOutputTokens ограничивает каждый ответ модели, а системная инструкция просит отвечать одним предложением. GLM 4.7 Flash может потратить бюджет токенов на внутреннее рассуждение до появления видимого текста, поэтому в этом коротком сценарии поддержки размышления явно отключены; учащийся видит краткий ответ, а не пустой пузырь сообщения помощника. Передача abortSignal позволяет SDK отменить выполняющийся запрос к модели, если текущий ответ явно остановлен.

Оба обработчика маршрутизации используют предоставленный HMAC-проверяющий модуль. onBeforeConnect защищает рукопожатие WebSocket, а onBeforeRequest также защищает вспомогательные HTTP-запросы, например /get-messages. Браузер получает подписанное утверждение, но не секрет подписи. В журнал записываются ID запроса и количество сообщений, однако текст обращения намеренно не сохраняется.

Подключите поддерживаемые хуки чата React

На этом шаге вы подключите готовую оболочку страницы к текущим поддерживаемым хукам React.

Подготовленные HTML и стили — это только оболочка. Теперь подключите её к именованному Agent. Создайте конфигурацию TypeScript и 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

Создайте 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() управляет подписанным WebSocket-подключением к SupportChatAgent:<session>. useAgentChat() добавляет к этому подключению протокол AI-чата: сообщения, состояние потоковой передачи, отправку сообщений и первоначальное восстановление истории. Токен передаётся в URL подключения, поскольку браузерное рукопожатие WebSocket не может добавить пользовательский заголовок авторизации; срок действия токена составляет десять минут, и он ограничен одним синтетическим диалогом.

Сгенерируйте типы и соберите обе части

На этом шаге вы сгенерируете точные типы окружения и скомпилируете серверную и клиентскую части до запуска среды выполнения.

Wrangler может сгенерировать точные типы привязок из конфигурации. Выполните эту команду перед обычными сборками TypeScript и Vite:

npx wrangler types
npm run check
npm run build

Проверка типов связывает this.env.AI, пространство имён Durable Object и секретную привязку с объявленным Env. Сборка Vite создаёт один пакет Worker и один пакет браузера; среди успешных результатов должен быть файл dist/client/index.html.

Локально проверьте границу подписанной сессии

На этом шаге вы запустите локальную среду выполнения и проверите контроль доступа, не расходуя запрос к модели.

Workers AI использует удалённую привязку, поэтому локальной среде выполнения Vite нужна OAuth-авторизация, уже сохранённая Wrangler. Прочитайте её непосредственно во временную переменную оболочки, передайте только дочернему процессу и сразу удалите копию из оболочки:

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

Не выводите это значение и не сохраняйте его в .dev.vars. Это существующий временный OAuth-доступ Wrangler, а не новый API Token. CI=true и перенаправление стандартного ввода позволяют процессу Vite продолжить работу в фоне после возврата приглашения терминала.

Подождите, пока появится URL:

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

Выполните независимую локальную проверку:

python3 .labex/verify.py local

Эта проверка намеренно не расходует запрос к модели. Она подтверждает, что правильно подписанная новая сессия может прочитать пустую историю, а неподписанный запрос и действительный токен, ограниченный другим именем, получают HTTP 401. Локальный Miniflare использует те же обработчики маршрутизации и секрет из .dev.vars.

Разверните приложение и проверьте постоянную потоковую передачу

На этом шаге вы развернёте приложение, увидите один реальный потоковый ответ, восстановите его после обновления страницы и подтвердите изоляцию сессий.

Разверните рабочую сборку, затем загрузите сгенерированный ключ подписи как секрет Worker:

npm run deploy
npx wrangler secret bulk .dev.vars

Команда работы с секретом отправляет значение в Cloudflare, не помещая его в wrangler.jsonc или пакет. Не выводите содержимое .dev.vars.

Сохраните точный origin workers.dev, показанный после успешного развёртывания, затем создайте токен на десять минут для диалога 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 должен содержать только origin, без завершающего слеша и пути. Храните токен в текущем сеансе терминала и не вставляйте его в заметки или снимки экрана.

Откройте полный URL. Сначала состояние должно перейти в ready, а на странице должно быть указано, что в этом диалоге нет сохранённых сообщений. Отправьте подготовленный синтетический вопрос. Проследите, как состояние submitted меняется на streaming, а затем снова на ready по мере поступления текста.

Диалог planning после одного потокового ответа поддержки

Показанные ресурс и ответ — примеры из проверенного временного запуска. Точная формулировка у вас может отличаться, поскольку вывод модели недетерминирован.

Обновите тот же URL. Завершённые сообщения пользователя и помощника должны вернуться из SQLite, а не исчезнуть:

Тот же диалог planning, восстановленный после обновления страницы

Теперь подтвердите изоляцию по имени. Создайте и откройте отдельно подписанный URL:

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

Страница private авторизована, но относится к другому именованному экземпляру Agent, поэтому её история пуста:

Отдельно авторизованный диалог private с пустой историей

Наконец, выполните одну независимую удалённую проверку, уникальную для этого запуска. Она выполняет ещё один ограниченный запрос к модели, подтверждает наличие нескольких частей потока, после повторного подключения получает сохранённые сообщения пользователя и помощника, проверяет пустую вторую авторизованную сессию и отклоняет доступ между сессиями:

python3 .labex/verify.py deployed

Проверьте и удалите ресурсы чата

На этом шаге вы сопоставите поведение среды выполнения с данными в Dashboard, а затем удалите только ресурсы этой лабораторной работы.

В Cloudflare Dashboard откройте Workers & Pages, выберите Worker с точным именем labex-c11-s03-... и проверьте его привязки. Вы должны увидеть привязку AI и привязку Durable Object SupportChatAgent:

Развёрнутый Worker с привязками AI и SupportChatAgent

Откройте Durable Objects и выберите пространство имён, использующее SQL и принадлежащее этому Worker. Пространство имён — это представление ресурса на уровне Cloudflare; planning, private и имена, используемые проверкой, — изолированные экземпляры внутри него:

Пространство имён SupportChatAgent с хранилищем SQL

Откройте журналы Worker или представление observability и найдите support_chat_turn_started. Событие содержит ограниченные метаданные, например количество сообщений, но не запрос учащегося и не ответ модели:

Структурированный журнал чата с ограниченными данными

После проверки создайте миграцию удаления, которая удалит только пространство имён класса этой лабораторной работы:

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

Подтвердите отсутствие Worker в разделе Workers & Pages:

Временный Worker чата удалён

Затем подтвердите отсутствие принадлежащего вам пространства имён SupportChatAgent в разделе Durable Objects:

Временное пространство имён чата удалено

Пока эта виртуальная машина ещё авторизована, выполните проверку отсутствия ресурсов:

python3 .labex/verify.py deleted

Одного удаления Worker недостаточно: явная миграция deleted_classes делает жизненный цикл состояния проверяемым и не позволяет оставить сохранённую синтетическую историю этой лабораторной работы.

Отзовите авторизацию этой виртуальной машины

На этом шаге вы отзовёте авторизацию временной виртуальной машины после проверки очистки ресурсов в облаке.

Облачные ресурсы уже удалены. Теперь отзовите OAuth-авторизацию, сохранённую на этой временной виртуальной машине:

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

В структурированном результате должно быть указано "loggedIn": false (либо Wrangler может вернуть ненулевой код завершения, поскольку авторизация отсутствует). Этот шаг намеренно выполняется последним: для проверки очистки нужна действующая авторизация, а после выхода из аккаунта удалённая виртуальная машина остаётся защищённой.

Итоги

Вы создали постоянный потоковый диалог поддержки с помощью актуальной интеграции чата Cloudflare. Вы:

  • расширили AIChatAgent и использовали ограниченный вызов Workers AI streamText();
  • подключили предоставленную оболочку React с помощью useAgent() и useAgentChat();
  • защитили маршруты WebSocket и HTTP для истории с помощью истекающей подписи, ограниченной сессией;
  • наблюдали промежуточные состояния, восстановили историю из SQLite после обновления страницы и подтвердили изоляцию другого именованного диалога;
  • проверили данные Cloudflare с ограниченными с точки зрения конфиденциальности сведениями;
  • удалили точное пространство имён класса Agent и Worker перед отзывом авторизации виртуальной машины.

В следующей лабораторной работе используется та же постоянная идентичность Agent для запланированных последующих обращений поддержки. Планирование — это другая задача жизненного цикла: оно позволяет запускать работу позже, даже когда браузер уже не подключён.