Przejdź do treści

TipTap, asystent AI i autozapis: jak zbudowaliśmy edytor treści w blogu

Jak działa edytor w naszym CMS: TipTap, metadane generowane przez AI, autozapis szkiców i obróbka obrazów. Według naszych pomiarów tworzenie wpisu skróciło się o około 40%.

AutorDział IT
  • 7 min czytania
  • 210 wyświetleń
TipTap, asystent AI i autozapis: jak zbudowaliśmy edytor treści w blogu

W pierwszym artykule pisałem o architekturze naszego bloga. Ten dotyczy części, z którą autorzy mają do czynienia na co dzień: edytora. Pokażę, jak go zbudowaliśmy, jak AI przejmuje rutynowe zadania i jak działa autozapis.

Wybór edytora: dlaczego TipTap?

Rozważaliśmy Quill, Draft.js i CKEditor, a skończyliśmy na TipTap. Powody:

  • Jest zbudowany na ProseMirror, solidnej bazie

  • Bardzo dobrze współpracuje z Vue.js

  • Ma modularną architekturę

  • Daje pełną kontrolę nad funkcjonalnością

Integracja edytora TipTap

Tak wygląda nasz komponent edytora:

<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">
      <!-- Panel narzędzi -->
      <button 
        type="button" 
        @click="editor?.chain().focus().toggleBold().run()"
        :class="buttonClass(editor?.isActive('bold'))"
      >
        <Lucide icon="Bold" class="w-4 h-4" />
      </button>
      <!-- Inne przyciski formatowania -->
    </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>

Asystent AI do generowania metadanych

Jedna z najbardziej przydatnych funkcji to generowanie metadanych SEO przez AI. Ręczne wymyślanie tytułów, opisów i słów kluczowych jest nudne, więc AI czyta treść i proponuje warianty, z których autor wybiera najlepszy.

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
    {
        // Wstępne przetwarzanie treści
        $processedContent = $this->preprocessContent($content, $contentType, $language);
        
        // Budowanie prompta
        $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";
    }
}

Przetwarzanie treści przed wysłaniem do AI

Zanim treść trafi do AI, odpowiednio ją przygotowujemy:

class ContentProcessor
{
    protected int $maxTokens;
    
    public function processForAI(string $htmlContent, string $language, array $options): string
    {
        // Czyszczenie HTML
        $cleanHtml = $this->cleanHtml($htmlContent);
        
        // Wyciągnięcie kluczowej treści
        $extractedContent = $this->extractKeyContent($cleanHtml, $options);
        
        // Konwersja do przetworzonego tekstu
        $processedText = $this->convertToProcessedText($extractedContent, $language);
        
        // Kompresja do limitu tokenów
        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' => [],    // Nagłówki
            'important' => [],   // Wyróżniony tekst
            'regular' => [],     // Zwykłe paragrafy
            'lists' => []        // Listy
        ];
        
        // Wyciągamy nagłówki z wagami
        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;
    }
}

Autozapis szkiców

Każdy autor zna to uczucie, gdy znika niezapisana praca. Autozapis usuwa to ryzyko:

// Composable do zarządzania szkicami
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,
      // ... inne pola
    };
    
    draftStore.saveDraft(draftData, postId);
    lastSaveTime.value = Date.now();
  };
  
  // Automatyczny zapis co 30 sekund
  const startAutosave = () => {
    autosaveInterval.value = setInterval(() => {
      saveDraft();
    }, 30000);
  };
  
  // Śledzenie zmian formularza
  watch(
    () => [form.contents, form.categories, form.tags, /* ... */],
    () => {
      handleFormChange();
    },
    { deep: true, flush: 'post' }
  );
  
  return {
    saveDraft,
    loadDraft,
    clearDraft,
    hasDraft,
    formatLastSaveTime
  };
}

Pinia Store dla szkiców

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);
    
    // Głębokie klonowanie dla uniknięcia problemów z reaktywnością
    const draftData = JSON.parse(JSON.stringify(formData));
    
    drafts.value[key] = draftData;
    lastSaved.value[key] = Date.now();
    
    // Zapisywanie w 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
  };
});

Komponent odzyskiwania szkicu

<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>

Przetwarzanie i optymalizacja obrazów

Dla każdego obrazu tworzymy trzy wersje, żeby strona ładowała tylko to, czego potrzebuje:

class ImageProcessing
{
    private const SCALES = [
        'low' => 400,      // Dla podglądów i urządzeń mobilnych
        'medium' => 800,   // Dla normalnego wyświetlania
        'full' => null     // Oryginalna rozdzielczość
    ];

    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);
                }
                
                // Konwersja do WebP dla optymalnej kompresji
                $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;
        }
    }
}

Składamy komponenty w całość

Tak te elementy współpracują w formularzu tworzenia wpisu:

<script setup lang="ts">
// Kompozycje dla różnych aspektów formularza
const { form, saveDraft, submitForm } = usePostForm(props.post, initializeContents);
const { hasDraft, loadDraft, clearDraft } = usePostDraft(form, props.post);
const { aiLoading, aiError, launchAI } = usePostAI(form, activeLocale);

// Podczas montowania sprawdzamy obecność szkicu
onMounted(() => {
  if (shouldShowDraftWarning.value) {
    showDraftRecoveryModal.value = true;
  }
});

// Obsługa generowania AI
const handleAIGeneration = async () => {
  await launchAI();
  // AI automatycznie zaktualizuje pola formularza
};

// Wysyłanie formularza
const handleSubmit = () => {
  clearDraft(); // Czyścimy szkic po pomyślnym wysłaniu
  submitForm();
};
</script>

Wyniki

Po wdrożeniu zmierzyliśmy:

  • Czas tworzenia wpisu krótszy o 40% dzięki generowaniu metadanych przez AI

  • Zero przypadków utraty danych od wprowadzenia autozapisu

  • CTR wyższy o 25%, co przypisujemy zoptymalizowanym tytułom SEO

  • Obciążenie serwera niższe o 30% dzięki optymalizacji obrazów

Wnioski

Dobry system zarządzania treścią to coś więcej niż ładny edytor. U nas sprowadziło się to do czterech rzeczy:

  • AI przejmuje rutynowe zadania

  • Autozapis chroni przed utratą danych

  • Media są zoptymalizowane, więc strony ładują się szybko

  • Interfejs jest zbudowany wokół tego, co autorzy faktycznie robią

W następnym artykule opowiem, jak utrzymaliśmy wydajność systemu pod dużym obciążeniem: przetwarzanie asynchroniczne, liczenie wyświetleń i cachowanie.

Ciąg dalszy nastąpi.

Tagi

Udostępnij ten artykuł

Wróć do bloga

Pliki cookies

Używamy niezbędnych plików cookies i jednego funkcjonalnego, tego tylko za Twoją zgodą. Statystyki odwiedzin liczymy na serwerze, bez cookies. Polityka cookies