init project

This commit is contained in:
2026-05-10 13:21:24 +03:30
parent 5f210c6c73
commit dc36701c9d
26 changed files with 2149 additions and 69 deletions

View File

@@ -3,13 +3,13 @@
])
@if($sidebar)
<flux:sidebar.brand name="Laravel Starter Kit" {{ $attributes }}>
<flux:sidebar.brand :name="config('app.name')" {{ $attributes }}>
<x-slot name="logo" class="flex aspect-square size-8 items-center justify-center rounded-md bg-accent-content text-accent-foreground">
<x-app-logo-icon class="size-5 fill-current text-white dark:text-black" />
</x-slot>
</flux:sidebar.brand>
@else
<flux:brand name="Laravel Starter Kit" {{ $attributes }}>
<flux:brand :name="config('app.name')" {{ $attributes }}>
<x-slot name="logo" class="flex aspect-square size-8 items-center justify-center rounded-md bg-accent-content text-accent-foreground">
<x-app-logo-icon class="size-5 fill-current text-white dark:text-black" />
</x-slot>

View File

@@ -1,9 +1,8 @@
<flux:dropdown position="bottom" align="start">
<flux:sidebar.profile
:name="auth()->user()->name"
<flux:dropdown position="bottom" align="end" {{ $attributes }}>
<flux:profile
:initials="auth()->user()->initials()"
icon:trailing="chevrons-up-down"
data-test="sidebar-menu-button"
icon-trailing="chevron-down"
data-test="user-menu-button"
/>
<flux:menu>

View File

