Insights / Технические материалы

Технический дизайн AI-чата для обращений на сайте Astro

Практический дизайн AI-чата для обращений на статическом сайте Astro + Cloudflare Pages с OpenAI Responses API. Рассмотрены граница API, контекст сайта, управление prompt, URL по locale, проверка Origin, rate limit и безопасный рендеринг Markdown-ссылок.

  • Технологии
  • Cloudflare
  • Веб-сайт
  • AI
  • Услуги
Технический дизайн AI-чата для обращений на сайте Astro
Содержание
  1. Общая структура
  2. Держать контракт endpoint небольшим
  3. Управлять валидацией и вызовом модели на сервере
  4. Сделать контекст сайта явным
  5. Писать правила в prompt
  6. Разделить каналы обращения
  7. Сохранять URL по locale
  8. Origin check и rate limit
  9. Рендерить Markdown-ссылки через allowlist
  10. Проверять local, preview и production
  11. Операционные метрики
  12. Отдельный скоуп
  13. Итог

Добавить AI-чат на сайт несложно. Сложнее сделать это пригодным для эксплуатации: определить, что AI может отвечать, куда направлять посетителя, какие URL показывать и как контролировать стоимость API.

На сайте Acecore AI-чат для обращений был добавлен в статическую архитектуру Astro + Cloudflare Pages. Основная реализация находится в PR с AI для контактов и CMS-ограниченным потоком переводов. Затем безопасный рендеринг Markdown-ссылок был доработан в отдельном PR. Подробности вынесены в статью Безопасный рендеринг Markdown-ссылок в ответах AI-чата.

Эта статья описывает не только конкретную работу, а повторно используемый технический дизайн для других статических сайтов. Даже вне Astro принцип тот же: разделить клиентский widget, API-границу, prompt и renderer.

Общая структура

Слой Ответственность
Chat widget UI, ввод, текущий locale, минимальная история, Markdown rendering
/api/ai-contact Валидация, Origin check, rate limit, prompt, вызов OpenAI
OpenAI Responses API Генерация ответа из публичного контекста и состояния диалога

Браузер не должен вызывать OpenAI напрямую. Серверный endpoint скрывает ключ, позволяет менять prompt и контекст на сервере, а также централизует лимиты и ошибки.

В Astro + Cloudflare Pages это можно реализовать как Pages Function /api/ai-contact. В Next.js это был бы Route Handler, в Hono или Express обычный API route.

Держать контракт endpoint небольшим

type ContactAiRequest = {
  message: string;
  locale: "ja" | "en" | "zh-cn" | "es" | "pt" | "fr" | "ko" | "de" | "ru";
  history?: Array<{
    role: "user" | "assistant";
    content: string;
  }>;
};

type ContactAiResponse = {
  answer: string;
};

Имя, email, телефон, компания и подробные поля формы не должны проходить через чат. Его задача — помочь выбрать услугу и канал обращения, а не собирать персональные данные.

История тоже ограничивается несколькими последними сообщениями и максимальной длиной. Это уменьшает prompt и стоимость.

Управлять валидацией и вызовом модели на сервере

export async function onRequestPost({ request, env }: PagesFunction<Env>) {
  assertSameOrigin(request);
  assertRateLimit(request);

  const body = await request.json();
  const message = validateMessage(body.message);
  const locale = validateLocale(body.locale);
  const history = trimHistory(body.history);

  const prompt = buildContactPrompt({
    locale,
    message,
    history,
    siteContext: buildPublicSiteContext(locale),
  });

  const answer = await callOpenAIResponsesApi({
    apiKey: env.OPENAI_API_KEY,
    model: env.OPENAI_MODEL,
    prompt,
  });

  return Response.json({ answer });
}

Главное — уменьшить и проверить ввод до вызова AI API. Длинные сообщения, бесконечная история и внешние повторные запросы быстро делают эксплуатацию нестабильной.

OPENAI_MODEL лучше хранить в переменной окружения, а OPENAI_API_KEY только на сервере. Про доставку и CSP см. статью о безопасности Cloudflare Pages.

Сделать контекст сайта явным

