feat: intégration plugin Kirby SEO
All checks were successful
Deploy / Deploy to Production (push) Successful in 22s
All checks were successful
Deploy / Deploy to Production (push) Successful in 22s
- Ajout de tobimori/kirby-seo via Composer
- snippet('seo/head') dans header.php (remplace les meta manuels)
- snippet('seo/schemas') dans footer.php pour JSON-LD
- Onglet SEO ajouté dans site.yml et tous les blueprints de pages
- Configuration SEO dans config.php (sitemap, robots, canonicalBase TODO)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
baab2fb3a1
commit
58c31ea391
133 changed files with 9201 additions and 253 deletions
141
site/plugins/kirby-seo/classes/Ai/Chunk.php
Normal file
141
site/plugins/kirby-seo/classes/Ai/Chunk.php
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
<?php
|
||||
|
||||
namespace tobimori\Seo\Ai;
|
||||
|
||||
/**
|
||||
* Value object representing a streamed AI response chunk.
|
||||
*/
|
||||
final class Chunk
|
||||
{
|
||||
public const string TYPE_STREAM_START = 'stream-start';
|
||||
public const string TYPE_STREAM_END = 'stream-end';
|
||||
public const string TYPE_TEXT_START = 'text-start';
|
||||
public const string TYPE_TEXT_DELTA = 'text-delta';
|
||||
public const string TYPE_TEXT_COMPLETE = 'text-complete';
|
||||
public const string TYPE_THINKING_START = 'thinking-start';
|
||||
public const string TYPE_THINKING_DELTA = 'thinking-delta';
|
||||
public const string TYPE_THINKING_COMPLETE = 'thinking-complete';
|
||||
public const string TYPE_TOOL_CALL = 'tool-call';
|
||||
public const string TYPE_TOOL_RESULT = 'tool-result';
|
||||
public const string TYPE_ERROR = 'error';
|
||||
|
||||
private function __construct(
|
||||
public readonly string $type,
|
||||
public readonly mixed $payload = null,
|
||||
public readonly ?string $text = null
|
||||
) {
|
||||
}
|
||||
|
||||
public static function streamStart(array $payload = []): self
|
||||
{
|
||||
return new self(self::TYPE_STREAM_START, $payload);
|
||||
}
|
||||
|
||||
public static function streamEnd(array $payload = []): self
|
||||
{
|
||||
return new self(self::TYPE_STREAM_END, $payload);
|
||||
}
|
||||
|
||||
public static function textStart(array $payload = []): self
|
||||
{
|
||||
return new self(self::TYPE_TEXT_START, $payload);
|
||||
}
|
||||
|
||||
public static function textDelta(string $text, array $payload = []): self
|
||||
{
|
||||
return new self(self::TYPE_TEXT_DELTA, $payload, $text);
|
||||
}
|
||||
|
||||
public static function textComplete(array $payload = []): self
|
||||
{
|
||||
return new self(self::TYPE_TEXT_COMPLETE, $payload);
|
||||
}
|
||||
|
||||
public static function thinkingStart(array $payload = []): self
|
||||
{
|
||||
return new self(self::TYPE_THINKING_START, $payload);
|
||||
}
|
||||
|
||||
public static function thinkingDelta(string $text, array $payload = []): self
|
||||
{
|
||||
return new self(self::TYPE_THINKING_DELTA, $payload, $text);
|
||||
}
|
||||
|
||||
public static function thinkingComplete(array $payload = []): self
|
||||
{
|
||||
return new self(self::TYPE_THINKING_COMPLETE, $payload);
|
||||
}
|
||||
|
||||
public static function toolCall(array $payload = []): self
|
||||
{
|
||||
return new self(self::TYPE_TOOL_CALL, $payload);
|
||||
}
|
||||
|
||||
public static function toolResult(array $payload = []): self
|
||||
{
|
||||
return new self(self::TYPE_TOOL_RESULT, $payload);
|
||||
}
|
||||
|
||||
public static function error(string $message, array $payload = []): self
|
||||
{
|
||||
return new self(self::TYPE_ERROR, [
|
||||
'message' => $message,
|
||||
'data' => $payload,
|
||||
]);
|
||||
}
|
||||
|
||||
public function isStreamStart(): bool
|
||||
{
|
||||
return $this->type === self::TYPE_STREAM_START;
|
||||
}
|
||||
|
||||
public function isStreamEnd(): bool
|
||||
{
|
||||
return $this->type === self::TYPE_STREAM_END;
|
||||
}
|
||||
|
||||
public function isTextStart(): bool
|
||||
{
|
||||
return $this->type === self::TYPE_TEXT_START;
|
||||
}
|
||||
|
||||
public function isTextDelta(): bool
|
||||
{
|
||||
return $this->type === self::TYPE_TEXT_DELTA;
|
||||
}
|
||||
|
||||
public function isTextComplete(): bool
|
||||
{
|
||||
return $this->type === self::TYPE_TEXT_COMPLETE;
|
||||
}
|
||||
|
||||
public function isThinkingStart(): bool
|
||||
{
|
||||
return $this->type === self::TYPE_THINKING_START;
|
||||
}
|
||||
|
||||
public function isThinkingDelta(): bool
|
||||
{
|
||||
return $this->type === self::TYPE_THINKING_DELTA;
|
||||
}
|
||||
|
||||
public function isThinkingComplete(): bool
|
||||
{
|
||||
return $this->type === self::TYPE_THINKING_COMPLETE;
|
||||
}
|
||||
|
||||
public function isToolCall(): bool
|
||||
{
|
||||
return $this->type === self::TYPE_TOOL_CALL;
|
||||
}
|
||||
|
||||
public function isToolResult(): bool
|
||||
{
|
||||
return $this->type === self::TYPE_TOOL_RESULT;
|
||||
}
|
||||
|
||||
public function isError(): bool
|
||||
{
|
||||
return $this->type === self::TYPE_ERROR;
|
||||
}
|
||||
}
|
||||
86
site/plugins/kirby-seo/classes/Ai/Content.php
Normal file
86
site/plugins/kirby-seo/classes/Ai/Content.php
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
<?php
|
||||
|
||||
namespace tobimori\Seo\Ai;
|
||||
|
||||
use Imagick;
|
||||
use Kirby\Cms\File;
|
||||
|
||||
/**
|
||||
* Fluent builder for AI message content.
|
||||
* Each instance represents a single message with a role and content blocks.
|
||||
*/
|
||||
class Content
|
||||
{
|
||||
private string $role;
|
||||
private array $blocks = [];
|
||||
|
||||
private function __construct(string $role)
|
||||
{
|
||||
$this->role = $role;
|
||||
}
|
||||
|
||||
public static function user(): static
|
||||
{
|
||||
return new static('user');
|
||||
}
|
||||
|
||||
public static function assistant(): static
|
||||
{
|
||||
return new static('assistant');
|
||||
}
|
||||
|
||||
public static function system(): static
|
||||
{
|
||||
return new static('system');
|
||||
}
|
||||
|
||||
public function text(string $text): static
|
||||
{
|
||||
$this->blocks[] = ['type' => 'text', 'text' => $text];
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an image block from a Kirby File, converted to WebP for smaller payloads.
|
||||
* Non-resizable formats (SVG, etc.) are rasterized via Imagick.
|
||||
*/
|
||||
public function image(File $file, int $maxDimension = 1024): static
|
||||
{
|
||||
if ($file->isResizable()) {
|
||||
$thumb = $file->thumb([
|
||||
'width' => $maxDimension,
|
||||
'height' => $maxDimension,
|
||||
'format' => 'webp',
|
||||
]);
|
||||
|
||||
$data = base64_encode($thumb->read());
|
||||
} else {
|
||||
// TODO: better handling without ext-imagick
|
||||
$imagick = new Imagick();
|
||||
$imagick->readImage($file->root());
|
||||
$imagick->setImageFormat('webp');
|
||||
$imagick->thumbnailImage($maxDimension, $maxDimension, true);
|
||||
$data = base64_encode($imagick->getImageBlob());
|
||||
$imagick->clear();
|
||||
$imagick->destroy();
|
||||
}
|
||||
|
||||
$this->blocks[] = [
|
||||
'type' => 'image',
|
||||
'data' => $data,
|
||||
'mediaType' => 'image/webp',
|
||||
];
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function role(): string
|
||||
{
|
||||
return $this->role;
|
||||
}
|
||||
|
||||
public function blocks(): array
|
||||
{
|
||||
return $this->blocks;
|
||||
}
|
||||
}
|
||||
40
site/plugins/kirby-seo/classes/Ai/Driver.php
Normal file
40
site/plugins/kirby-seo/classes/Ai/Driver.php
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
|
||||
namespace tobimori\Seo\Ai;
|
||||
|
||||
use Generator;
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
use tobimori\Seo\Seo;
|
||||
|
||||
abstract class Driver
|
||||
{
|
||||
public function __construct(protected string $providerId)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams a response for the given content.
|
||||
*
|
||||
* @param array<Content> $content Array of Content messages forming a conversation.
|
||||
* @param string|null $model Model override.
|
||||
*
|
||||
* @return Generator<int, Chunk, mixed, void>
|
||||
*/
|
||||
abstract public function stream(array $content, string|null $model = null): Generator;
|
||||
|
||||
/**
|
||||
* Returns a configuration value or throws when required.
|
||||
*/
|
||||
protected function config(string $key, mixed $default = null, bool $required = false): mixed
|
||||
{
|
||||
$value = Seo::option("ai.providers.{$this->providerId}.config.{$key}", $default);
|
||||
|
||||
if ($required === true && ($value === null || $value === '')) {
|
||||
throw new InvalidArgumentException(
|
||||
"Missing required \"{$key}\" configuration for driver " . static::class . '.'
|
||||
);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
140
site/plugins/kirby-seo/classes/Ai/Drivers/Anthropic.php
Normal file
140
site/plugins/kirby-seo/classes/Ai/Drivers/Anthropic.php
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
<?php
|
||||
|
||||
namespace tobimori\Seo\Ai\Drivers;
|
||||
|
||||
use Generator;
|
||||
use tobimori\Seo\Ai\Chunk;
|
||||
use tobimori\Seo\Ai\Content;
|
||||
use tobimori\Seo\Ai\Driver;
|
||||
use tobimori\Seo\Ai\SseStream;
|
||||
|
||||
class Anthropic extends Driver
|
||||
{
|
||||
protected const string DEFAULT_ENDPOINT = 'https://api.anthropic.com/v1/messages';
|
||||
protected const string DEFAULT_MODEL = 'claude-4-5-haiku';
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function stream(
|
||||
array $content,
|
||||
string|null $model = null,
|
||||
): Generator {
|
||||
$apiKey = $this->config('apiKey', required: true);
|
||||
$headers = [
|
||||
'Content-Type: application/json',
|
||||
'Accept: text/event-stream',
|
||||
"x-api-key: {$apiKey}",
|
||||
'anthropic-version: 2023-06-01',
|
||||
];
|
||||
|
||||
$payload = [
|
||||
'model' => $model ?? $this->config('model', static::DEFAULT_MODEL),
|
||||
'messages' => $this->buildMessages($content),
|
||||
'max_tokens' => 4096,
|
||||
'stream' => true,
|
||||
];
|
||||
|
||||
$stream = new SseStream($this->config('endpoint', static::DEFAULT_ENDPOINT), $headers, $payload, (int)$this->config('timeout', 120));
|
||||
yield from $stream->stream(function (array $event): Generator {
|
||||
$type = $event['type'] ?? null;
|
||||
|
||||
// handle message start event
|
||||
if ($type === 'message_start') {
|
||||
yield Chunk::streamStart($event);
|
||||
return;
|
||||
}
|
||||
|
||||
// handle content block start (beginning of text output)
|
||||
if ($type === 'content_block_start') {
|
||||
$contentBlock = $event['content_block'] ?? [];
|
||||
if (($contentBlock['type'] ?? null) === 'text') {
|
||||
yield Chunk::textStart($event);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// handle content block delta (text chunks)
|
||||
if ($type === 'content_block_delta') {
|
||||
$delta = $event['delta'] ?? [];
|
||||
if (($delta['type'] ?? null) === 'text_delta') {
|
||||
$text = $delta['text'] ?? '';
|
||||
if ($text !== '') {
|
||||
yield Chunk::textDelta($text, $event);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// handle content block stop (end of text block)
|
||||
if ($type === 'content_block_stop') {
|
||||
yield Chunk::textComplete($event);
|
||||
return;
|
||||
}
|
||||
|
||||
// handle message stop (end of stream)
|
||||
if ($type === 'message_stop') {
|
||||
yield Chunk::streamEnd($event);
|
||||
return;
|
||||
}
|
||||
|
||||
// handle ping events (keep-alive)
|
||||
if ($type === 'ping') {
|
||||
// ignore ping events
|
||||
return;
|
||||
}
|
||||
|
||||
// handle error events
|
||||
if ($type === 'error') {
|
||||
$error = $event['error'] ?? [];
|
||||
$message = $error['message'] ?? 'Unknown Anthropic streaming error.';
|
||||
yield Chunk::error($message, $event);
|
||||
return;
|
||||
}
|
||||
|
||||
// handle message delta (contains usage info)
|
||||
if ($type === 'message_delta') {
|
||||
// we could extract usage info here if needed
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates an array of Content messages into the Anthropic messages format.
|
||||
*
|
||||
* @param array<Content> $content
|
||||
*/
|
||||
private function buildMessages(array $content): array
|
||||
{
|
||||
$messages = [];
|
||||
|
||||
foreach ($content as $message) {
|
||||
$blocks = [];
|
||||
foreach ($message->blocks() as $block) {
|
||||
if ($block['type'] === 'image') {
|
||||
$blocks[] = [
|
||||
'type' => 'image',
|
||||
'source' => [
|
||||
'type' => 'base64',
|
||||
'media_type' => $block['mediaType'],
|
||||
'data' => $block['data'],
|
||||
],
|
||||
];
|
||||
} elseif ($block['type'] === 'text') {
|
||||
$blocks[] = [
|
||||
'type' => 'text',
|
||||
'text' => $block['text'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$messages[] = [
|
||||
'role' => $message->role(),
|
||||
'content' => $blocks,
|
||||
];
|
||||
}
|
||||
|
||||
return $messages;
|
||||
}
|
||||
}
|
||||
149
site/plugins/kirby-seo/classes/Ai/Drivers/Gemini.php
Normal file
149
site/plugins/kirby-seo/classes/Ai/Drivers/Gemini.php
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
<?php
|
||||
|
||||
namespace tobimori\Seo\Ai\Drivers;
|
||||
|
||||
use Generator;
|
||||
use tobimori\Seo\Ai\Chunk;
|
||||
use tobimori\Seo\Ai\Content;
|
||||
use tobimori\Seo\Ai\Driver;
|
||||
use tobimori\Seo\Ai\SseStream;
|
||||
|
||||
class Gemini extends Driver
|
||||
{
|
||||
protected const string DEFAULT_ENDPOINT = 'https://generativelanguage.googleapis.com/v1beta';
|
||||
protected const string DEFAULT_MODEL = 'gemini-3.1-flash-lite-preview';
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function stream(
|
||||
array $content,
|
||||
string|null $model = null,
|
||||
): Generator {
|
||||
$apiKey = $this->config('apiKey', required: true);
|
||||
$model = $model ?? $this->config('model', static::DEFAULT_MODEL);
|
||||
$baseEndpoint = $this->config('endpoint', static::DEFAULT_ENDPOINT);
|
||||
$endpoint = "{$baseEndpoint}/models/{$model}:streamGenerateContent?alt=sse&key={$apiKey}";
|
||||
|
||||
$headers = [
|
||||
'Content-Type: application/json',
|
||||
];
|
||||
|
||||
$payload = [
|
||||
'contents' => $this->buildContents($content),
|
||||
];
|
||||
|
||||
$systemInstruction = $this->buildSystemInstruction($content);
|
||||
if ($systemInstruction !== null) {
|
||||
$payload['systemInstruction'] = $systemInstruction;
|
||||
}
|
||||
|
||||
$stream = new SseStream($endpoint, $headers, $payload, (int)$this->config('timeout', 120));
|
||||
$started = false;
|
||||
|
||||
yield from $stream->stream(function (array $event) use (&$started): Generator {
|
||||
$candidates = $event['candidates'] ?? [];
|
||||
$candidate = $candidates[0] ?? null;
|
||||
|
||||
if ($candidate === null) {
|
||||
$error = $event['error'] ?? null;
|
||||
if ($error) {
|
||||
yield Chunk::error($error['message'] ?? 'Unknown Gemini error.', $event);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$started) {
|
||||
yield Chunk::streamStart($event);
|
||||
yield Chunk::textStart($event);
|
||||
$started = true;
|
||||
}
|
||||
|
||||
$finishReason = $candidate['finishReason'] ?? null;
|
||||
if ($finishReason === 'SAFETY') {
|
||||
yield Chunk::error('Response blocked by safety filters.', $event);
|
||||
return;
|
||||
}
|
||||
|
||||
$parts = $candidate['content']['parts'] ?? [];
|
||||
foreach ($parts as $part) {
|
||||
$text = $part['text'] ?? '';
|
||||
if ($text !== '') {
|
||||
yield Chunk::textDelta($text, $event);
|
||||
}
|
||||
}
|
||||
|
||||
if ($finishReason !== null) {
|
||||
yield Chunk::textComplete($event);
|
||||
yield Chunk::streamEnd($event);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates an array of Content messages into the Gemini contents format.
|
||||
*
|
||||
* @param array<Content> $content
|
||||
*/
|
||||
private function buildContents(array $content): array
|
||||
{
|
||||
$contents = [];
|
||||
|
||||
foreach ($content as $message) {
|
||||
if ($message->role() === 'system') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parts = [];
|
||||
foreach ($message->blocks() as $block) {
|
||||
if ($block['type'] === 'image') {
|
||||
$parts[] = [
|
||||
'inline_data' => [
|
||||
'mime_type' => $block['mediaType'],
|
||||
'data' => $block['data'],
|
||||
],
|
||||
];
|
||||
} elseif ($block['type'] === 'text') {
|
||||
$parts[] = [
|
||||
'text' => $block['text'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$contents[] = [
|
||||
'role' => $message->role() === 'assistant' ? 'model' : 'user',
|
||||
'parts' => $parts,
|
||||
];
|
||||
}
|
||||
|
||||
return $contents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts system messages into a Gemini systemInstruction object.
|
||||
*
|
||||
* @param array<Content> $content
|
||||
*/
|
||||
private function buildSystemInstruction(array $content): array|null
|
||||
{
|
||||
$parts = [];
|
||||
|
||||
foreach ($content as $message) {
|
||||
if ($message->role() !== 'system') {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($message->blocks() as $block) {
|
||||
if ($block['type'] === 'text') {
|
||||
$parts[] = ['text' => $block['text']];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($parts === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ['parts' => $parts];
|
||||
}
|
||||
}
|
||||
118
site/plugins/kirby-seo/classes/Ai/Drivers/OpenAi.php
Normal file
118
site/plugins/kirby-seo/classes/Ai/Drivers/OpenAi.php
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
<?php
|
||||
|
||||
namespace tobimori\Seo\Ai\Drivers;
|
||||
|
||||
use Generator;
|
||||
use tobimori\Seo\Ai\Chunk;
|
||||
use tobimori\Seo\Ai\Content;
|
||||
use tobimori\Seo\Ai\Driver;
|
||||
use tobimori\Seo\Ai\SseStream;
|
||||
|
||||
class OpenAi extends Driver
|
||||
{
|
||||
protected const string DEFAULT_ENDPOINT = 'https://api.openai.com/v1/responses';
|
||||
protected const string DEFAULT_MODEL = 'gpt-5-mini-2025-08-07';
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function stream(
|
||||
array $content,
|
||||
string|null $model = null,
|
||||
): Generator {
|
||||
$apiKey = $this->config('apiKey', required: true);
|
||||
$headers = [
|
||||
'Content-Type: application/json',
|
||||
'Accept: text/event-stream',
|
||||
"Authorization: Bearer {$apiKey}",
|
||||
];
|
||||
if ($organization = $this->config('organization')) {
|
||||
$headers[] = "OpenAI-Organization: {$organization}";
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'model' => $model ?? $this->config('model', static::DEFAULT_MODEL),
|
||||
'input' => $this->buildInput($content),
|
||||
// instructions does not work for e.g. openrouter so let's just put everything in user prompt
|
||||
'stream' => true,
|
||||
];
|
||||
|
||||
$stream = new SseStream($this->config('endpoint', static::DEFAULT_ENDPOINT), $headers, $payload, (int)$this->config('timeout', 120));
|
||||
yield from $stream->stream(function (array $event): Generator {
|
||||
$type = $event['type'] ?? null;
|
||||
|
||||
if ($type === 'response.created') {
|
||||
yield Chunk::streamStart($event);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($type === 'response.in_progress') {
|
||||
yield Chunk::textStart($event);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($type === 'response.output_text.delta') {
|
||||
$delta = $event['delta'] ?? '';
|
||||
if ($delta !== '') {
|
||||
yield Chunk::textDelta($delta, $event);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if ($type === 'response.output_text.done') {
|
||||
yield Chunk::textComplete($event);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($type === 'response.completed') {
|
||||
yield Chunk::streamEnd($event);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($type === 'response.output_item.added' && ($event['item']['type'] ?? null) === 'reasoning') {
|
||||
yield Chunk::thinkingStart($event);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($type === 'response.error') {
|
||||
$message = $event['error']['message'] ?? 'Unknown OpenAI streaming error.';
|
||||
yield Chunk::error($message, $event);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates an array of Content messages into the OpenAI Responses API input format.
|
||||
*
|
||||
* @param array<Content> $content
|
||||
*/
|
||||
private function buildInput(array $content): array
|
||||
{
|
||||
$input = [];
|
||||
|
||||
foreach ($content as $message) {
|
||||
$blocks = [];
|
||||
|
||||
foreach ($message->blocks() as $block) {
|
||||
if ($block['type'] === 'image') {
|
||||
$blocks[] = [
|
||||
'type' => 'input_image',
|
||||
'image_url' => "data:{$block['mediaType']};base64,{$block['data']}",
|
||||
];
|
||||
} elseif ($block['type'] === 'text') {
|
||||
$blocks[] = [
|
||||
'type' => 'input_text',
|
||||
'text' => $block['text'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$input[] = [
|
||||
'role' => $message->role(),
|
||||
'content' => $blocks,
|
||||
];
|
||||
}
|
||||
|
||||
return $input;
|
||||
}
|
||||
}
|
||||
194
site/plugins/kirby-seo/classes/Ai/SseStream.php
Normal file
194
site/plugins/kirby-seo/classes/Ai/SseStream.php
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
<?php
|
||||
|
||||
namespace tobimori\Seo\Ai;
|
||||
|
||||
use Generator;
|
||||
use Kirby\Exception\Exception as KirbyException;
|
||||
|
||||
use function curl_errno;
|
||||
use function curl_error;
|
||||
use function curl_getinfo;
|
||||
use function curl_init;
|
||||
use function curl_multi_add_handle;
|
||||
use function curl_multi_close;
|
||||
use function curl_multi_exec;
|
||||
use function curl_multi_init;
|
||||
use function curl_multi_remove_handle;
|
||||
use function curl_multi_select;
|
||||
use function curl_setopt_array;
|
||||
use function strlen;
|
||||
use function sprintf;
|
||||
use function is_array;
|
||||
|
||||
use const CURLOPT_HTTPHEADER;
|
||||
use const CURLOPT_POST;
|
||||
use const CURLOPT_POSTFIELDS;
|
||||
use const CURLOPT_RETURNTRANSFER;
|
||||
use const CURLOPT_TIMEOUT;
|
||||
use const CURLOPT_WRITEFUNCTION;
|
||||
use const CURLINFO_HTTP_CODE;
|
||||
use const CURLM_CALL_MULTI_PERFORM;
|
||||
|
||||
final class SseStream
|
||||
{
|
||||
private const int ERROR_CONTEXT_LIMIT = 8192;
|
||||
|
||||
public function __construct(
|
||||
private readonly string $endpoint,
|
||||
private readonly array $headers,
|
||||
private readonly array $payload,
|
||||
private readonly int $timeout = 120
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param callable(array $event): Generator<Chunk> $mapper
|
||||
* @return Generator<Chunk>
|
||||
*/
|
||||
public function stream(callable $mapper): Generator
|
||||
{
|
||||
$buffer = '';
|
||||
$response = '';
|
||||
$handle = curl_init($this->endpoint);
|
||||
|
||||
curl_setopt_array($handle, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_HTTPHEADER => $this->headers,
|
||||
CURLOPT_POSTFIELDS => json_encode(
|
||||
$this->payload,
|
||||
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
|
||||
),
|
||||
CURLOPT_RETURNTRANSFER => false,
|
||||
CURLOPT_TIMEOUT => $this->timeout,
|
||||
CURLOPT_WRITEFUNCTION => static function ($curl, $data) use (&$buffer, &$response) {
|
||||
$buffer .= $data;
|
||||
$currentLength = strlen($response);
|
||||
|
||||
if ($currentLength < self::ERROR_CONTEXT_LIMIT) {
|
||||
$response .= substr($data, 0, self::ERROR_CONTEXT_LIMIT - $currentLength);
|
||||
}
|
||||
|
||||
return strlen($data);
|
||||
},
|
||||
]);
|
||||
|
||||
$multi = curl_multi_init();
|
||||
curl_multi_add_handle($multi, $handle);
|
||||
|
||||
try {
|
||||
$running = null;
|
||||
do {
|
||||
$status = curl_multi_exec($multi, $running);
|
||||
|
||||
if ($status === CURLM_CALL_MULTI_PERFORM) {
|
||||
continue;
|
||||
}
|
||||
|
||||
yield from $this->drainBuffer($buffer, $mapper);
|
||||
|
||||
if ($running) {
|
||||
curl_multi_select($multi, 0.1);
|
||||
}
|
||||
} while ($running);
|
||||
|
||||
yield from $this->drainBuffer($buffer, $mapper, true);
|
||||
|
||||
$errno = curl_errno($handle);
|
||||
if ($errno) {
|
||||
throw new KirbyException(curl_error($handle) ?: 'Streaming request failed.', $errno);
|
||||
}
|
||||
|
||||
$code = curl_getinfo($handle, CURLINFO_HTTP_CODE);
|
||||
if ($code !== null && $code >= 400) {
|
||||
$message = sprintf('Streaming request failed (%d)', $code);
|
||||
$body = trim($response);
|
||||
|
||||
if ($body !== '') {
|
||||
$decoded = json_decode($body, true);
|
||||
|
||||
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
|
||||
$body = $decoded['error']['message'] ?? $decoded['message'] ?? $body;
|
||||
}
|
||||
|
||||
if (strlen($body) > 200) {
|
||||
$body = substr($body, 0, 200) . '...';
|
||||
}
|
||||
|
||||
$message .= ': ' . preg_replace('/\s+/', ' ', $body);
|
||||
}
|
||||
|
||||
throw new KirbyException($message);
|
||||
}
|
||||
} finally {
|
||||
curl_multi_remove_handle($multi, $handle);
|
||||
curl_multi_close($multi);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param callable(array $event): Generator<Chunk> $mapper
|
||||
* @return Generator<Chunk>
|
||||
*/
|
||||
private function drainBuffer(string &$buffer, callable $mapper, bool $final = false): Generator
|
||||
{
|
||||
while (
|
||||
preg_match('/\r?\n\r?\n/', $buffer, $match, PREG_OFFSET_CAPTURE) === 1
|
||||
) {
|
||||
$pos = $match[0][1];
|
||||
$len = strlen($match[0][0]);
|
||||
$frame = substr($buffer, 0, $pos);
|
||||
$buffer = substr($buffer, $pos + $len);
|
||||
|
||||
foreach ($this->mapFrame($frame, $mapper) as $chunk) {
|
||||
yield $chunk;
|
||||
}
|
||||
}
|
||||
|
||||
if ($final && trim($buffer) !== '') {
|
||||
foreach ($this->mapFrame($buffer, $mapper) as $chunk) {
|
||||
yield $chunk;
|
||||
}
|
||||
|
||||
$buffer = '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param callable(array $event): Generator<Chunk> $mapper
|
||||
* @return Generator<Chunk>
|
||||
*/
|
||||
private function mapFrame(string $frame, callable $mapper): Generator
|
||||
{
|
||||
$frame = trim($frame);
|
||||
|
||||
if ($frame === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$payload = '';
|
||||
|
||||
foreach (preg_split("/\r\n|\n|\r/", $frame) as $line) {
|
||||
$line = trim($line);
|
||||
|
||||
if ($line === '' || str_starts_with($line, ':')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (str_starts_with($line, 'data:')) {
|
||||
$payload .= substr($line, 5);
|
||||
}
|
||||
}
|
||||
|
||||
$payload = trim($payload);
|
||||
if ($payload === '' || $payload === '[DONE]') {
|
||||
return;
|
||||
}
|
||||
|
||||
$event = json_decode($payload, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE || !is_array($event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
yield from $mapper($event);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue