Skip to content
Open
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
62 changes: 62 additions & 0 deletions app/Dto/Post/IntentContent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php

declare(strict_types=1);

namespace App\Dto\Post;

/**
* The body of a publish_now / schedule_at intent: a single text or a thread,
* plus optional media and mentions. `text` stays the legacy single-post input;
* non-empty `segments` take precedence over it, as in create_post.
*/
final readonly class IntentContent
{
/**
* @param list<string> $segments
* @param list<string> $mediaIds
* @param list<array{id: string, label: string, handles?: array<string, string|null>}> $mentions
*/
public function __construct(
public string $text,
public array $segments = [],
public array $mediaIds = [],
public array $mentions = [],
) {}

/**
* @param array<string, mixed> $validated
*/
public static function fromValidated(array $validated): self
{
return new self(
text: (string) ($validated['text'] ?? ''),
segments: array_values($validated['segments'] ?? []),
mediaIds: array_values($validated['media_ids'] ?? []),
mentions: array_values($validated['mentions'] ?? []),
);
}

/**
* @return list<string>
*/
public function effectiveSegments(): array
{
return $this->segments !== [] ? $this->segments : [$this->text];
}

/**
* Fields folded into the idempotency fingerprint. Empty optional fields are
* left out so a text-only request hashes exactly as it did before media,
* segments, and mentions existed, keeping stored request hashes valid.
*
* @return array<string, mixed>
*/
public function fingerprintFields(): array
{
return array_filter([
'media_ids' => $this->mediaIds,
'mentions' => $this->mentions,
'segments' => $this->segments,
], static fn (array $value): bool => $value !== []);
}
}
11 changes: 11 additions & 0 deletions app/Enums/AgentIntentAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

declare(strict_types=1);

namespace App\Enums;

enum AgentIntentAction: string
{
case PublishNow = 'publish_now';
case ScheduleAt = 'schedule_at';
}
6 changes: 5 additions & 1 deletion app/Enums/OnboardingStep.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

/**
* The getting-started checklist steps. Most steps derive "done" from real data
* (an account exists, a post exists, a teammate was invited). Timezone is the
* (an account exists, a sync pipeline exists, a post exists, a teammate was invited). Timezone is the
* exception: there is no reliable data signal (UTC is a legitimate choice, not
* "unset"), so it is click-to-complete — clicking records its key in the
* workspace's onboarding_progress. This enum is the single source of truth
Expand All @@ -15,6 +15,7 @@
enum OnboardingStep: string
{
case ConnectAccount = 'connect_account';
case EnableSync = 'enable_sync';
case FirstPost = 'first_post';
case Timezone = 'timezone';
case InviteTeammate = 'invite_teammate';
Expand All @@ -32,6 +33,7 @@ public function label(): string
{
return match ($this) {
self::ConnectAccount => 'Connect an account',
self::EnableSync => 'Turn on sync',
self::FirstPost => 'Compose your first post',
self::Timezone => 'Set your workspace timezone',
self::InviteTeammate => 'Invite a teammate',
Expand All @@ -45,6 +47,7 @@ public function permission(): string
{
return match ($this) {
self::ConnectAccount => 'workspace.accounts.manage',
self::EnableSync => 'workspace.settings.manage',
self::FirstPost => 'workspace.update',
self::Timezone => 'workspace.settings.manage',
self::InviteTeammate => 'workspace.users.manage',
Expand All @@ -58,6 +61,7 @@ public function routeName(): string
{
return match ($this) {
self::ConnectAccount => 'accounts.index',
self::EnableSync => 'sync.index',
self::FirstPost => 'dashboard',
self::Timezone => 'settings.workspace',
self::InviteTeammate => 'settings.workspace.members',
Expand Down
1 change: 1 addition & 0 deletions app/Enums/PostOrigin.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ enum PostOrigin: string
case Composer = 'composer';
case Sync = 'sync';
case External = 'external';
case Agent = 'agent';
}
9 changes: 9 additions & 0 deletions app/Enums/PostStatus.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,13 @@ public function isEditable(): bool
{
return $this === self::Draft || $this === self::Scheduled;
}

/**
* Only finished posts can be copied into a new draft; a draft or scheduled
* post is still editable in place.
*/
public function isDuplicable(): bool
{
return in_array($this, [self::Published, self::Partial, self::Failed, self::Missed], true);
}
}
19 changes: 19 additions & 0 deletions app/Enums/UiMode.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

namespace App\Enums;

enum UiMode: string
{
case Agentic = 'agentic';
case Classic = 'classic';

public function homeRoute(): string
{
return match ($this) {
self::Agentic => 'agentic.home',
self::Classic => 'dashboard',
};
}
}
24 changes: 24 additions & 0 deletions app/Exceptions/AgentIntentRejectedException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

namespace App\Exceptions;

use RuntimeException;

/**
* A high-level agent intent was refused before it changed workspace state.
*/
class AgentIntentRejectedException extends RuntimeException
{
/**
* @param array<string, mixed> $payload
*/
public function __construct(
string $message,
public readonly int $status = 422,
public readonly array $payload = [],
) {
parent::__construct($message);
}
}
180 changes: 180 additions & 0 deletions app/Http/Controllers/AgenticHomeController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Enums\PostOrigin;
use App\Enums\PostStatus;
use App\Models\ConnectedAccount;
use App\Models\Post;
use App\Models\SyncPipeline;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Billing\WorkspaceSubscriptionGate;
use App\Services\ConnectedAccounts\AccountConnectLinks;
use App\Support\PostListItem;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Inertia\Inertia;
use Inertia\Response;