Для сайта такого размера не обязательно начинать с векторной базы. Часто достаточно структурированного контекста из публичных страниц.

Контекст может включать описание компании и услуг, целевые аудитории, примеры обращений, URL, FAQ, правила для формы/LINE/email/телефона, области без утверждений вроде цены и договоров, а также внутренние URL по locale.

function buildPublicSiteContext(locale: Locale) {
  return {
    services: [
      {
        name: "Web production",
        summary: "Corporate sites, recruiting sites, and landing pages",
        url: localizePath("/services/web-production/", locale),
      },
      {
        name: "Server operations",
        summary: "Reservation, inventory, and customer management systems",
        url: localizePath("/services/business-system/", locale),
      },
    ],
    contact: {
      form: localizePath("/contact/", locale),
      line: "https://lin.ee/...",
      emailPolicy:
        "Show email only when the form cannot be used or follow-up is needed",
      phonePolicy: "Show phone only for urgent confirmation",
    },
  };
}

Цель не в том, чтобы модель отвечала из общих знаний, а в том, чтобы она знала, что этот сайт имеет право сказать. При росте сайта слой можно развить в Pagefind, CMS JSON, D1, Vectorize или другой retrieval.

Писать правила в prompt

You are the contact guidance AI for this website.
Answer only from public site information.

Rules:
- Do not make firm statements about pricing, contracts, schedules, or guarantees
- Send formal consultations and estimates to the contact form
- Also suggest LINE for quick checks and Schools-related inquiries
- Show email and phone only when the user asks for direct contact
- Use URLs that match the current locale
- If unsure, do not guess; guide the user to the form

Типичная ошибка — слишком полезная AI, которая обещает слишком много. Вопросы о цене, сроках и гарантиях должны получать общую ориентацию и переход к форме.

Разделить каналы обращения

Канал Роль
FAQ Ответить на частые вопросы прямо на странице
AI-чат Помочь выбрать услуги, каналы и связанные страницы
LINE Короткие обращения, связь с Acecore Schools и простые уточнения
Форма Оценки, производство, партнерства и найм
Прямой контакт Дополнение после формы или срочное подтверждение

AI соединяет общий контент вроде обзора услуг с конкретными маршрутами на странице контактов. Это подходит для B2B, агентств, школ и SaaS support.

Сохранять URL по locale

На многоязычном сайте важен не только язык ответа, но и URL.

function localizePath(path: string, locale: Locale) {
  if (locale === "ja") return path;
  return `/${locale}${path}`;
}

Генерация на сервере надежнее, чем только инструкция в prompt. Основа переводов описана в статье Как вести многоязычный блог с Sveltia CMS.

Origin check и rate limit

function assertSameOrigin(request: Request) {
  const origin = request.headers.get("Origin");
  if (!origin) return;

  const requestUrl = new URL(request.url);
  const originUrl = new URL(origin);

  if (originUrl.host !== requestUrl.host) {
    throw new Response("Forbidden", { status: 403 });
  }
}

IP-based rate limit — первая защита. В Cloudflare можно использовать CF-Connecting-IP, X-Forwarded-For или CF-Ray. При большем трафике лучше Cloudflare WAF, Turnstile, KV, D1 или Durable Objects. CMS-эксплуатация для обновления контента описана в руководстве по внедрению Sveltia CMS; защита форм и комментариев от ботов относится к отдельному уровню.

Рендерить Markdown-ссылки через allowlist

Поддерживайте только абзацы, списки, жирный текст, inline code и Markdown-ссылки. Цели ссылок ограничиваются внутренними путями, текущим origin, https://acecore.net, официальным LINE и нужными mailto: или tel:.

function sanitizeHref(rawHref: string, currentOrigin: string) {
  const href = rawHref.trim();

  if (href.startsWith("/")) return href;
  if (href.startsWith(`${currentOrigin}/`)) return href;
  if (href.startsWith("https://acecore.net/")) return href;
  if (href.startsWith("https://lin.ee/")) return href;
  if (href === "mailto:info@acecore.net") return href;
  if (href === "tel:05088902788") return href;

  return null;
}

