Skip to content

Commit e80f215

Browse files
authored
Merge pull request #166 from tobimori/feat/ai
2 parents 511b09d + a966e11 commit e80f215

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+2498
-7795
lines changed

README.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
![Kirby SEO Banner](/.github/new-banner.png)
22

33
<h1 align="center">Kirby SEO</h1>
4-
<p align="center">All-in-one toolkit that makes implementing SEO & Meta best practices a breeze</p>
4+
<p align="center">
5+
The default choice for SEO on Kirby: Implement technical SEO & Meta best practices with ease and provide an easy-to-use editor experience
6+
</p>
57

68
---
79

@@ -23,6 +25,10 @@ If you're looking to use Kirby SEO with Kirby 5 or newer, please install the Alp
2325

2426
```composer require tobimori/kirby-seo:^2.0.0-alpha.6```
2527

28+
## Contributing
29+
30+
Kirby SEO is open to contributors: If you open a pull request that gets merged, such as fixing a bug or translating the plugin into a new language, you're eligible for a free SEO license of your choice. Please note that I might reject minor repeat contributions or simple fixes of typos for this. Please send an email to support after your contribution has been merged.
31+
2632
## License
2733

2834
Kirby SEO 2.0 is not free software. In order to run it on a public server, you'll have to purchase a valid Kirby license & a valid SEO license.
@@ -32,4 +38,4 @@ Copyright 2023-2025 © Tobias Möritz - Love & Kindness GmbH
3238

3339
---
3440

35-
Kirby SEO 1.0 is licensed under the MIT license.
41+
[Kirby SEO 1.0 is licensed under the MIT license.](./LICENSE.md)

blueprints/fields/meta-group.yml

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ fields:
66
numbered: false
77
metaTitle:
88
label: seo.fields.titleOverwrite.label
9-
type: text
9+
type: seo-writer
10+
ai: title
1011
placeholder: "{{ page.title }}"
1112
metaTemplate:
1213
extends: seo/fields/title-template
@@ -26,9 +27,7 @@ fields:
2627
metaDescription:
2728
label: seo.fields.metaDescription.label
2829
type: seo-writer
29-
inline: true
30-
marks: false
31-
nodes: false
30+
ai: description
3231
help: seo.fields.metaDescription.help
3332
placeholder: "{{ page.metadata.metaDescription }}"
3433
_seoLine1:

blueprints/fields/og-group.yml

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,7 @@ fields:
2323
ogDescription:
2424
label: seo.fields.ogDescription.label
2525
type: seo-writer
26-
inline: true
27-
marks: false
28-
nodes: false
26+
ai: og-description
2927
placeholder: "{{ page.metadata.ogDescription }}"
3028
ogImage:
3129
label: seo.fields.ogImage.label

blueprints/fields/title-template.yml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
type: seo-writer
2-
inline: true
3-
marks: false
42
nodes:
53
- seoTemplateTitle
64
- seoTemplateSiteTitle

blueprints/site.yml

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,7 @@ columns:
1616
metaDescription:
1717
label: seo.fields.metaDescription.label
1818
type: seo-writer
19-
inline: true
20-
marks: false
21-
nodes: false
19+
ai: site-description
2220
help: seo.fields.metaDescription.help
2321
_seoLine1:
2422
type: line
@@ -36,9 +34,7 @@ columns:
3634
ogDescription:
3735
label: seo.fields.ogDescription.label
3836
type: seo-writer
39-
inline: true
40-
marks: false
41-
nodes: false
37+
ai: site-og-description
4238
placeholder: "{{ site.metaDescription }}"
4339
ogSiteName:
4440
label: seo.fields.ogSiteName.label

classes/Ai.php

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<?php
2+
3+
namespace tobimori\Seo;
4+
5+
use Generator;
6+
use Kirby\Exception\Exception as KirbyException;
7+
use tobimori\Seo\Ai\Driver;
8+
9+
/**
10+
* Ai facade
11+
*/
12+
final class Ai
13+
{
14+
private static array $providers = [];
15+
16+
public static function enabled(): bool
17+
{
18+
return (bool)Seo::option('ai.enabled', false);
19+
}
20+
21+
/**
22+
* Returns a provider instance for the given ID or the default provider.
23+
*/
24+
public static function provider(string|null $providerId = null): Driver
25+
{
26+
$providerId ??= Seo::option('ai.provider');
27+
28+
if (isset(self::$providers[$providerId])) {
29+
return self::$providers[$providerId];
30+
}
31+
32+
$config = Seo::option("ai.providers.{$providerId}");
33+
if (!is_array($config)) {
34+
throw new KirbyException("AI provider \"{$providerId}\" is not defined.");
35+
}
36+
37+
$driver = $config['driver'] ?? null;
38+
if (!is_string($driver) || $driver === '') {
39+
throw new KirbyException("AI provider \"{$providerId}\" is missing a driver reference.");
40+
}
41+
42+
if (!is_subclass_of($driver, Driver::class)) {
43+
throw new KirbyException("AI provider driver \"{$driver}\" must extend " . Driver::class . '.');
44+
}
45+
46+
return self::$providers[$providerId] = new $driver($config['config'] ?? []);
47+
}
48+
49+
public static function streamTask(string $taskId, array $variables = []): Generator
50+
{
51+
$snippet = "seo/prompts/tasks/{$taskId}";
52+
$prompt = trim(snippet($snippet, $variables, return: true));
53+
if ($prompt === '') {
54+
throw new KirbyException("AI prompt snippet \"{$snippet}\" is missing or empty.");
55+
}
56+
57+
return self::provider()->stream($prompt, /* todo custom model here */);
58+
}
59+
}

