422], ); } public static function create(array $input) { $id = (string) ($input['sessionId'] ?? ''); $session = APEARI_Workspace::load($id); if (is_wp_error($session)) { return $session; } if (!$session['scopes']['page']) { return self::invalid(__('Włącz edycję bloków wybranej strony, aby wysłać polecenie.', 'apeari')); } $page = (string) ($input['pageRef'] ?? 'home'); $document = APEARI_Workspace::page($session, $page); if (is_wp_error($document)) { return $document; } $refs = [$page]; $expand_selection = (bool) preg_match( '/cał\w*\s+stron|wszystki\w*\s+(?:blok|sekcj|element)|inne\w*\s+(?:blok|sekcj|element)|poza\s+zaznaczon/iu', (string) ($input['instruction'] ?? ''), ); $focus = $input['selection'] ?? null; $expected_key = is_array($focus) && is_string($focus['documentRef'] ?? null) && is_array($focus['path'] ?? null) ? 'element:' . $focus['documentRef'] . ':' . implode('.', $focus['path']) : 'page:' . $page; $thread_key = $input['threadKey'] ?? (is_array($focus) ? $expected_key : 'general'); if ( !is_string($thread_key) || ('general' !== $thread_key && $thread_key !== $expected_key) || strlen($thread_key) > 240 ) { return self::invalid(__('Nieprawidłowa rozmowa dla zaznaczonego elementu.', 'apeari')); } $media = APEARI_Media::context($input['mediaIds'] ?? [], !empty($input['mediaRequested'])); if (is_wp_error($media)) { return $media; } $linked = []; $reference_ids = []; if (!is_array($reference_ids) || count($reference_ids) > 4) { return self::invalid(__('Dołącz maksymalnie cztery różne obrazy referencyjne.', 'apeari')); } $allowed_ids = array_column($media, 'id'); $seen_references = []; foreach ($reference_ids as $reference_id) { if ( !is_int($reference_id) || !in_array($reference_id, $allowed_ids, true) || isset($seen_references[$reference_id]) ) { return self::invalid( __('Obraz referencyjny musi pochodzić z dołączonych Multimediów.', 'apeari'), ); } $seen_references[$reference_id] = true; } $context = [ 'historyVersion' => 1, 'schemaVersion' => 3, 'scopes' => $session['scopes'], 'documents' => [], 'targets' => [], 'focus' => null, 'threadKey' => $thread_key, 'conversation' => [], 'media' => $media, 'mediaRequested' => !empty($input['mediaRequested']), 'referenceIds' => $reference_ids, 'availableShortcodes' => [], 'linkedInstructionHash' => $linked ? hash('sha256', (string) ($input['instruction'] ?? '')) : '', 'truncated' => false, ]; foreach (array_unique($refs) as $ref) { $context['expandSelection'] = $expand_selection; $scope = 'page'; if (count($context['documents']) >= 8 || count($context['targets']) >= 120) { $context['truncated'] = true; break; } $snapshot = APEARI_Context::snapshot($id, $ref); if (is_wp_error($snapshot)) { return $snapshot; } $doc = $snapshot['document']; $context['documents'][$ref] = [ 'documentRef' => $ref, 'documentVersion' => $snapshot['version'], 'baseHash' => $doc['hash'], 'title' => $doc['title'], 'scope' => $scope, ]; $root = [ 'documentRef' => $ref, 'documentVersion' => $snapshot['version'], 'baseHash' => $doc['hash'], 'blockPath' => [], 'blockHash' => $snapshot['version'], 'placement' => 'inside', ]; $context['targets']['t' . count($context['targets'])] = [ 'target' => $root, 'blockName' => 'document', 'ancestors' => [], 'allowedStyles' => [], 'text' => '', 'source' => '', 'sourceTruncated' => false, ]; $nodes = APEARI_Context::nodes(parse_blocks($snapshot['content'])); $selected_path = ($focus['documentRef'] ?? null) === $ref && 'edit' === ($focus['placement'] ?? 'edit') && is_array($focus['path'] ?? null) && !empty($focus['path']) && !$expand_selection && empty($input['mediaIds']) && empty($input['mediaRequested']) && self::selected_text((string) ($input['instruction'] ?? '')) ? $focus['path'] : null; if ($selected_path) { self::walk($nodes, [], [], $root, $context, $selected_path); } if ( !$selected_path || !self::can_set_selected_text($context['targets']['t1']['blockName'] ?? '') ) { unset($context['targets']['t1']); self::walk($nodes, [], [], $root, $context); } } if (is_array($focus) && isset($context['documents'][$focus['documentRef'] ?? ''])) { foreach ($context['targets'] as $alias => &$candidate) { if ( $candidate['target']['documentRef'] !== $focus['documentRef'] || $candidate['target']['blockPath'] !== ($focus['path'] ?? null) ) { continue; } if ($candidate['target']['documentVersion'] !== ($focus['version'] ?? '')) { return APEARI_Context::stale(); } $placement = $focus['placement'] ?? 'edit'; if ( !in_array($placement, ['edit', 'before', 'after', 'inside'], true) || ('inside' === $placement && !in_array($candidate['blockName'], APEARI_Plans::CONTAINERS, true)) ) { return self::invalid(__('Wybierz kontener do wstawienia bloku wewnątrz.', 'apeari')); } $candidate['target']['placement'] = $placement; $context['focus'] = $alias; if (!in_array($candidate['blockName'], APEARI_Plans::BLOCKS, true)) { $context['selectedElement'] = [ 'blockName' => $candidate['blockName'], 'dynamic' => true, 'directTarget' => true, ]; } $element = $focus['element'] ?? []; foreach (['tag', 'classes', 'path', 'text'] as $field) { if (is_string($element[$field] ?? null)) { $candidate['element'][$field] = sanitize_text_field( mb_substr($element[$field], 0, 1200), ); } } break; } unset($candidate); if (null === $context['focus']) { $doc = $context['documents'][$focus['documentRef']]; if ( $doc['documentVersion'] !== ($focus['version'] ?? '') || !is_array($focus['path'] ?? null) ) { return APEARI_Context::stale(); } $snapshot = APEARI_Context::snapshot($id, $focus['documentRef']); if (is_wp_error($snapshot)) { return $snapshot; } $found = APEARI_Context::find($snapshot['content'], $focus['path']); if (is_wp_error($found)) { return $found; } $context['selectedElement'] = [ 'blockName' => $found['block']['blockName'], 'attributes' => $found['block']['attrs'], 'source' => '', 'text' => sanitize_text_field( mb_substr((string) ($focus['element']['text'] ?? ''), 0, 1200), ), 'dynamic' => true, 'placement' => $focus['placement'] ?? 'edit', ]; $depth = -1; foreach ($context['targets'] as $alias => $candidate) { $path = $candidate['target']['blockPath']; if ( $candidate['target']['documentRef'] === $focus['documentRef'] && count($path) > $depth && array_slice($focus['path'], 0, count($path)) === $path ) { $context['focus'] = $alias; $depth = count($path); } } } } if (is_array($focus) && !$context['focus']) { return self::invalid( __('Nie znaleziono zaznaczonego bloku w kontekście. Wskaż go ponownie.', 'apeari'), ); } $history = $input['conversation'] ?? ($session['conversations'][$thread_key] ?? []); if (!is_array($history)) { return self::invalid(); } foreach (array_slice(APEARI_Conversations::clean($history, true), -6) as $message) { if ( is_array($message) && in_array($message['role'] ?? '', ['user', 'assistant'], true) && is_string($message['content'] ?? null) ) { $context['conversation'][] = [ 'role' => $message['role'], 'content' => sanitize_textarea_field(mb_substr($message['content'], 0, 2000)), 'createdAt' => $message['createdAt'] ?? time(), ]; if ( !$reference_ids && 'user' === $message['role'] && is_array($message['attachments'] ?? null) ) { $previous = array_column($message['attachments'], 'id'); $previous = array_slice(array_values(array_filter($previous, 'is_int')), 0, 4); if ($previous && !is_wp_error(APEARI_Media::context($previous, false))) { $context['referenceIds'] = $previous; } } } } if ($context['mediaRequested'] && empty($input['mediaIds']) && $context['referenceIds']) { $reused = APEARI_Media::context($context['referenceIds'], false); if (is_wp_error($reused)) { return $reused; } $context['media'] = array_merge($reused, $linked); } if (strlen(wp_json_encode($context)) > 240000) { return self::invalid( __('Kontekst strony jest zbyt duży. Zawęź zakres edycji checkboxami.', 'apeari'), ); } $context_id = hash( 'sha256', 'chat3|' . get_current_blog_id() . '|' . get_current_user_id() . '|' . $id . '|' . wp_json_encode($context), ); $cached = get_transient('apeari_chat_' . $context_id); APEARI_Privacy::track('apeari_chat_' . $context_id); set_transient( 'apeari_chat_' . $context_id, [ 'user' => get_current_user_id(), 'theme' => get_stylesheet(), 'sessionId' => $id, 'context' => $context, ], APEARI_Context::TTL, ); return [ 'contextId' => $context_id, 'cacheHit' => (bool) $cached, 'targetCount' => count($context['targets']), 'documentCount' => count($context['documents']), 'truncated' => $context['truncated'], ]; } private static function walk( array $nodes, array $path, array $parents, array $root, array &$context, ?array $selected_path = null, ): void { if (null !== $selected_path) { $index = $selected_path[count($path)] ?? null; $nodes = is_int($index) && isset($nodes[$index]) ? [$index => $nodes[$index]] : []; } foreach ($nodes as $index => $block) { if (count($context['targets']) >= 120) { $context['truncated'] = true; return; } if (count($path) >= 25) { $context['truncated'] = true; continue; } if ( !empty($block['attrs']['templateLock']) || !empty($block['attrs']['lock']['edit']) || !empty($block['attrs']['metadata']['bindings']) ) { continue; } $current_path = array_merge($path, [$index]); if ( (null === $selected_path || $selected_path === $current_path) && is_string($block['blockName']) && in_array($block['blockName'], array_merge(APEARI_Plans::BLOCKS, ['core/gallery']), true) && WP_Block_Type_Registry::get_instance()->is_registered($block['blockName']) ) { $source = serialize_block($block); $target = $root; $target['blockPath'] = $current_path; $target['blockHash'] = hash('sha256', $source); $target['placement'] = 'edit'; $context['targets']['t' . count($context['targets'])] = [ 'target' => $target, 'blockName' => $block['blockName'], 'attributes' => $block['attrs'], 'ancestors' => $parents, 'allowedStyles' => APEARI_Providers::allowed_styles($block['blockName']), 'text' => mb_substr(wp_strip_all_tags($block['innerHTML']), 0, 350), 'source' => '', 'sourceTruncated' => mb_strlen($source) > 450, ]; } $ancestors = array_merge($parents, [['blockName' => $block['blockName']]]); self::walk( APEARI_Context::nodes($block['innerBlocks']), $current_path, $ancestors, $root, $context, $selected_path, ); } } public static function resolve(string $id, string $session_id = '') { $cached = preg_match('/^[a-f0-9]{64}$/', $id) ? get_transient('apeari_chat_' . $id) : false; if ( !is_array($cached) || $cached['user'] !== get_current_user_id() || $cached['theme'] !== get_stylesheet() || ($session_id && $session_id !== $cached['sessionId']) ) { return new WP_Error( 'apeari_chat_expired', __('Kontekst rozmowy wygasł. Wyślij polecenie ponownie.', 'apeari'), [ 'status' => 410, ], ); } $session = APEARI_Workspace::load($cached['sessionId']); if (is_wp_error($session)) { return $session; } $context = $cached['context']; $context['conversation'] = ($context['historyVersion'] ?? 0) === 1 ? APEARI_Conversations::clean($context['conversation'] ?? []) : []; unset($context['historyVersion']); if ($context['scopes'] !== $session['scopes']) { return APEARI_Context::stale(); } if (!empty($context['media'])) { $stored_media = array_filter( $context['media'], static fn($key) => str_starts_with($key, 'm'), ARRAY_FILTER_USE_KEY, ); $ids = array_values(array_filter(array_column($stored_media, 'id'))); $current_media = APEARI_Media::context($ids, isset($stored_media['m0']) && !$ids); if (is_wp_error($current_media) || $current_media !== $stored_media) { return APEARI_Context::stale(); } } if ( !empty($context['referenceIds']) && is_wp_error(APEARI_Media::context($context['referenceIds'], false)) ) { return APEARI_Context::stale(); } foreach ($context['documents'] as $ref => $doc) { $snapshot = APEARI_Context::snapshot($cached['sessionId'], $ref); if (is_wp_error($snapshot)) { return $snapshot; } if ( $snapshot['version'] !== $doc['documentVersion'] || $snapshot['document']['hash'] !== $doc['baseHash'] ) { return APEARI_Context::stale(); } } return $context; } public static function validate($plan, array $context) { if ( !is_array($plan) || !is_string($plan['summary'] ?? null) || !is_array($plan['changes'] ?? null) || count($plan['changes']) > 8 ) { return self::invalid(); } if (!$plan['changes']) { return [ 'summary' => sanitize_text_field(mb_substr($plan['summary'], 0, 500)) ?: __('Doprecyzuj, który element mam zmienić.', 'apeari'), 'changes' => [], ]; } $clean = ['summary' => sanitize_text_field(mb_substr($plan['summary'], 0, 500)), 'changes' => []]; $seen = []; $operations = 0; $nodes = 0; $count_nodes = static function (array $items) use (&$count_nodes): int { $count = count($items); foreach ($items as $node) { $count += $count_nodes($node['children']); } return $count; }; foreach ($plan['changes'] as $change) { $alias = $change['targetId'] ?? ''; if (!is_string($alias) || !isset($context['targets'][$alias]) || isset($seen[$alias])) { return self::invalid(); } $seen[$alias] = true; $candidate = $context['targets'][$alias]; $part = APEARI_Plans::validate([ 'summary' => $clean['summary'], 'operations' => $change['operations'] ?? null, ]); if (is_wp_error($part)) { return $part; } if (!empty($context['referenceIds']) && empty($context['mediaRequested'])) { foreach ($part['operations'] as $operation) { $keys = 'insert_gallery' === $operation['type'] ? $operation['mediaKeys'] : (isset($operation['mediaKey']) ? [$operation['mediaKey']] : []); foreach ($keys as $key) { if (in_array($context['media'][$key]['id'] ?? 0, $context['referenceIds'], true)) { return self::invalid( __( 'Obraz dołączony do rozmowy jest referencją. Aby umieścić go na stronie, poproś o to wprost.', 'apeari', ), ); } } } } $part['operations'] = APEARI_Media::bind($part['operations'], $context['media'] ?? []); if (is_wp_error($part['operations'])) { return $part['operations']; } $operations += count($part['operations']); foreach ($part['operations'] as $op) { $nodes += 'insert_blocks' === $op['type'] ? $count_nodes($op['blocks']) : ('insert_section' === $op['type'] ? 3 : ('insert_gallery' === $op['type'] ? count($op['attachments']) + 1 : (in_array($op['type'], ['insert_image'], true) ? 1 : 0))); if ('document' !== $candidate['blockName']) { continue; } if ( !in_array( $op['type'], ['insert_blocks', 'insert_section', 'insert_image', 'insert_gallery'], true, ) || 'inside' !== $op['position'] ) { return self::invalid(); } $root_nodes = 'insert_image' === $op['type'] ? [['blockName' => 'core/image']] : ('insert_gallery' === $op['type'] ? [['blockName' => 'core/gallery']] : $op['blocks'] ?? [['blockName' => 'core/group']]); foreach ($root_nodes as $node) { if (!APEARI_Plans::allowed_parent($node['blockName'], null)) { return self::invalid(); } } } if ($operations > 12 || $nodes > 40) { return self::invalid( __( 'Podziel polecenie na mniejsze zmiany (maksymalnie 12 operacji i 40 nowych bloków).', 'apeari', ), ); } if ('document' !== $candidate['blockName']) { $part = APEARI_Plans::for_context($part, $candidate); } if (is_wp_error($part)) { return $part; } $clean['changes'][] = ['targetId' => $alias, 'operations' => $part['operations']]; } return $clean; } public static function selected_text(string $instruction): ?array { if ( !preg_match( '/^\s*(?:(?:zmień|zmien|zamień|zamien|ustaw|podmień|podmien)\s+(?:tekst|treść|tresc|napis)\s+na\s+|(?:change|set|replace)\s+(?:the\s+)?text\s+(?:to|with)\s+)(.+?)\s*$/isu', $instruction, $parts, ) ) { return null; } $text = $parts[1]; if (preg_match('/^(?:"([^"]*)"|„([^”]*)”|“([^”]*)”)$/su', $text, $quoted, PREG_UNMATCHED_AS_NULL)) { $text = $quoted[1] ?? ($quoted[2] ?? $quoted[3]); } elseif ( preg_match( '/[<>\r\n]|^(?:bardziej|mniej|krótsz\w*|dłuższ\w*|inn\w*|shorter|longer|more|less)(?:\s|[.!?]?$)|(?:^|[.!?,;]\s*|\s+(?:i|oraz|a potem|and|then)\s+)(?:zmień|zmien|ustaw|dodaj|usuń|usun|wstaw|przesuń|popraw|skróć|napisz|change|set|add|remove|rewrite)\b/iu', $text, ) ) { return null; } if ($text === '' || strlen($text) > 6000 || sanitize_textarea_field($text) !== $text) { return null; } return ['type' => 'set_text', 'text' => $text]; } public static function can_set_selected_text(string $block_name): bool { return in_array( $block_name, ['core/paragraph', 'core/heading', 'core/button', 'core/list-item'], true, ); } public static function selected_text_plan(string $instruction, array $context) { $focus = $context['focus'] ?? null; $target = $context['targets'][$focus] ?? []; if ( !$focus || !empty($context['expandSelection']) || !empty($context['selectedElement']['dynamic']) || !empty($context['mediaRequested']) || !empty($context['referenceIds']) || 'edit' !== ($target['target']['placement'] ?? '') || !self::can_set_selected_text($target['blockName'] ?? '') ) { return null; } $operation = self::selected_text($instruction); if (null === $operation) { return null; } return self::validate( [ 'summary' => __('Zmieniono tekst zaznaczonego bloku w prywatnym podglądzie.', 'apeari'), 'changes' => [['targetId' => $focus, 'operations' => [$operation]]], ], $context, ); } public static function common(string $instruction, array $context) { $gallery = (bool) preg_match('/galeri|karuzel|carousel/iu', $instruction); if (!$gallery && !self::simple_image_request($instruction)) { return null; } $keys = array_keys($context['media'] ?? []); if (!$keys || ($gallery && count($keys) < 2)) { return [ 'summary' => $gallery ? __('Wybierz przynajmniej dwa zdjęcia z biblioteki.', 'apeari') : __('Wybierz istniejące zdjęcie z biblioteki.', 'apeari'), 'changes' => [], ]; } $alias = $context['focus'] ?? null; if (!$alias) { $alias = array_key_first($context['targets']); } $candidate = $context['targets'][$alias]; $position = in_array( $candidate['blockName'], array_merge(APEARI_Plans::CONTAINERS, ['document']), true, ) ? 'inside' : 'after'; $op = $gallery ? [ 'type' => 'insert_gallery', 'mediaKeys' => $keys, 'position' => $position, 'presentation' => preg_match('/karuzel|carousel/iu', $instruction) ? 'carousel' : 'gallery', ] : ['type' => 'insert_image', 'mediaKey' => $keys[0], 'position' => $position]; if (!$gallery && $candidate['blockName'] === 'core/image') { $op = ['type' => 'set_image', 'mediaKey' => $keys[0]]; } return self::validate( [ 'summary' => __('Zdjęcia z biblioteki przygotowane w szkicu.', 'apeari'), 'changes' => [['targetId' => $alias, 'operations' => [$op]]], ], $context, ); } public static function demo(string $instruction, array $context) { $common = self::common($instruction, $context); if (null !== $common) { return $common; } if ( !empty($context['referenceIds']) && preg_match( '/jak na (?:tym |załączonym )?(?:zdjęciu|zdjeciu|obrazie)|takie logo|według (?:zdjęcia|obrazu)/iu', $instruction, ) && !preg_match('/\b(?:wstaw|dodaj|umieść|użyj|ustaw)\b/iu', $instruction) ) { return [ 'summary' => __( 'Tryb demo nie analizuje obrazów. Wybierz model OpenAI lub Claude obsługujący obrazy, aby użyć tej referencji.', 'apeari', ), 'changes' => [], ]; } if ( $context['media'] && preg_match( '/\b(?:wstaw|dodaj|umieść|użyj|ustaw|zamień|zastąp)\b.{0,90}(?:zdję|zdjec|fot|obraz|grafik|galer|karuzel|carousel|\bimage\b|\bphoto\b)/iu', $instruction, ) ) { $alias = $context['focus']; if (!$alias) { foreach ($context['targets'] as $candidate_id => $candidate) { if ( 'document' === $candidate['blockName'] && str_starts_with($candidate['target']['documentRef'], 'page:') ) { $alias = $candidate_id; break; } } } if (!$alias) { return self::invalid(__('Nie znaleziono miejsca na zdjęcie na tej stronie.', 'apeari')); } $keys = array_keys($context['media']); $candidate = $context['targets'][$alias]; $position = 'document' === $candidate['blockName'] ? 'inside' : (preg_match('/\bnad\b|\bprzed\b/iu', $instruction) ? 'before' : (preg_match('/\bwewnątrz\b|\bw środku\b/iu', $instruction) ? 'inside' : 'after')); $carousel = (bool) preg_match('/karuzel|carousel/iu', $instruction); if ($carousel && count($keys) < 2) { return [ 'summary' => __( 'Wybierz co najmniej dwa zdjęcia z biblioteki WordPressa, aby utworzyć karuzelę.', 'apeari', ), 'changes' => [], ]; } $gallery = count($keys) > 1 && preg_match('/galer|karuzel|carousel/iu', $instruction); $op = $gallery ? [ 'type' => 'insert_gallery', 'position' => $position, 'mediaKeys' => $keys, 'presentation' => $carousel ? 'carousel' : 'gallery', ] : ['type' => 'insert_image', 'position' => $position, 'mediaKey' => $keys[0]]; if ('core/image' === $candidate['blockName'] && !$gallery && $context['media'][$keys[0]]['id']) { $op = ['type' => 'set_image', 'mediaKey' => $keys[0]]; } return self::validate( [ 'summary' => __('Zdjęcie z biblioteki WordPressa dodano do podglądu.', 'apeari'), 'changes' => [['targetId' => $alias, 'operations' => [$op]]], ], $context, ); } $part = APEARI_Plans::demo($instruction); if (is_wp_error($part)) { return $part; } $lower = mb_strtolower($instruction); $region = preg_match('/stopk|footer/u', $lower) ? 'footer' : (preg_match('/nagłówk.*strony|header/u', $lower) ? 'header' : ''); $insertion = 'insert_blocks' === $part['operations'][0]['type']; $aliases = $context['focus'] && !$region ? [$context['focus']] : array_keys($context['targets']); foreach ($aliases as $alias) { $candidate = $context['targets'][$alias]; $ref = $candidate['target']['documentRef']; if ($region && !str_contains($ref, '//' . $region)) { continue; } if ( !$region && !$context['focus'] && !str_starts_with($ref, 'page:') && $context['scopes']['page'] ) { continue; } if (!$context['focus'] && $insertion && 'document' !== $candidate['blockName']) { continue; } $ops = $part['operations']; if ($insertion && 'edit' !== $candidate['target']['placement']) { $ops[0]['position'] = $candidate['target']['placement']; } $plan = self::validate( ['summary' => $part['summary'], 'changes' => [['targetId' => $alias, 'operations' => $ops]]], $context, ); if (!is_wp_error($plan)) { return $plan; } } return self::invalid( __( 'Demo nie znalazło pasującego bloku w dozwolonym zakresie. Wybierz element lub zmień polecenie.', 'apeari', ), ); } }