Перейти до вмісту

TipTap, AI-асистент і автозбереження: як ми зробили редактор для блогу

Як працює редактор у нашій CMS: TipTap, метадані від AI, автозбереження чернеток і обробка зображень. За нашими вимірами, створення посту стало швидшим приблизно на 40%.

АвторВідділ ІТ
  • 7 хв читання
  • 210 переглядів
TipTap, AI-асистент і автозбереження: як ми зробили редактор для блогу

У першій статті я розповів про архітектуру нашого блогу. Ця присвячена тому, з чим автори працюють щодня: редактору. Покажу, як ми його зробили, як AI бере на себе рутину і як працює автозбереження.

Вибір редактора: чому TipTap?

Ми розглядали Quill, Draft.js і CKEditor, а зупинилися на TipTap. Причини:

  • Він побудований на ProseMirror, надійній основі

  • Дуже добре працює з Vue.js

  • Має модульну архітектуру

  • Дає повний контроль над функціональністю

Інтеграція TipTap Editor

Ось як виглядає наш компонент редактора:

<template>
  <div class="tiptap-editor border rounded-md">
    <div class="sticky top-16 z-10 flex flex-wrap items-center gap-1 p-3 
                border-b bg-slate-50/50 dark:bg-darkmode-600">
      <!-- Панель інструментів -->
      <button 
        type="button" 
        @click="editor?.chain().focus().toggleBold().run()"
        :class="buttonClass(editor?.isActive('bold'))"
      >
        <Lucide icon="Bold" class="w-4 h-4" />
      </button>
      <!-- Інші кнопки форматування -->
    </div>
    
    <EditorContent 
      :editor="editor" 
      :class="editorContentClass"
    />
    
    <div v-if="showCharacterCount" class="text-right text-xs p-2">
      {{ characterCount }} {{ $trans('main.blog.posts.characters') }}
      <span v-if="characterLimit"> / {{ characterLimit }}</span>
    </div>
  </div>
</template>

<script setup lang="ts">
import { useEditor, EditorContent } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import TextAlign from '@tiptap/extension-text-align'
import Image from '@tiptap/extension-image'
import Link from '@tiptap/extension-link'
import CharacterCount from '@tiptap/extension-character-count'

const editor = useEditor({
  content: props.modelValue,
  extensions: [
    StarterKit,
    TextAlign.configure({
      types: ['heading', 'paragraph'],
    }),
    Image.configure({
      HTMLAttributes: {
        class: 'max-w-full h-auto rounded-lg',
      },
    }),
    Link.configure({
      openOnClick: false,
    }),
    CharacterCount.configure({
      limit: 15000,
    }),
  ],
  onUpdate: ({ editor }) => {
    const htmlContent = editor.getHTML()
    const textContent = editor.getText()
    
    emit('update:modelValue', htmlContent)
    emit('update:text', textContent)
  },
})
</script>

AI-асистент для генерації метаданих

Одна з найкорисніших функцій: генерація SEO-метаданих через AI. Вигадувати заголовки, описи й ключові слова вручну нудно, тож AI читає контент і пропонує варіанти, з яких автор обирає найкращий.

class AIGenerator
{
    protected Client $client;
    protected ContentProcessor $contentProcessor;

    public function __construct(ContentProcessor $contentProcessor)
    {
        $this->client = OpenAI::client(env('OPENAI_API_KEY'));
        $this->contentProcessor = $contentProcessor;
    }
    
    public function generate(string $content, string $contentType, string $language): array
    {
        // Попередня обробка контенту
        $processedContent = $this->preprocessContent($content, $contentType, $language);
        
        // Побудова промпта
        $prompt = $this->buildPrompt($processedContent, $language);
        
        $response = $this->client->chat()->create([
            'model' => env('OPENAI_MODEL'),
            'messages' => [
                [
                    'role' => 'system',
                    'content' => $this->getSystemPrompt($language)
                ],
                [
                    'role' => 'user',
                    'content' => $prompt
                ]
            ],
            'temperature' => 0.7,
            'max_tokens' => 4000,
        ]);

        return $this->parseGeneratedContent($response->choices[0]->message->content);
    }
    
