Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,6 @@ KIT_MATTERMOST_TEAM=dhoh8n1xkjfgzy7s63ufckb9ko
KIT_MATTERMOST_CHANNEL=3r1tnxhe5fgcmjxgrspm7316oc
KIT_MATTERMOST_DM=3r1tnxhe5fgcmjxgrspm7316oc
KIT_MATTERMOST_HALLWAY=9sp18hgiaiybtj9p5nm8uw3sna
KIT_IMAGINE_BASE=https://api.x.ai/v1
KIT_IMAGINE_MODEL=grok-imagine-image-2.0
KIT_IMAGINE_TIMEOUT=60
2 changes: 2 additions & 0 deletions app/Agent/KitAgent.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use App\Tools\CatalogRead;
use App\Tools\ChannelCreate;
use App\Tools\HeartbeatRead;
use App\Tools\ImagineStill;
use App\Tools\LookReport;
use App\Tools\MemorySearch;
use App\Tools\MemoryStore;
Expand Down Expand Up @@ -36,6 +37,7 @@ public function tools(): iterable
new MemorySearch,
new MemoryStore,
new AskLexi,
new ImagineStill,
];
}
}
90 changes: 90 additions & 0 deletions app/Imaging/ImagineClient.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<?php

namespace App\Imaging;

use Illuminate\Support\Facades\Http;
use RuntimeException;

class ImagineClient
{
/**
* @param list<string> $paths
*/
public function edit(string $prompt, array $paths, string $aspect, string $resolution, string $model): string
{
if ($paths === []) {
throw new RuntimeException('edit needs at least one image');
}

$images = [];
foreach (array_slice($paths, 0, 3) as $path) {
$bin = (string) file_get_contents($path);
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
$mime = $ext === 'png' ? 'image/png' : 'image/jpeg';
$images[] = ['url' => 'data:'.$mime.';base64,'.base64_encode($bin)];
}

return $this->post('/images/edits', [
'model' => $model,
'prompt' => $prompt,
'images' => $images,
'aspect_ratio' => $aspect,
'resolution' => $this->resolution($resolution),
'response_format' => 'b64_json',
]);
}

public function generate(string $prompt, string $aspect, string $resolution, string $model): string
{
return $this->post('/images/generations', [
'model' => $model,
'prompt' => $prompt,
'n' => 1,
'aspect_ratio' => $aspect,
'resolution' => $this->resolution($resolution),
'response_format' => 'b64_json',
]);
}

/**
* @param array<string, mixed> $body
*/
private function post(string $path, array $body): string
{
$key = (string) config('ai.providers.xai.key');
if ($key === '') {
throw new RuntimeException('XAI_API_KEY empty');
}

$base = rtrim((string) config('kit.imagine.base', 'https://api.x.ai/v1'), '/');
$timeout = (int) config('kit.imagine.timeout', 60);
$response = Http::withToken($key)
->timeout($timeout)
->acceptJson()
->asJson()
->post($base.$path, $body);

if (! $response->successful()) {
throw new RuntimeException('imagine '.$path.' '.$response->status().' '.$response->body());
}

$b64 = $response->json('data.0.b64_json');
if (! is_string($b64) || $b64 === '') {
throw new RuntimeException('imagine missing b64_json');
}

$bytes = base64_decode($b64, true);
if ($bytes === false || $bytes === '') {
throw new RuntimeException('imagine b64 decode failed');
}

return $bytes;
}

public function resolution(string $raw): string
{
$n = strtolower(trim($raw));

return in_array($n, ['1k', '2k'], true) ? $n : '1k';
}
}
159 changes: 159 additions & 0 deletions app/Imaging/RefStore.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
<?php

namespace App\Imaging;

use RuntimeException;