@@ -1,18 +1,146 @@
<x-layouts::app :title="__('Dashboard')">
<div class="flex h-full w-full flex-1 flex-col gap-4 rounded-xl">
<div class="grid auto-rows-min gap-4 md:grid-cols-3">
<div class="relative aspect-video overflow-hidden rounded-xl border border-neutral-200 dark:border-neutral-700">
<x-placeholder-pattern class="absolute inset-0 size-full stroke-gray-900/20 dark:stroke-neutral-100/20" />
</div>
<div class="relative aspect-video overflow-hidden rounded-xl border border-neutral-200 dark:border-neutral-700">
<x-placeholder-pattern class="absolute inset-0 size-full stroke-gray-900/20 dark:stroke-neutral-100/20" />
</div>
<div class="relative aspect-video overflow-hidden rounded-xl border border-neutral-200 dark:border-neutral-700">
<x-placeholder-pattern class="absolute inset-0 size-full stroke-gray-900/20 dark:stroke-neutral-100/20" />
@php
$stats = [
['label' => __('Actions'), 'value' => number_format($usage['totals']['actions']), 'meta' => __('Last :days days', ['days' => $usage['days']]), 'color' => 'bg-emerald-500'],
['label' => __('Phrases'), 'value' => number_format($usage['totals']['inputs']), 'meta' => __('Submitted inputs'), 'color' => 'bg-sky-500'],
['label' => __('Results'), 'value' => number_format($usage['totals']['results']), 'meta' => __('Returned rows'), 'color' => 'bg-amber-500'],
['label' => __('Models'), 'value' => number_format($usage['totals']['models']), 'meta' => __('Used locally'), 'color' => 'bg-zinc-500'],
];
@endphp
<section class="flex w-full flex-col gap-6">
<div class="flex flex-col justify-between gap-4 sm:flex-row sm:items-end">
<div>
<flux:heading size="xl">{{ __('Usage overview') }}</flux:heading>
<flux:text>{{ __('Last :days days', ['days' => $usage['days']]) }}</flux:text>
</div>
<flux:button icon="sparkles" variant="primary" :href="route('embeddings.embed')" wire:navigate>
{{ __('New embedding') }}
</flux:button>
</div>
<div class="relative h-full flex-1 overflow-hidden rounded-xl border border-neutral-200 dark:border-neutral-700">
<x-placeholder-pattern class="absolute inset-0 size-full stroke-gray-900/20 dark:stroke-neutral-100/20" />
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
@foreach ($stats as $stat)
<section class="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900">
<div class="flex items-start justify-between gap-3">
<div>
<flux:text class="text-sm">{{ $stat['label'] }}</flux:text>
<div class="mt-2 text-3xl font-semibold tabular-nums text-zinc-950 dark:text-zinc-50">{{ $stat['value'] }}</div>
</div>
<span class="mt-1 size-2.5 rounded-full {{ $stat['color'] }}"></span>
</div>
<div class="mt-3 text-xs text-zinc-500 dark:text-zinc-400">{{ $stat['meta'] }}</div>
</section>
@endforeach
</div>
</div>
<div class="grid gap-4 xl:grid-cols-[minmax(0,2fr)_minmax(280px,1fr)]">
<section class="rounded-lg border border-zinc-200 bg-white p-5 dark:border-zinc-700 dark:bg-zinc-900">
<div class="flex items-center justify-between gap-3">
<flux:heading>{{ __('Activity') }}</flux:heading>
<flux:badge>{{ __('Actions') }}</flux:badge>
</div>
<div class="mt-5 flex h-56 items-end gap-2">
@foreach ($usage['daily'] as $day)
<div class="flex min-w-0 flex-1 flex-col items-center gap-2" title="{{ $day['label'] }}: {{ $day['actions'] }} {{ __('actions') }}, {{ $day['inputs'] }} {{ __('inputs') }}">
<div class="flex h-44 w-full items-end rounded-md bg-zinc-100 px-1 dark:bg-zinc-800">
<div
class="w-full rounded-t-md bg-emerald-500 transition-[height] dark:bg-emerald-400"
style="height: {{ $day['height'] }}%"
></div>
</div>
<div class="w-full truncate text-center text-[11px] text-zinc-500 dark:text-zinc-400">{{ $day['label'] }}</div>
</div>
@endforeach
</div>
</section>
<section class="rounded-lg border border-zinc-200 bg-white p-5 dark:border-zinc-700 dark:bg-zinc-900">
<flux:heading>{{ __('Tool mix') }}</flux:heading>
<div class="mt-5 flex flex-col gap-4">
@forelse ($usage['tools'] as $tool)
<div>
<div class="flex items-center justify-between gap-3 text-sm">
<span class="font-medium text-zinc-900 dark:text-zinc-100">{{ $tool['label'] }}</span>
<span class="tabular-nums text-zinc-500 dark:text-zinc-400">{{ number_format($tool['actions']) }}</span>
</div>
<div class="mt-2 h-2 rounded-full bg-zinc-100 dark:bg-zinc-800">
<div class="h-2 rounded-full bg-sky-500 dark:bg-sky-400" style="width: {{ $tool['percentage'] }}%"></div>
</div>
<div class="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
{{ __(':inputs inputs, :results results', ['inputs' => number_format($tool['inputs']), 'results' => number_format($tool['results'])]) }}
</div>
</div>
@empty
<div class="rounded-lg border border-dashed border-zinc-200 p-4 text-sm text-zinc-500 dark:border-zinc-700 dark:text-zinc-400">
{{ __('No usage yet.') }}
</div>
@endforelse
</div>
</section>
</div>
<div class="grid gap-4 xl:grid-cols-2">
<section class="rounded-lg border border-zinc-200 bg-white p-5 dark:border-zinc-700 dark:bg-zinc-900">
<flux:heading>{{ __('Models') }}</flux:heading>
<div class="mt-5 flex flex-col gap-4">
@forelse ($usage['models'] as $model)
<div>
<div class="flex items-center justify-between gap-3 text-sm">
<span class="truncate font-medium text-zinc-900 dark:text-zinc-100" title="{{ $model['label'] }}">{{ $model['label'] }}</span>
<span class="tabular-nums text-zinc-500 dark:text-zinc-400">{{ number_format($model['actions']) }}</span>
</div>
<div class="mt-2 h-2 rounded-full bg-zinc-100 dark:bg-zinc-800">
<div class="h-2 rounded-full bg-amber-500 dark:bg-amber-400" style="width: {{ $model['percentage'] }}%"></div>
</div>
<div class="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
{{ __(':inputs inputs', ['inputs' => number_format($model['inputs'])]) }}
</div>
</div>
@empty
<div class="rounded-lg border border-dashed border-zinc-200 p-4 text-sm text-zinc-500 dark:border-zinc-700 dark:text-zinc-400">
{{ __('No model usage yet.') }}
</div>
@endforelse
</div>
</section>
<section class="rounded-lg border border-zinc-200 bg-white p-5 dark:border-zinc-700 dark:bg-zinc-900">
<flux:heading>{{ __('Recent activity') }}</flux:heading>
<div class="mt-5 overflow-x-auto">
<table class="min-w-full divide-y divide-zinc-200 text-sm dark:divide-zinc-700">
<thead class="text-zinc-500 dark:text-zinc-400">
<tr>
<th class="py-2 text-start font-medium">{{ __('Tool') }}</th>
<th class="py-2 text-start font-medium">{{ __('Model') }}</th>
<th class="py-2 text-end font-medium">{{ __('Inputs') }}</th>
<th class="py-2 text-end font-medium">{{ __('Results') }}</th>
<th class="py-2 text-end font-medium">{{ __('When') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-200 dark:divide-zinc-700">
@forelse ($usage['recent'] as $event)
<tr>
<td class="py-3 font-medium text-zinc-900 dark:text-zinc-100">{{ $event['tool_label'] }}</td>
<td class="max-w-48 truncate py-3 text-zinc-600 dark:text-zinc-300" title="{{ $event['model'] }}">{{ $event['model'] }}</td>
<td class="py-3 text-end tabular-nums text-zinc-600 dark:text-zinc-300">{{ number_format($event['inputs']) }}</td>
<td class="py-3 text-end tabular-nums text-zinc-600 dark:text-zinc-300">{{ number_format($event['results']) }}</td>
<td class="py-3 text-end text-zinc-500 dark:text-zinc-400">{{ $event['created_at'] }}</td>
</tr>
@empty
<tr>
<td colspan="5" class="py-6 text-center text-sm text-zinc-500 dark:text-zinc-400">{{ __('No recent activity.') }}</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</section>
</div>
</section>
</x-layouts::app>

View File

@@ -1,5 +1,5 @@
<x-layouts::app.sidebar :title="$title ?? null">
<x-layouts::app.header :title="$title ?? null">
<flux:main>
{{ $slot }}
</flux:main>
</x-layouts::app.sidebar>
</x-layouts::app.header>

View File

@@ -13,34 +13,22 @@
<flux:navbar.item icon="layout-grid" :href="route('dashboard')" :current="request()->routeIs('dashboard')" wire:navigate>
{{ __('Dashboard') }}
</flux:navbar.item>
<flux:navbar.item icon="sparkles" :href="route('embeddings.embed')" :current="request()->routeIs('embeddings.embed')" wire:navigate>
{{ __('Embed') }}
</flux:navbar.item>
<flux:navbar.item icon="magnifying-glass" :href="route('embeddings.search')" :current="request()->routeIs('embeddings.search')" wire:navigate>
{{ __('Search') }}
</flux:navbar.item>
<flux:navbar.item icon="arrows-right-left" :href="route('embeddings.compare')" :current="request()->routeIs('embeddings.compare')" wire:navigate>
{{ __('Compare') }}
</flux:navbar.item>
</flux:navbar>
<flux:spacer />
<flux:navbar class="me-1.5 space-x-0.5 rtl:space-x-reverse py-0!">
<flux:tooltip :content="__('Search')" position="bottom">
<flux:navbar.item class="!h-10 [&>div>svg]:size-5" icon="magnifying-glass" href="#" :label="__('Search')" />
</flux:tooltip>
<flux:tooltip :content="__('Repository')" position="bottom">
<flux:navbar.item
class="h-10 max-lg:hidden [&>div>svg]:size-5"
icon="folder-git-2"
href="https://github.com/laravel/livewire-starter-kit"
target="_blank"
:label="__('Repository')"
/>
</flux:tooltip>
<flux:tooltip :content="__('Documentation')" position="bottom">
<flux:navbar.item
class="h-10 max-lg:hidden [&>div>svg]:size-5"
icon="book-open-text"
href="https://laravel.com/docs/starter-kits#livewire"
target="_blank"
:label="__('Documentation')"
/>
</flux:tooltip>
</flux:navbar>
<x-desktop-user-menu />
</flux:header>
@@ -56,19 +44,21 @@
<flux:sidebar.item icon="layout-grid" :href="route('dashboard')" :current="request()->routeIs('dashboard')" wire:navigate>
{{ __('Dashboard') }}
</flux:sidebar.item>
<flux:sidebar.item icon="sparkles" :href="route('embeddings.embed')" :current="request()->routeIs('embeddings.embed')" wire:navigate>
{{ __('Embed') }}
</flux:sidebar.item>
<flux:sidebar.item icon="magnifying-glass" :href="route('embeddings.search')" :current="request()->routeIs('embeddings.search')" wire:navigate>
{{ __('Search') }}
</flux:sidebar.item>
<flux:sidebar.item icon="arrows-right-left" :href="route('embeddings.compare')" :current="request()->routeIs('embeddings.compare')" wire:navigate>
{{ __('Compare') }}
</flux:sidebar.item>
</flux:sidebar.group>
</flux:sidebar.nav>
<flux:spacer />
<flux:sidebar.nav>
<flux:sidebar.item icon="folder-git-2" href="https://github.com/laravel/livewire-starter-kit" target="_blank">
{{ __('Repository') }}
</flux:sidebar.item>
<flux:sidebar.item icon="book-open-text" href="https://laravel.com/docs/starter-kits#livewire" target="_blank">
{{ __('Documentation') }}
</flux:sidebar.item>
</flux:sidebar.nav>
</flux:sidebar>
{{ $slot }}

View File

@@ -15,21 +15,15 @@
<flux:sidebar.item icon="home" :href="route('dashboard')" :current="request()->routeIs('dashboard')" wire:navigate>
{{ __('Dashboard') }}
</flux:sidebar.item>
<flux:sidebar.item icon="circle-stack" :href="route('embeddings.embed')" :current="request()->routeIs('embeddings.*')" wire:navigate>
{{ __('Embeddings') }}
</flux:sidebar.item>
</flux:sidebar.group>
</flux:sidebar.nav>
<flux:spacer />
<flux:sidebar.nav>
<flux:sidebar.item icon="folder-git-2" href="https://github.com/laravel/livewire-starter-kit" target="_blank">
{{ __('Repository') }}
</flux:sidebar.item>
<flux:sidebar.item icon="book-open-text" href="https://laravel.com/docs/starter-kits#livewire" target="_blank">
{{ __('Documentation') }}
</flux:sidebar.item>
</flux:sidebar.nav>
<x-desktop-user-menu class="hidden lg:block" :name="auth()->user()->name" />
</flux:sidebar>

View File

@@ -0,0 +1,31 @@
<header class="flex flex-col gap-4">
<div class="flex flex-col justify-between gap-3 sm:flex-row sm:items-end">
<div>
<flux:heading>{{ $heading }}</flux:heading>
</div>
<div class="flex flex-wrap gap-2">
<flux:badge icon="cpu-chip">{{ $providerSummary }}</flux:badge>
<flux:badge icon="circle-stack">{{ __(':count stored', ['count' => $storedCount]) }}</flux:badge>
</div>
</div>
<div class="flex justify-end">
<div class="w-full max-w-sm">
<flux:select wire:model="embeddingModel" :label="__('Embedding model')">
@foreach ($modelOptions as $option)
<flux:select.option value="{{ $option['name'] }}">
{{ $option['name'] }} ({{ $option['dimensions'] }}d)
</flux:select.option>
@endforeach
</flux:select>
<flux:error name="embeddingModel" />
</div>
</div>
@if (! $databaseReady)
<div class="rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-700 dark:bg-amber-950 dark:text-amber-100">
{{ __('Embedding storage is waiting for the pgvector migration.') }}
</div>
@endif
</header>

View File

@@ -0,0 +1,333 @@
<?php
use App\Actions\Embeddings\RecordEmbeddingUsageEvent;
use App\Models\EmbeddingEntry;
use App\Services\EmbeddingVector;
use App\Services\EmbeddingWorkbench;
use Flux\Flux;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Schema;
use Livewire\Attributes\Title;
use Livewire\Component;
new #[Title('Embedding comparison')] class extends Component
{
/** @var array<int, string> */
public array $comparisonPhrases = ['', '', ''];
public string $embeddingModel = '';
/** @var array<int, array{name: string, dimensions: int, default: bool}> */
public array $modelOptions = [];
/** @var array<int, array<string, mixed>> */
public array $comparisonEntries = [];
/** @var array<int, array<string, mixed>> */
public array $comparisonMatrix = [];
/** @var array<int, array{first: string, second: string, similarity: float, distance: float}> */
public array $comparisonPairs = [];
/** @var array<string, mixed>|null */
public ?array $closestPair = null;
public string $providerSummary = '';
public int $storedCount = 0;
public bool $databaseReady = true;
public function mount(EmbeddingWorkbench $workbench): void
{
$this->initializeWorkbench($workbench);
}
public function comparePhrases(EmbeddingWorkbench $workbench, RecordEmbeddingUsageEvent $recordUsageEvent): void
{
$this->validate([
'comparisonPhrases' => ['required', 'array', 'min:2', 'max:12'],
'comparisonPhrases.*' => ['nullable', 'string', 'max:1000'],
'embeddingModel' => ['required', 'string'],
]);
$phrases = $this->comparisonPhraseValues();
if (count($phrases) < 2) {
$this->addError('comparisonPhrases', __('Enter at least two unique phrases.'));
return;
}
if (! $this->databaseReady) {
$this->addError('comparisonPhrases', __('The embedding table is not ready.'));
return;
}
try {
$comparison = $workbench->compare($phrases, $this->embeddingModel);
$this->comparisonEntries = $comparison['entries']
->map(fn (EmbeddingEntry $entry): array => $workbench->presentEntry($entry))
->all();
$this->comparisonMatrix = collect($comparison['matrix'])
->map(fn (array $row): array => [
'source_text' => $row['entry']->source_text,
'scores' => $row['scores'],
])
->all();
$this->comparisonPairs = $this->comparisonPairsFromMatrix($comparison['entries'], $comparison['matrix']);
$this->closestPair = $comparison['closest_pair'] === null ? null : [
'first' => $comparison['closest_pair']['first']->source_text,
'second' => $comparison['closest_pair']['second']->source_text,
'similarity' => $comparison['closest_pair']['similarity'],
];
$recordUsageEvent->handle(
user: auth()->user(),
workbench: $workbench,
selectedModel: $this->embeddingModel,
tool: 'compare',
inputCount: count($phrases),
resultCount: count($this->comparisonPairs),
);
$this->refreshSummary($workbench);
Flux::toast(variant: 'success', text: __('Comparison ready.'));
} catch (\Throwable $exception) {
$this->addError('embeddingModel', $exception->getMessage());
}
}
public function addComparisonPhrase(): void
{
if (count($this->comparisonPhrases) >= 12) {
return;
}
$this->comparisonPhrases[] = '';
$this->resetComparisonResults();
}
public function removeComparisonPhrase(int $index): void
{
if (count($this->comparisonPhrases) <= 2 || ! array_key_exists($index, $this->comparisonPhrases)) {
return;
}
unset($this->comparisonPhrases[$index]);
$this->comparisonPhrases = array_values($this->comparisonPhrases);
$this->resetComparisonResults();
}
protected function initializeWorkbench(EmbeddingWorkbench $workbench): void
{
$this->modelOptions = $workbench->modelOptions();
$this->embeddingModel = $this->embeddingModel ?: $workbench->modelName();
$this->refreshSummary($workbench);
}
protected function refreshSummary(EmbeddingWorkbench $workbench): void
{
$provider = $workbench->providerName();
$model = $workbench->modelName($this->embeddingModel);
$dimensions = $workbench->dimensions($model);
$this->providerSummary = "{$provider} / {$model} / {$dimensions}d";
$this->databaseReady = Schema::hasTable('embedding_entries');
if (! $this->databaseReady) {
$this->storedCount = 0;
return;
}
$this->storedCount = EmbeddingEntry::query()
->where('provider', $provider)
->where('model', $model)
->where('embedding_dimensions', $dimensions)
->count();
}
/**
* @return list<string>
*/
protected function comparisonPhraseValues(): array
{
return EmbeddingVector::phrasesFromText(implode("\n", $this->comparisonPhrases), 12);
}
/**
* @param Collection<int, EmbeddingEntry> $entries
* @param array<int, array{entry: EmbeddingEntry, scores: array<int, float>}> $matrix
* @return array<int, array{first: string, second: string, similarity: float, distance: float}>
*/
protected function comparisonPairsFromMatrix(Collection $entries, array $matrix): array
{
$pairs = [];
foreach ($matrix as $rowIndex => $row) {
foreach ($row['scores'] as $columnIndex => $similarity) {
if ($rowIndex >= $columnIndex) {
continue;
}
$firstEntry = $entries->get($rowIndex);
$secondEntry = $entries->get($columnIndex);
if (! $firstEntry instanceof EmbeddingEntry || ! $secondEntry instanceof EmbeddingEntry) {
continue;
}
$pairs[] = [
'first' => $firstEntry->source_text,
'second' => $secondEntry->source_text,
'similarity' => $similarity,
'distance' => round(1 - $similarity, 6),
];
}
}
return collect($pairs)
->sortByDesc('similarity')
->values()
->all();
}
protected function resetComparisonResults(): void
{
$this->comparisonEntries = [];
$this->comparisonMatrix = [];
$this->comparisonPairs = [];
$this->closestPair = null;
}
};
?>
<section class="flex w-full flex-col gap-6">
@include('pages.embeddings.partials.navigation', ['heading' => __('Proximity comparison')])
<section class="rounded-lg border border-zinc-200 bg-white p-5 dark:border-zinc-700 dark:bg-zinc-900">
<form wire:submit="comparePhrases" class="flex flex-col gap-4">
<div class="flex flex-col justify-between gap-3 sm:flex-row sm:items-center">
<div>
@if ($closestPair !== null)
<div class="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm dark:border-emerald-800 dark:bg-emerald-950">
<div class="font-medium text-emerald-900 dark:text-emerald-100">{{ __('Closest pair') }}</div>
<div class="mt-1 flex flex-wrap items-center gap-2 text-emerald-800 dark:text-emerald-200">
<span class="max-w-80 truncate" title="{{ $closestPair['first'] }}">{{ $closestPair['first'] }}</span>
<span class="text-emerald-600 dark:text-emerald-400">{{ __('vs') }}</span>
<span class="max-w-80 truncate" title="{{ $closestPair['second'] }}">{{ $closestPair['second'] }}</span>
<flux:badge color="emerald">{{ number_format($closestPair['similarity'], 3) }}</flux:badge>
</div>
</div>
@endif
</div>
<flux:button type="submit" variant="primary" icon="arrows-right-left" :disabled="! $databaseReady" class="data-loading:opacity-60">
<span wire:loading.remove wire:target="comparePhrases">{{ __('Compare') }}</span>
<span wire:loading wire:target="comparePhrases">{{ __('Comparing') }}</span>
</flux:button>
</div>
<div class="flex flex-col gap-3">
<div class="flex items-center justify-between gap-3">
<flux:label>{{ __('Phrases') }}</flux:label>
<flux:button type="button" variant="filled" icon="plus" wire:click="addComparisonPhrase" :disabled="count($comparisonPhrases) >= 12">
{{ __('Add phrase') }}
</flux:button>
</div>
<flux:error name="comparisonPhrases" />
<div class="grid gap-3">
@foreach ($comparisonPhrases as $index => $phrase)
<div wire:key="comparison-phrase-{{ $index }}" class="grid gap-2 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-end">
<flux:field>
<flux:label>{{ __('Phrase :number', ['number' => $index + 1]) }}</flux:label>
<flux:input wire:model="comparisonPhrases.{{ $index }}" placeholder="{{ __('PostgreSQL vectors') }}" />
<flux:error name="comparisonPhrases.{{ $index }}" />
</flux:field>
<flux:button
type="button"
variant="ghost"
icon="trash"
aria-label="{{ __('Remove phrase') }}"
wire:click="removeComparisonPhrase({{ $index }})"
:disabled="count($comparisonPhrases) <= 2"
/>
</div>
@endforeach
</div>
</div>
@if ($comparisonEntries !== [])
<div class="overflow-x-auto rounded-lg border border-zinc-200 dark:border-zinc-700">
<table class="min-w-full divide-y divide-zinc-200 text-sm dark:divide-zinc-700">
<thead class="bg-zinc-50 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">
<tr>
<th class="px-3 py-2 text-start font-medium">{{ __('First phrase') }}</th>
<th class="px-3 py-2 text-start font-medium">{{ __('Second phrase') }}</th>
<th class="px-3 py-2 text-end font-medium">{{ __('Similarity') }}</th>
<th class="px-3 py-2 text-end font-medium">{{ __('Distance') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-200 dark:divide-zinc-700">
@foreach ($comparisonPairs as $pair)
<tr wire:key="comparison-pair-{{ $loop->index }}">
<td class="max-w-72 truncate px-3 py-3 font-medium text-zinc-900 dark:text-zinc-100" title="{{ $pair['first'] }}">
{{ $pair['first'] }}
</td>
<td class="max-w-72 truncate px-3 py-3 text-zinc-700 dark:text-zinc-200" title="{{ $pair['second'] }}">
{{ $pair['second'] }}
</td>
<td class="px-3 py-3 text-end tabular-nums {{ $pair['similarity'] >= 0.8 ? 'text-emerald-700 dark:text-emerald-300' : ($pair['similarity'] >= 0.5 ? 'text-amber-700 dark:text-amber-300' : 'text-zinc-600 dark:text-zinc-300') }}">
{{ number_format($pair['similarity'], 3) }}
</td>
<td class="px-3 py-3 text-end tabular-nums text-zinc-600 dark:text-zinc-300">
{{ number_format($pair['distance'], 3) }}
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<div class="overflow-x-auto rounded-lg border border-zinc-200 dark:border-zinc-700">
<table class="min-w-max divide-y divide-zinc-200 text-sm dark:divide-zinc-700">
<thead class="bg-zinc-50 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">
<tr>
<th class="sticky left-0 bg-zinc-50 px-3 py-2 text-start font-medium dark:bg-zinc-800">{{ __('Compared phrase') }}</th>
@foreach ($comparisonEntries as $entry)
<th class="max-w-48 truncate px-3 py-2 text-center font-medium" title="{{ $entry['source_text'] }}">{{ $entry['source_text'] }}</th>
@endforeach
</tr>
</thead>
<tbody class="divide-y divide-zinc-200 dark:divide-zinc-700">
@foreach ($comparisonMatrix as $row)
<tr wire:key="comparison-row-{{ $loop->index }}">
<th class="sticky left-0 max-w-64 truncate bg-white px-3 py-3 text-start font-medium text-zinc-900 dark:bg-zinc-900 dark:text-zinc-100">
#{{ $loop->iteration }} {{ $row['source_text'] }}
</th>
@foreach ($row['scores'] as $score)
<td class="px-3 py-3 text-center tabular-nums {{ $score >= 0.8 ? 'bg-emerald-50 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200' : ($score >= 0.5 ? 'bg-amber-50 text-amber-800 dark:bg-amber-950 dark:text-amber-200' : 'text-zinc-600 dark:text-zinc-300') }}">
{{ number_format($score, 3) }}
</td>
@endforeach
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</form>
</section>
</section>

View File

@@ -0,0 +1,168 @@
<?php
use App\Actions\Embeddings\RecordEmbeddingUsageEvent;
use App\Models\EmbeddingEntry;
use App\Services\EmbeddingVector;
use App\Services\EmbeddingWorkbench;
use Flux\Flux;
use Illuminate\Support\Facades\Schema;
use Livewire\Attributes\Title;
use Livewire\Component;
new #[Title('Embed')] class extends Component
{
public string $embeddingInput = '';
public string $embeddingModel = '';
/** @var array<int, array{name: string, dimensions: int, default: bool}> */
public array $modelOptions = [];
/** @var array<int, array<string, mixed>> */
public array $embeddingResults = [];
public string $providerSummary = '';
public int $storedCount = 0;
public bool $databaseReady = true;
public function mount(EmbeddingWorkbench $workbench): void
{
$this->initializeWorkbench($workbench);
}
public function generateEmbeddings(EmbeddingWorkbench $workbench, RecordEmbeddingUsageEvent $recordUsageEvent): void
{
$this->validate([
'embeddingInput' => ['required', 'string', 'max:12000'],
'embeddingModel' => ['required', 'string'],
]);
$phrases = EmbeddingVector::phrasesFromText($this->embeddingInput);
if ($phrases === []) {
$this->addError('embeddingInput', __('Enter at least one phrase.'));
return;
}
if (! $this->databaseReady) {
$this->addError('embeddingInput', __('The embedding table is not ready.'));
return;
}
try {
$entries = $workbench->embed($phrases, $this->embeddingModel);
$tokens = (int) $entries->sum(fn (EmbeddingEntry $entry): int => (int) ($entry->tokens ?? 0));
$this->embeddingResults = $entries
->map(fn (EmbeddingEntry $entry): array => $workbench->presentEntry($entry))
->all();
$recordUsageEvent->handle(
user: auth()->user(),
workbench: $workbench,
selectedModel: $this->embeddingModel,
tool: 'embed',
inputCount: count($phrases),
resultCount: $entries->count(),
tokens: $tokens > 0 ? $tokens : null,
);
$this->refreshSummary($workbench);
Flux::toast(variant: 'success', text: __('Embeddings generated.'));
} catch (\Throwable $exception) {
$this->addError('embeddingModel', $exception->getMessage());
}
}
protected function initializeWorkbench(EmbeddingWorkbench $workbench): void
{
$this->modelOptions = $workbench->modelOptions();
$this->embeddingModel = $this->embeddingModel ?: $workbench->modelName();
$this->refreshSummary($workbench);
}
protected function refreshSummary(EmbeddingWorkbench $workbench): void
{
$provider = $workbench->providerName();
$model = $workbench->modelName($this->embeddingModel);
$dimensions = $workbench->dimensions($model);
$this->providerSummary = "{$provider} / {$model} / {$dimensions}d";
$this->databaseReady = Schema::hasTable('embedding_entries');
if (! $this->databaseReady) {
$this->storedCount = 0;
return;
}
$this->storedCount = EmbeddingEntry::query()
->where('provider', $provider)
->where('model', $model)
->where('embedding_dimensions', $dimensions)
->count();
}
};
?>
<section class="flex w-full flex-col gap-6">
@include('pages.embeddings.partials.navigation', ['heading' => __('Embed')])
<section class="rounded-lg border border-zinc-200 bg-white p-5 dark:border-zinc-700 dark:bg-zinc-900">
<form wire:submit="generateEmbeddings" class="flex flex-col gap-4">
<flux:field>
<flux:label>{{ __('Phrases') }}</flux:label>
<flux:textarea wire:model="embeddingInput" rows="8" placeholder="{{ __('Laravel vectors') }}" />
<flux:error name="embeddingInput" />
</flux:field>
<div>
<flux:button type="submit" variant="primary" icon="sparkles" :disabled="! $databaseReady" class="data-loading:opacity-60">
<span wire:loading.remove wire:target="generateEmbeddings">{{ __('Generate') }}</span>
<span wire:loading wire:target="generateEmbeddings">{{ __('Generating') }}</span>
</flux:button>
</div>
@if ($embeddingResults !== [])
<div class="overflow-x-auto rounded-lg border border-zinc-200 dark:border-zinc-700">
<table class="min-w-full divide-y divide-zinc-200 text-sm dark:divide-zinc-700">
<thead class="bg-zinc-50 text-start text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">
<tr>
<th class="px-3 py-2 text-start font-medium">{{ __('Phrase') }}</th>
<th class="px-3 py-2 text-start font-medium">{{ __('Vector') }}</th>
<th class="px-3 py-2 text-start font-medium">{{ __('Meta') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-200 dark:divide-zinc-700">
@foreach ($embeddingResults as $result)
<tr wire:key="embedding-result-{{ $result['id'] }}">
<td class="max-w-xs px-3 py-3 align-top font-medium text-zinc-900 dark:text-zinc-100">
{{ $result['source_text'] }}
</td>
<td class="px-3 py-3 align-top">
<code class="block max-w-md truncate text-xs text-zinc-700 dark:text-zinc-300">[{{ implode(', ', $result['vector_preview']) }}...]</code>
<details class="mt-2">
<summary class="cursor-pointer text-xs text-zinc-500 dark:text-zinc-400">{{ __('Full vector') }}</summary>
<pre class="mt-2 max-h-52 overflow-auto rounded-md bg-zinc-950 p-3 text-xs text-zinc-100">{{ $result['vector_json'] }}</pre>
</details>
</td>
<td class="px-3 py-3 align-top text-zinc-600 dark:text-zinc-300">
<div>{{ $result['dimensions'] }}d</div>
<div class="text-xs">{{ $result['provider'] }} / {{ $result['model'] }}</div>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</form>
</section>
</section>

View File

@@ -0,0 +1,159 @@
<?php
use App\Actions\Embeddings\RecordEmbeddingUsageEvent;
use App\Models\EmbeddingEntry;
use App\Services\EmbeddingWorkbench;
use Illuminate\Support\Facades\Schema;
use Livewire\Attributes\Title;
use Livewire\Component;
new #[Title('Embedding search')] class extends Component
{
public string $similarityQuery = '';
public int $similarityLimit = 10;
public string $minimumSimilarity = '0.30';
public string $embeddingModel = '';
/** @var array<int, array{name: string, dimensions: int, default: bool}> */
public array $modelOptions = [];
/** @var array<int, array<string, mixed>> */
public array $similarityResults = [];
public string $providerSummary = '';
public int $storedCount = 0;
public bool $databaseReady = true;
public function mount(EmbeddingWorkbench $workbench): void
{
$this->initializeWorkbench($workbench);
}
public function searchSimilar(EmbeddingWorkbench $workbench, RecordEmbeddingUsageEvent $recordUsageEvent): void
{
$this->validate([
'similarityQuery' => ['required', 'string', 'max:1000'],
'similarityLimit' => ['required', 'integer', 'min:1', 'max:50'],
'minimumSimilarity' => ['required', 'numeric', 'min:0', 'max:1'],
'embeddingModel' => ['required', 'string'],
]);
if (! $this->databaseReady) {
$this->addError('similarityQuery', __('The embedding table is not ready.'));
return;
}
try {
$this->similarityResults = $workbench
->search($this->similarityQuery, $this->similarityLimit, (float) $this->minimumSimilarity, $this->embeddingModel)
->map(function (array $result) use ($workbench): array {
return [
...$workbench->presentEntry($result['entry']),
'similarity' => $result['similarity'],
'distance' => $result['distance'],
];
})
->all();
$recordUsageEvent->handle(
user: auth()->user(),
workbench: $workbench,
selectedModel: $this->embeddingModel,
tool: 'search',
inputCount: 1,
resultCount: count($this->similarityResults),
);
$this->refreshSummary($workbench);
} catch (\Throwable $exception) {
$this->addError('embeddingModel', $exception->getMessage());
}
}
protected function initializeWorkbench(EmbeddingWorkbench $workbench): void
{
$this->modelOptions = $workbench->modelOptions();
$this->embeddingModel = $this->embeddingModel ?: $workbench->modelName();
$this->refreshSummary($workbench);
}
protected function refreshSummary(EmbeddingWorkbench $workbench): void
{
$provider = $workbench->providerName();
$model = $workbench->modelName($this->embeddingModel);
$dimensions = $workbench->dimensions($model);
$this->providerSummary = "{$provider} / {$model} / {$dimensions}d";
$this->databaseReady = Schema::hasTable('embedding_entries');
if (! $this->databaseReady) {
$this->storedCount = 0;
return;
}
$this->storedCount = EmbeddingEntry::query()
->where('provider', $provider)
->where('model', $model)
->where('embedding_dimensions', $dimensions)
->count();
}
};
?>
<section class="flex w-full flex-col gap-6">
@include('pages.embeddings.partials.navigation', ['heading' => __('Similarity search')])
<section class="rounded-lg border border-zinc-200 bg-white p-5 dark:border-zinc-700 dark:bg-zinc-900">
<form wire:submit="searchSimilar" class="flex flex-col gap-4">
<flux:field>
<flux:label>{{ __('Query') }}</flux:label>
<flux:textarea wire:model="similarityQuery" rows="4" placeholder="{{ __('semantic search') }}" />
<flux:error name="similarityQuery" />
</flux:field>
<div class="grid gap-3 sm:grid-cols-2">
<flux:input wire:model="similarityLimit" :label="__('Limit')" type="number" min="1" max="50" />
<flux:input wire:model="minimumSimilarity" :label="__('Minimum similarity')" type="number" min="0" max="1" step="0.05" />
</div>
<div>
<flux:button type="submit" variant="primary" icon="magnifying-glass" :disabled="! $databaseReady" class="data-loading:opacity-60">
<span wire:loading.remove wire:target="searchSimilar">{{ __('Search') }}</span>
<span wire:loading wire:target="searchSimilar">{{ __('Searching') }}</span>
</flux:button>
</div>
@if ($similarityResults !== [])
<div class="overflow-x-auto rounded-lg border border-zinc-200 dark:border-zinc-700">
<table class="min-w-full divide-y divide-zinc-200 text-sm dark:divide-zinc-700">
<thead class="bg-zinc-50 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">
<tr>
<th class="px-3 py-2 text-start font-medium">{{ __('Phrase') }}</th>
<th class="px-3 py-2 text-start font-medium">{{ __('Similarity') }}</th>
<th class="px-3 py-2 text-start font-medium">{{ __('Distance') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-200 dark:divide-zinc-700">
@foreach ($similarityResults as $result)
<tr wire:key="similarity-result-{{ $result['id'] }}">
<td class="px-3 py-3 font-medium text-zinc-900 dark:text-zinc-100">{{ $result['source_text'] }}</td>
<td class="px-3 py-3 text-emerald-700 dark:text-emerald-300">{{ number_format($result['similarity'], 3) }}</td>
<td class="px-3 py-3 text-zinc-600 dark:text-zinc-300">{{ number_format($result['distance'], 3) }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</form>
</section>
</section>