    protected function buildPrompt(string $content, string $language): string
    {
        $languageMap = [
            'en' => 'English',
            'pl' => 'Polish',
            'uk' => 'Ukrainian'
        ];

        $targetLanguage = $languageMap[$language] ?? 'English';
        
        return "Based on the following content, generate SEO metadata in {$targetLanguage}:
        
        Content: {$content}
        
        Generate metadata with the following JSON structure:
        {
            \"title\": \"Engaging title (10-255 characters)\",
            \"excerpt\": \"Compelling summary (50-65535 characters)\",
            \"slug\": \"url-friendly-slug\",
            \"meta_title\": \"SEO optimized title (10-255 characters)\",
            \"meta_description\": \"SEO description (120-255 characters)\",
            \"keywords\": [\"keyword1\", \"keyword2\", \"...\"]
        }
        
        Requirements:
        - Extract keywords from the actual content
        - Create compelling, clickable titles
        - Include call-to-action in meta descriptions
        - Make slug SEO-friendly and readable
        - Return ONLY valid JSON";
    }
}

Обробка контенту перед відправкою в AI

Перш ніж контент потрапить до AI, ми його готуємо:

class ContentProcessor
{
    protected int $maxTokens;
    
    public function processForAI(string $htmlContent, string $language, array $options): string
    {
        // Очищення HTML
        $cleanHtml = $this->cleanHtml($htmlContent);
        
        // Витяг ключового контенту
        $extractedContent = $this->extractKeyContent($cleanHtml, $options);
        
        // Конвертація в оброблений текст
        $processedText = $this->convertToProcessedText($extractedContent, $language);
        
        // Стиснення до ліміту токенів
        return $this->compressToTokenLimit($processedText, $language);
    }
    