class RefStore
{
/** @var list<string> */
public const VIEWS = ['front', 'side', 'back', 'ride'];

/** @var list<string> */
public const PROTECTED = ['jordan-sheet.png', 'jordan-hero.jpg'];

/**
* @return array{canonical: string, extras: list<string>, aspect: string}|null
*/
public function map(string $view): ?array
{
return match ($view) {
'front' => ['canonical' => 'jordan-front-flops.jpg', 'extras' => ['jordan-sheet.png', 'jordan-hero.jpg'], 'aspect' => '3:4'],
'side' => ['canonical' => 'jordan-side-flops.jpg', 'extras' => ['jordan-sheet.png', 'jordan-hero.jpg'], 'aspect' => '3:4'],
'back' => ['canonical' => 'jordan-back-flops.jpg', 'extras' => ['jordan-sheet.png', 'jordan-hero.jpg'], 'aspect' => '3:4'],
'ride' => ['canonical' => 'jordan-ride-flops.jpg', 'extras' => ['jordan-sheet.png', 'jordan-hero.jpg'], 'aspect' => '16:9'],
default => null,
};
}

public function refsRoot(): string
{
return rtrim((string) config('kit.bikes_v2'), '/').'/tools/models/rider/refs';
}

public function wipDir(): string
{
return $this->refsRoot().'/_wip';
}

/**
* @param list<string> $extraNames
* @return list<string>
*/
public function resolve(string $view, array $extraNames = []): array
{
$map = $this->map($view);
if ($map === null) {
throw new RuntimeException('view must be front|side|back|ride');
}

$root = $this->refsRoot();
$names = [$map['canonical']];
$extras = $extraNames !== [] ? $extraNames : $map['extras'];
foreach ($extras as $name) {
$name = basename((string) $name);
if ($name === '' || $name === $map['canonical']) {
continue;
}
if (! $this->allowlisted($name)) {
throw new RuntimeException('ref not allowlisted: '.$name);
}
$names[] = $name;
if (count($names) >= 3) {
break;
}
}

$paths = [];
foreach ($names as $name) {
$path = $root.'/'.$name;
if (! is_file($path)) {
throw new RuntimeException('missing ref '.$name);
}
$paths[] = $path;
}

return $paths;
}

public function allowlisted(string $name): bool
{
$name = basename($name);

return (bool) preg_match('/^jordan-[a-z0-9-]+\.(jpg|jpeg|png)$/', $name);
}

/**
* @return array<string, string>
*/
public function protectHashes(): array
{
$hashes = [];
$roots = [
$this->refsRoot(),
rtrim((string) config('kit.bikes_v2'), '/').'/public/models/rider/refs',
];
foreach ($roots as $root) {
foreach (self::PROTECTED as $name) {
$path = $root.'/'.$name;
if (is_file($path)) {
$hashes[$path] = hash_file('sha256', $path) ?: '';
}
}
}

return $hashes;
}

/**
* @param array<string, string> $before
*/
public function hashesUnchanged(array $before): bool
{
$after = $this->protectHashes();
foreach ($before as $path => $hash) {
if (($after[$path] ?? '') !== $hash) {
return false;
}
}

return true;
}

/**
* @param array<string, mixed> $meta
* @return array{path: string, sha256: string, sidecar: string, bytes: int}
*/
public function writeWip(string $view, string $bytes, array $meta): array
{
if ($bytes === '') {
throw new RuntimeException('empty still');
}
$dir = $this->wipDir();
if (! is_dir($dir) && ! mkdir($dir, 0755, true) && ! is_dir($dir)) {
throw new RuntimeException('cannot create '.$dir);
}

$sha = hash('sha256', $bytes);
$name = now('America/Phoenix')->format('Ymd').'-'.$view.'-'.substr($sha, 0, 8).'.jpg';
$path = $dir.'/'.$name;
$realDir = realpath($dir);
if ($realDir === false || ! str_starts_with($path, $realDir)) {
throw new RuntimeException('wip path escaped');
}
if (file_put_contents($path, $bytes) === false) {
throw new RuntimeException('wip write failed');
}

$sidecar = substr($path, 0, -4).'.kit.json';
$payload = array_merge($meta, [
'sha256' => $sha,
'view' => $view,
'created' => now('America/Phoenix')->toIso8601String(),
'bytes' => strlen($bytes),
]);
file_put_contents($sidecar, json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n");

return ['path' => $path, 'sha256' => $sha, 'sidecar' => $sidecar, 'bytes' => strlen($bytes)];
}
}
Loading
Loading