trim() важен, потому что AI может вернуть [Services]( /services/ ). Небольшой строгий renderer проще поддерживать, чем полный Markdown.

Проверять local, preview и production

Astro dev или preview не полностью совпадает с Cloudflare Pages Functions. Без OPENAI_API_KEY локально проверяются fallback и ошибки UI.

В preview или production проверьте POST на /api/ai-contact, переменные OPENAI_API_KEY и OPENAI_MODEL, отказ для другого Origin, лимиты ввода, ответы в нужном locale, локализованные URL, отсутствие утверждений о смете или договоре, отсутствие email и телефона по умолчанию, а также Markdown-ссылки только при разрешенном URL.

Отдельно тестируйте длинный ввод, неожиданные вопросы, английские страницы, запрос прямого контакта и вопросы о цене.

Операционные метрики

После релиза смотрите error rate API, срабатывания rate limit, среднее число сообщений на обращение, переходы к форме и LINE, случаи перевода на форму и использование по locale.

Если сохранять тексты диалогов, сначала определите правила приватности. Безопаснее начать с событий и ошибок без текста сообщений.

Отдельный скоуп

Эта статья только о техническом дизайне AI-чата. Навигация, передающая предмет консультации со страницы услуги в форму, также реализована и описана в Технический дизайн передачи контекста от CTA услуги в форму обратной связи.

  • AI-чат: через диалог убрать неопределенность и безопасно направить
  • Service CTA: передать в форму контекст услуги, которую читал посетитель

Разделение делает статьи понятнее и облегчает внутренние ссылки.

Итог

Для AI-чата на статическом сайте сначала проектируйте API-границу и контроль ответов.

Ключевые решения: вызывать OpenAI из Cloudflare Pages Function, держать ввод и историю маленькими, собирать контекст и locale URL на сервере, писать ограничения в prompt, разделить форму/LINE/прямой контакт, добавить Origin check и rate limit, а Markdown-ссылки рендерить после trim() через allowlist.

Статические сайты могут иметь полезный AI-чат для обращений. Цель не в том, чтобы выделить AI, а в том, чтобы посетитель безопасно выбрал следующий шаг.

Референсная архитектура

  1. Widget

    UI чата в Astro отправляет только вопрос, текущий locale и минимальную историю.

  2. Function

    Cloudflare Pages Function проверяет ввод, Origin, rate limit и собирает prompt.

  3. Model

    OpenAI Responses API получает публичный контекст сайта и состояние диалога.

  4. Renderer

    Клиент рендерит только разрешенный Markdown и ведет к внутренним ссылкам или одобренным контактам.

Ответственности, которые нужно разделить

Если все смешано

  • AI API вызывается прямо из браузера
  • Контекст сайта, API key, UI и рендеринг ссылок смешаны
  • AI может слишком уверенно утверждать цены, договоры или сроки
  • Markdown и URL могут попасть в HTML без контроля

Если ответственности разделены

  • API key и вызов модели остаются на сервере
  • Публичная информация сайта управляется как явный контекст
  • Prompt контролирует область ответа и маршруты контакта
  • Markdown и URL проходят через allowlist

Чеклист для внедрения на других сайтах

  • Определить чат как навигационную помощь, а не замену формы
  • Создать серверную границу API и не раскрывать API key в браузере
  • Ограничить ответы публичной информацией сайта
  • Определить, что AI не должен утверждать, например цены, договоры, сроки и гарантии
  • Разделить роли формы, LINE, email и телефона
  • Генерировать URL по locale, чтобы не ломать многоязычную навигацию
  • Добавить Origin check, лимиты длины, лимиты истории и rate limiting
  • Делать trim URL в Markdown-ссылках перед проверкой allowlist

Частые вопросы

Нужны ли RAG или векторная база для такого AI-чата?

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

Видит ли браузер OpenAI API key?

Нет. Браузер отправляет вопрос только в /api/ai-contact. Cloudflare Pages Function вызывает OpenAI Responses API и хранит API key.

Может ли AI выводить любые ссылки?

Нет. Разрешены внутренние пути, текущий origin, acecore.net, официальный LINE и необходимые mailto или tel. Markdown URL очищаются через trim перед проверкой.