    protected function extractKeyContent(string $html, array $options): array
    {
        $dom = new DOMDocument('1.0', 'UTF-8');
        @$dom->loadHTML($html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
        
        $extractedContent = [
            'priority' => [],    // Заголовки
            'important' => [],   // Виділений текст
            'regular' => [],     // Звичайні параграфи
            'lists' => []        // Списки
        ];
        
        // Витягуємо заголовки з вагами
        foreach(['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] as $tag) {
            $elements = $xpath->query("//{$tag}");
            foreach($elements as $element) {
                $text = $this->cleanText($element->textContent);
                if(strlen($text) > 5) {
                    $extractedContent['priority'][] = [
                        'tag' => $tag,
                        'text' => $text,
                        'weight' => $this->getElementWeight($tag)
                    ];
                }
            }
        }
        
        return $extractedContent;
    }
}

Автозбереження чернеток

Кожен автор знає, як неприємно втратити незбережену роботу. Автозбереження прибирає цей ризик:

// Composable для управління чернетками
export function usePostDraft(form: any, post?: FormPost) {
  const draftStore = usePostDraftStore();
  const { $trans } = useTranslation();
  
  const autosaveInterval = ref<NodeJS.Timeout | null>(null);
  const lastSaveTime = ref<number | null>(null);
  
  const saveDraft = () => {
    if (!draftStore.autosaveEnabled) return;
    
    const draftData: PostDraftState = {
      author_id: form.author_id,
      scheduled_at: form.scheduled_at,
      publish_now: form.publish_now,
      contents: form.contents,
      categories: form.categories,
      tags: form.tags,
      enable_fake_views: form.enable_fake_views,
      fake_view_count: form.fake_view_count,
      views_increment_type: form.views_increment_type,
      // ... інші поля
    };
    
    draftStore.saveDraft(draftData, postId);
    lastSaveTime.value = Date.now();
  };
  
  // Автозбереження кожні 30 секунд
  const startAutosave = () => {
    autosaveInterval.value = setInterval(() => {
      saveDraft();
    }, 30000);
  };
  
  // Відстеження змін форми
  watch(
    () => [form.contents, form.categories, form.tags, /* ... */],
    () => {
      handleFormChange();
    },
    { deep: true, flush: 'post' }
  );
  
  return {
    saveDraft,
    loadDraft,
    clearDraft,
    hasDraft,
    formatLastSaveTime
  };
}

Pinia Store для чернеток

export const usePostDraftStore = defineStore('postDraft', () => {
  const drafts = ref<Record<string, PostDraftState>>({});
  const autosaveEnabled = ref(true);
  const lastSaved = ref<Record<string, number>>({});

  const getDraftKey = (postId?: number) => {
    return postId ? `edit_${postId}` : 'create_new';
  };

  const saveDraft = (formData: PostDraftState, postId?: number) => {
    if (!autosaveEnabled.value) return;

    const key = getDraftKey(postId);
    
    // Глибоке клонування для уникнення проблем з реактивністю
    const draftData = JSON.parse(JSON.stringify(formData));
    
    drafts.value[key] = draftData;
    lastSaved.value[key] = Date.now();
    
    // Збереження в localStorage
    try {
      localStorage.setItem('post_drafts', JSON.stringify(drafts.value));
      localStorage.setItem('post_drafts_timestamps', JSON.stringify(lastSaved.value));
    } catch (error) {
      console.error('Failed to save draft to localStorage:', error);
    }
  };
  
  return {
    drafts,
    saveDraft,
    loadDraft,
    clearDraft,
    hasDraft,
    getLastSavedTime
  };
});

Компонент відновлення чернетки

<template>
  <Dialog :open="open" @close="handleClose">
    <Dialog.Panel class="max-w-md">
      <Dialog.Title>
        <div class="flex items-center gap-3">
          <div class="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
            <Lucide icon="FileText" class="w-5 h-5 text-blue-600" />
          </div>
          <div>
            <h2 class="text-lg font-semibold">
              {{ $trans('main.blog.posts.draft_recovery_title') }}
            </h2>
            <p class="text-sm text-slate-600 mt-1">
              {{ lastSaveTime }}
            </p>
          </div>
        </div>
      </Dialog.Title>
      
      <Dialog.Description>
        <div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
          <p class="text-sm font-medium text-blue-800">
            {{ $trans('main.blog.posts.draft_found_message') }}
          </p>
        </div>
      </Dialog.Description>
      
      <Dialog.Footer class="flex gap-3">
        <Button variant="outline-secondary" @click="handleStartFresh">
          <Lucide icon="FileX" class="w-4 h-4 mr-2" />
          {{ $trans('main.blog.posts.start_fresh') }}
        </Button>
        <Button variant="primary" @click="handleRestoreDraft">
          <Lucide icon="RotateCcw" class="w-4 h-4 mr-2" />
          {{ $trans('main.blog.posts.restore_draft') }}
        </Button>
      </Dialog.Footer>
    </Dialog.Panel>
  </Dialog>
</template>

Обробка та оптимізація зображень

Для кожного зображення ми створюємо три версії, щоб сторінка завантажувала лише потрібне:

class ImageProcessing
{
    private const SCALES = [
        'low' => 400,      // Для прев'ю та мобільних
        'medium' => 800,   // Для звичайного перегляду
        'full' => null     // Оригінальна роздільність
    ];

    public function processAndSaveImage(UploadedFile $uploadedFile): ?array
    {
        try {
            $imagick = new Imagick($uploadedFile->getRealPath());
            
            $originalWidth = $imagick->getImageWidth();
            $originalHeight = $imagick->getImageHeight();
            
            $scalePaths = [];
            
            foreach(self::SCALES as $scale => $maxWidth) {
                $scaledImage = clone $imagick;
                
                if($maxWidth !== null && $originalWidth > $maxWidth) {
                    $scaledImage->scaleImage($maxWidth, 0);
                }
                
                // Конвертація в WebP для оптимального стиснення
                $scaledImage->setImageCompressionQuality(85);
                $scaledImage->setImageFormat('WEBP');
                
                $filename = $this->generateRandomFilename() . '_' . $scale . '.webp';
                $filePath = 'posts/' . $filename;
                
                Storage::disk('landing')->put(
                    $filePath,
                    $scaledImage->getImageBlob()
                );
                
                $scalePaths[$scale] = $filePath;
                $scaledImage->destroy();
            }
            
            $imagick->destroy();
            return $scalePaths;
            
        } catch(ImagickException $e) {
            Log::error('Image processing failed: ' . $e->getMessage());
            return null;
        }
    }
}

Збираємо компоненти разом

Ось як ці частини взаємодіють у формі створення посту:

<script setup lang="ts">
// Композиції для різних аспектів форми
const { form, saveDraft, submitForm } = usePostForm(props.post, initializeContents);
const { hasDraft, loadDraft, clearDraft } = usePostDraft(form, props.post);
const { aiLoading, aiError, launchAI } = usePostAI(form, activeLocale);

// При монтуванні перевіряємо наявність чернетки
onMounted(() => {
  if (shouldShowDraftWarning.value) {
    showDraftRecoveryModal.value = true;
  }
});

// Обробка AI-генерації
const handleAIGeneration = async () => {
  await launchAI();
  // AI автоматично оновить поля форми
};

// Відправка форми
const handleSubmit = () => {
  clearDraft(); // Очищуємо чернетку при успішній відправці
  submitForm();
};
</script>

Результати

Після впровадження ми виміряли таке:

  • Час створення посту менший на 40% завдяки генерації метаданих через AI

  • Жодного випадку втрати даних після появи автозбереження

  • CTR вищий на 25%, що ми пов'язуємо з оптимізованими SEO-заголовками

  • Навантаження на сервер нижче на 30% завдяки оптимізації зображень

Висновки

Хороша система керування контентом: це більше, ніж гарний редактор. У нашому випадку все звелося до чотирьох речей:

  • AI бере на себе рутинні завдання

  • Автозбереження захищає від втрати даних

  • Медіа оптимізовані, тому сторінки завантажуються швидко

  • Інтерфейс побудований навколо того, що автори справді роблять

У наступній статті розповім, як ми втримали продуктивність системи під високим навантаженням: асинхронна обробка, підрахунок переглядів і кешування.

Продовження буде.

Теги

Поділитися статтею

Яку LLM обрати для бізнесу: GPT, Claude, Gemini чи open-source

Сильні сторони GPT, Claude, Gemini та open-source моделей, підказки, коли обирати кожну, і шість кроків вибору з урахуванням вартості, безпеки даних та інтеграції.

  • 10 хв читання
  • 219 переглядів

AI-агенти в компанії: де вони окупаються, а де вистачить скрипта

Чим AI-агент відрізняється від чат-бота й RPA, у яких процесах він дає вимірювану віддачу і чому більшість пілотів не доходить до продакшну. У кінці тест із п'яти запитань перед стартом проєкту.

  • 9 хв читання
  • 202 перегляди

Чому ШІ посилює хаос у невпорядкованих компаніях

ШІ примножує все, що знаходить у компанії, зокрема безлад у даних і незадокументовані процеси. Розбираємо, чому впровадження провалюються і в якій послідовності діяти, щоб воно окупилося.

  • 9 хв читання
  • 143 перегляди
Повернутися до блогу

Файли cookies

Ми використовуємо необхідні файли cookies та один функціональний, який вмикаємо лише за вашою згодою. Статистику відвідувань рахуємо на сервері, без cookies. Політика cookies