Введение
Помощник поддержки кажется отзывчивым, когда текст появляется ещё во время генерации ответа моделью. Он также вызывает больше доверия, если обновление страницы не стирает диалог. Это две разные инженерные задачи: потоковая передача доставляет части ответа по мере их готовности, а сохранение записывает завершённые сообщения, чтобы позже восстановить тот же именованный диалог.
В этой лабораторной работе вы добавите обе возможности с помощью поддерживаемой интеграции чата Cloudflare:
AIChatAgentсохраняет сообщения чата и данные возобновляемого потока в Durable Object агента с хранилищем SQLite.streamText()создаёт ограниченный ответ Workers AI, не дожидаясь полной генерации.useAgentChat()превращает эти части в список сообщений React и восстанавливает сохранённую историю.- Краткоживущий подписанный токен ограничивает каждый запрос 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 по мере поступления текста.

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

Теперь подтвердите изоляцию по имени. Создайте и откройте отдельно подписанный URL:
PRIVATE_TOKEN="$(node scripts/create-session-token.mjs private)"
printf '%s/?session=private&token=%s\n' "${WORKER_URL%/}" "$PRIVATE_TOKEN"
Страница private авторизована, но относится к другому именованному экземпляру Agent, поэтому её история пуста:

Наконец, выполните одну независимую удалённую проверку, уникальную для этого запуска. Она выполняет ещё один ограниченный запрос к модели, подтверждает наличие нескольких частей потока, после повторного подключения получает сохранённые сообщения пользователя и помощника, проверяет пустую вторую авторизованную сессию и отклоняет доступ между сессиями:
python3 .labex/verify.py deployed
Проверьте и удалите ресурсы чата
На этом шаге вы сопоставите поведение среды выполнения с данными в Dashboard, а затем удалите только ресурсы этой лабораторной работы.
В Cloudflare Dashboard откройте Workers & Pages, выберите Worker с точным именем labex-c11-s03-... и проверьте его привязки. Вы должны увидеть привязку AI и привязку Durable Object SupportChatAgent:

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

Откройте журналы 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:

Затем подтвердите отсутствие принадлежащего вам пространства имён 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 AIstreamText(); - подключили предоставленную оболочку React с помощью
useAgent()иuseAgentChat(); - защитили маршруты WebSocket и HTTP для истории с помощью истекающей подписи, ограниченной сессией;
- наблюдали промежуточные состояния, восстановили историю из SQLite после обновления страницы и подтвердили изоляцию другого именованного диалога;
- проверили данные Cloudflare с ограниченными с точки зрения конфиденциальности сведениями;
- удалили точное пространство имён класса Agent и Worker перед отзывом авторизации виртуальной машины.
В следующей лабораторной работе используется та же постоянная идентичность Agent для запланированных последующих обращений поддержки. Планирование — это другая задача жизненного цикла: оно позволяет запускать работу позже, даже когда браузер уже не подключён.