class AgenticHomeController extends Controller
{
public function __construct(
private readonly AccountConnectLinks $connectLinks,
private readonly WorkspaceSubscriptionGate $subscriptions,
) {}

/**
* Agentic home. Before anything is posted it walks through setup: connect
* accounts, then turn on sync. After that it shows recent posts. The MCP
* and REST API setup lives in Settings → Agentic.
*/
public function index(Request $request): Response
{
/** @var User $user */
$user = $request->user();
// A stale current_workspace_id (e.g. removed from that workspace) falls
// back to the empty state instead of leaking its data or a 403.
$workspace = $user->currentWorkspace instanceof Workspace && $user->can('viewAny', Post::class)
? $user->currentWorkspace
: null;
$accounts = $workspace === null ? collect() : $this->enabledAccounts($workspace->id);

return Inertia::render('agentic/home', [
'platforms' => $user->can('create', ConnectedAccount::class) ? $this->connectablePlatforms() : [],
'accounts' => $accounts->map(fn (ConnectedAccount $account): array => [
'id' => $account->id,
'platform' => $account->platform->value,
'handle' => $account->handle,
'display_name' => $account->display_name,
'avatar_url' => $account->avatar_url,
'supports_native' => $account->platform->supportsNativeRead(),
])->values()->all(),
'sync' => $workspace === null ? $this->emptySync() : $this->syncState($workspace, $accounts),
'recent_posts' => $workspace === null ? null : $this->recentPosts($workspace->id),
]);
}

/**
* Once sync or an assistant has posted, the home is a feed of recent posts
* and setup moves out of the way. Null until then.
*
* @return list<array<string, mixed>>|null
*/
private function recentPosts(string $workspaceId): ?array
{
$hasPosted = Post::query()
->where('workspace_id', $workspaceId)
->whereIn('origin', [PostOrigin::Sync, PostOrigin::Agent])
->exists();

if (! $hasPosted) {
return null;
}

return array_values(Post::query()
->where('workspace_id', $workspaceId)
->where('status', '!=', PostStatus::Draft)
->with(['targets', 'media', 'author'])
->latest()
->limit(20)
->get()
->map(fn (Post $post): array => PostListItem::make($post))
->all());
}

/**
* @return Collection<int, ConnectedAccount>
*/
private function enabledAccounts(string $workspaceId): Collection
{
return ConnectedAccount::query()
->where('workspace_id', $workspaceId)
->enabled()
->oldest()
->get();
}

/**
* @return list<array{platform: string, label: string, method: string, url: string}>
*/
private function connectablePlatforms(): array
{
$platforms = [];

foreach ($this->connectLinks->listReturningToAgentic() as $link) {
if ($link['available'] && $link['url'] !== null) {
$platforms[] = [
'platform' => $link['platform'],
'label' => $link['label'],
'method' => $link['method'],
'url' => $link['url'],
];
}
}

return $platforms;
}

/**
* @return array{total: int, enabled: int, can_create: bool, can_track: bool, suggestion: null}
*/
private function emptySync(): array
{
return ['total' => 0, 'enabled' => 0, 'can_create' => false, 'can_track' => false, 'suggestion' => null];
}

/**
* @param Collection<int, ConnectedAccount> $accounts
* @return array{total: int, enabled: int, can_create: bool, can_track: bool, suggestion: array{name: string, source_connected_account_id: string, destination_connected_account_ids: list<string>}|null}
*/
private function syncState(Workspace $workspace, Collection $accounts): array
{
$pipelines = SyncPipeline::query()->where('workspace_id', $workspace->id);
$total = (clone $pipelines)->count();

return [
'total' => $total,
'enabled' => (clone $pipelines)->where('enabled', true)->count(),
'can_create' => $this->subscriptions->canCreateSyncPipeline($workspace),
'can_track' => $this->subscriptions->canTrackNativeAccount($workspace),
'suggestion' => $total === 0 ? $this->suggestPipeline($accounts) : null,
];
}

/**
* First sync to offer: the oldest account that can be tracked natively is
* the source, so posts made directly on that platform sync too. LinkedIn and
* Discord cannot be tracked, so they are only a source when nothing else is.
*
* @param Collection<int, ConnectedAccount> $accounts
* @return array{name: string, source_connected_account_id: string, destination_connected_account_ids: list<string>}|null
*/
private function suggestPipeline(Collection $accounts): ?array
{
if ($accounts->count() < 2) {
return null;
}

$source = $accounts->first(fn (ConnectedAccount $account): bool => $account->platform->supportsNativeRead())
?? $accounts->first();

if (! $source instanceof ConnectedAccount) {
return null;
}

$destinations = array_values($accounts
->reject(fn (ConnectedAccount $account): bool => $account->id === $source->id)
->take(3)
->map(fn (ConnectedAccount $account): string => $account->id)
->all());

return [
'name' => $source->platform->label().' '.$source->handle.' sync',
'source_connected_account_id' => $source->id,
'destination_connected_account_ids' => $destinations,
];
}
}
Loading
Loading