classes/Ai/Chunk.php

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
<?php
2+
3+
namespace tobimori\Seo\Ai;
4+
5+
/**
6+
* Value object representing a streamed AI response chunk.
7+
*/
8+
final class Chunk
9+
{
10+
public const string TYPE_STREAM_START = 'stream-start';
11+
public const string TYPE_STREAM_END = 'stream-end';
12+
public const string TYPE_TEXT_START = 'text-start';
13+
public const string TYPE_TEXT_DELTA = 'text-delta';
14+
public const string TYPE_TEXT_COMPLETE = 'text-complete';
15+
public const string TYPE_THINKING_START = 'thinking-start';
16+
public const string TYPE_THINKING_DELTA = 'thinking-delta';
17+
public const string TYPE_THINKING_COMPLETE = 'thinking-complete';
18+
public const string TYPE_TOOL_CALL = 'tool-call';
19+
public const string TYPE_TOOL_RESULT = 'tool-result';
20+
public const string TYPE_ERROR = 'error';
21+
22+
private function __construct(
23+
public readonly string $type,
24+
public readonly mixed $payload = null,
25+
public readonly ?string $text = null
26+
) {
27+
}
28+
29+
public static function streamStart(array $payload = []): self
30+
{
31+
return new self(self::TYPE_STREAM_START, $payload);
32+
}
33+
34+
public static function streamEnd(array $payload = []): self
35+
{
36+
return new self(self::TYPE_STREAM_END, $payload);
37+
}
38+
39+
public static function textStart(array $payload = []): self
40+
{
41+
return new self(self::TYPE_TEXT_START, $payload);
42+
}
43+
44+
public static function textDelta(string $text, array $payload = []): self
45+
{
46+
return new self(self::TYPE_TEXT_DELTA, $payload, $text);
47+
}
48+
49+
public static function textComplete(array $payload = []): self
50+
{
51+
return new self(self::TYPE_TEXT_COMPLETE, $payload);
52+
}
53+
54+
public static function thinkingStart(array $payload = []): self
55+
{
56+
return new self(self::TYPE_THINKING_START, $payload);
57+
}
58+
59+
public static function thinkingDelta(string $text, array $payload = []): self
60+
{
61+
return new self(self::TYPE_THINKING_DELTA, $payload, $text);
62+
}
63+
64+
public static function thinkingComplete(array $payload = []): self
65+
{
66+
return new self(self::TYPE_THINKING_COMPLETE, $payload);
67+
}
68+
69+
public static function toolCall(array $payload = []): self
70+
{
71+
return new self(self::TYPE_TOOL_CALL, $payload);
72+
}
73+
74+
public static function toolResult(array $payload = []): self
75+
{
76+
return new self(self::TYPE_TOOL_RESULT, $payload);
77+
}
78+
79+
public static function error(string $message, array $payload = []): self
80+
{
81+
return new self(self::TYPE_ERROR, [
82+
'message' => $message,
83+
'data' => $payload,
84+
]);
85+
}
86+
87+
public function isStreamStart(): bool
88+
{
89+
return $this->type === self::TYPE_STREAM_START;
90+
}
91+
92+
public function isStreamEnd(): bool
93+
{
94+
return $this->type === self::TYPE_STREAM_END;
95+
}
96+
97+
public function isTextStart(): bool
98+
{
99+
return $this->type === self::TYPE_TEXT_START;
100+
}
101+
102+
public function isTextDelta(): bool
103+
{
104+
return $this->type === self::TYPE_TEXT_DELTA;
105+
}
106+
107+
public function isTextComplete(): bool
108+
{
109+
return $this->type === self::TYPE_TEXT_COMPLETE;
110+
}
111+
112+
public function isThinkingStart(): bool
113+
{
114+
return $this->type === self::TYPE_THINKING_START;
115+
}
116+
117+
public function isThinkingDelta(): bool
118+
{
119+
return $this->type === self::TYPE_THINKING_DELTA;
120+
}
121+
122+
public function isThinkingComplete(): bool
123+
{
124+
return $this->type === self::TYPE_THINKING_COMPLETE;
125+
}
126+
127+
public function isToolCall(): bool
128+
{
129+
return $this->type === self::TYPE_TOOL_CALL;
130+
}
131+
132+
public function isToolResult(): bool
133+
{
134+
return $this->type === self::TYPE_TOOL_RESULT;
135+
}
136+
137+
public function isError(): bool
138+
{
139+
return $this->type === self::TYPE_ERROR;
140+
}
141+
}

classes/Ai/Driver.php

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
3+
namespace tobimori\Seo\Ai;
4+
5+
use Generator;
6+
use Kirby\Exception\InvalidArgumentException;
7+
8+
abstract class Driver
9+
{
10+
public function __construct(protected array $config = [])
11+
{
12+
}
13+
14+
/**
15+
* Streams a response for the given prompt and optional context data.
16+
*
17+
* @param string $prompt User prompt (e.g. Tasks).
18+
* @param string|null $model Model to use.
19+
*
20+
* @return Generator<int, Chunk, mixed, void>
21+
*/
22+
abstract public function stream(string $prompt, string|null $model = null): Generator;
23+
24+
/**
25+
* Returns a configuration value or throws when required.
26+
*/
27+
protected function config(string $key, mixed $default = null, bool $required = false): mixed
28+
{
29+
$value = $this->config[$key] ?? $default;
30+
31+
if ($required === true && ($value === null || $value === '')) {
32+
throw new InvalidArgumentException(
33+
"Missing required \"{$key}\" configuration for driver " . static::class . '.'
34+
);
35+
}
36+
37+
return $value;
38+
}
39+
}

0 commit comments

Comments
 (0)