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
38 changes: 34 additions & 4 deletions app/Http/Controllers/ChatController.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
use App\Support\Connections\ViewerIdentityResolver;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Validation\ValidationException;

class ChatController extends Controller
{
Expand All @@ -19,6 +21,23 @@ public function store(
ViewerIdentityResolver $viewerIdentityResolver,
WorkspaceChatManager $chatManager,
): RedirectResponse {
$submittedToken = (string) $request->validated('chat_submission_token');
$expectedToken = $request->session()->get('chat.create_token');

if (! is_string($expectedToken) || ! hash_equals($expectedToken, $submittedToken)) {
return to_route('home');
}

$submissionTokenCacheKey = sprintf(
'workspace-chat-create:%d:%s',
(int) $request->user()->getKey(),
sha1($submittedToken),
);

if (! Cache::add($submissionTokenCacheKey, true, now()->addMinutes(10))) {
return to_route('home');
}

$activeConnection = $connectionManager->activeConnectionFor(
$request->user(),
$request->root(),
Expand All @@ -33,10 +52,21 @@ public function store(
$activeWorkspace = $connectionManager->activeWorkspaceFor($activeConnection, $workspaces);
$viewerIdentity = $viewerIdentityResolver->resolve($request->user(), $activeConnection);

$chatManager->createChat($activeWorkspace, $request->user(), $viewerIdentity, [
'name' => $request->validated('chat_name'),
'kind' => $request->validated('chat_kind'),
]);
try {
$chatManager->createChat($activeWorkspace, $request->user(), $viewerIdentity, [
'name' => $request->validated('chat_name'),
'kind' => $request->validated('chat_kind'),
'workspace_agent_id' => $request->integer('workspace_agent_id') ?: null,
]);
} catch (ValidationException $exception) {
Cache::forget($submissionTokenCacheKey);

throw $exception;
}

if (hash_equals((string) $request->session()->get('chat.create_token'), $submittedToken)) {
$request->session()->forget('chat.create_token');
}
Comment on lines 24 to +69
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The submission-token protection isn’t race-safe: the token is validated, then the chat is created, and only afterwards the session token is cleared. Two near-simultaneous POSTs can both pass the initial hash_equals check and create duplicate chats. Consider consuming/invalidating the token before creating the chat (or using an atomic server-side idempotency mechanism such as a DB-backed unique token per user/workspace) so concurrent requests can’t both succeed.

Copilot uses AI. Check for mistakes.

return to_route('home');
}
Expand Down
36 changes: 28 additions & 8 deletions app/Http/Controllers/HomeController.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use App\Models\InstanceConnection;
use App\Models\SurrealWorkspace;
use App\Models\Workspace;
use App\Models\WorkspaceAgent;
use App\Models\WorkspaceChat;
use App\Models\WorkspaceChatMessage;
use App\Models\WorkspaceChatParticipant;
Expand All @@ -17,6 +18,7 @@
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\View\View;
use RuntimeException;
use Throwable;
Expand Down Expand Up @@ -310,18 +312,27 @@ private function roomLinks(InstanceConnection $activeConnection, string $activeR

/**
* @param EloquentCollection<int, WorkspaceChat> $chats
* @return array<int, array{label: string, active?: bool, prefix: string, tone: string, action?: string|null}>
* @return array<int, array{label: string, active?: bool, prefix: string, tone: string, meta?: string|null, action?: string|null}>
*/
private function chatLinks(EloquentCollection $chats, WorkspaceChat $activeChat): array
{
return $chats
->map(fn (WorkspaceChat $chat): array => [
'label' => $chat->name,
'active' => (int) $chat->getKey() === (int) $activeChat->getKey(),
'prefix' => $chat->kind === WorkspaceChat::KIND_DIRECT ? '@' : strtoupper(substr($chat->name, 0, 1)),
'tone' => $chat->kind === WorkspaceChat::KIND_DIRECT ? 'human' : 'room',
'action' => (int) $chat->getKey() === (int) $activeChat->getKey() ? null : route('chats.activate', $chat),
])
->map(function (WorkspaceChat $chat) use ($activeChat): array {
$hasAgentParticipant = (bool) $chat->has_agent_participant;

return [
'label' => $chat->name,
'active' => (int) $chat->getKey() === (int) $activeChat->getKey(),
'prefix' => $hasAgentParticipant
? 'AI'
: ($chat->kind === WorkspaceChat::KIND_DIRECT ? '@' : strtoupper(substr($chat->name, 0, 1))),
'tone' => $hasAgentParticipant
? 'bot'
: ($chat->kind === WorkspaceChat::KIND_DIRECT ? 'human' : 'room'),
'meta' => $hasAgentParticipant ? 'Agent' : null,
'action' => (int) $chat->getKey() === (int) $activeChat->getKey() ? null : route('chats.activate', $chat),
];
})
->values()
->all();
}
Expand Down Expand Up @@ -516,11 +527,15 @@ public function __invoke(
$connections = $connectionManager->connectionsFor($request->user());
$workspaces = $connectionManager->workspacesFor($activeConnection);
$activeWorkspaceModel = $connectionManager->activeWorkspaceFor($activeConnection, $workspaces);
$availableAgents = $activeWorkspaceModel->agents;
$activeWorkspace = $this->activeWorkspaceState($activeConnection, $activeWorkspaceModel, $localReady);
$activeChatModel = $chatManager->activeChatFor($activeWorkspaceModel, $request->user(), $viewerIdentity);
$chats = $chatManager->chatsFor($activeWorkspaceModel);
$participants = $this->chatParticipants($activeChatModel);
$messages = $this->chatMessages($activeChatModel);
$chatSubmissionToken = (string) Str::uuid();

$request->session()->put('chat.create_token', $chatSubmissionToken);

return view('welcome', [
'mvpShellEnabled' => $mvpShellEnabled,
Expand All @@ -536,6 +551,11 @@ public function __invoke(
'conversationNodeTabs' => $this->conversationNodeTabs($activeWorkspace),
'messages' => $messages,
'participants' => $participants,
'chatSubmissionToken' => $chatSubmissionToken,
'availableAgents' => $availableAgents->map(fn (WorkspaceAgent $workspaceAgent): array => [
'id' => (int) $workspaceAgent->getKey(),
'name' => $workspaceAgent->name,
])->values()->all(),
'viewerName' => $viewerIdentity['name'],
'viewerEmail' => $viewerIdentity['email'],
'viewerInitials' => $viewerIdentity['initials'],
Expand Down
11 changes: 11 additions & 0 deletions app/Http/Requests/StoreWorkspaceChatRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ public function authorize(): bool
return $this->user() !== null;
}

protected function prepareForValidation(): void
{
$this->merge([
'chat_name' => is_string($this->chat_name) ? trim($this->chat_name) : $this->chat_name,
]);
}

/**
* @return array<string, ValidationRule|array<int, ValidationRule|string>|string>
*/
Expand All @@ -20,6 +27,8 @@ public function rules(): array
return [
'chat_name' => ['required', 'string', 'max:255', 'regex:/\\S/'],
'chat_kind' => ['required', 'string', 'in:direct,group'],
'chat_submission_token' => ['required', 'string'],
'workspace_agent_id' => ['nullable', 'integer', 'exists:workspace_agents,id'],
];
}

Expand All @@ -33,6 +42,8 @@ public function messages(): array
'chat_name.regex' => 'Chat names cannot be blank.',
'chat_kind.required' => 'Choose whether this chat is direct or group.',
'chat_kind.in' => 'Chats must be direct or group conversations.',
'chat_submission_token.required' => 'Refresh the workspace before creating a chat.',
'workspace_agent_id.exists' => 'Choose a valid agent for this workspace.',
];
}
}
8 changes: 8 additions & 0 deletions app/Models/Workspace.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,12 @@ public function chats(): HasMany
{
return $this->hasMany(WorkspaceChat::class);
}

/**
* @return HasMany<WorkspaceAgent, $this>
*/
public function agents(): HasMany
{
return $this->hasMany(WorkspaceAgent::class);
}
}
38 changes: 38 additions & 0 deletions app/Models/WorkspaceAgent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

namespace App\Models;

use App\Ai\Agents\WorkspaceGuide;
use Database\Factories\WorkspaceAgentFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;

#[Fillable(['workspace_id', 'agent_key', 'name', 'agent_class', 'summary'])]
class WorkspaceAgent extends Model
{
public const KEY_WORKSPACE_GUIDE = 'workspace-guide';

public const CLASS_WORKSPACE_GUIDE = WorkspaceGuide::class;

/** @use HasFactory<WorkspaceAgentFactory> */
use HasFactory;

/**
* @return BelongsTo<Workspace, $this>
*/
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}

/**
* @return HasMany<WorkspaceChatParticipant, $this>
*/
public function chatParticipants(): HasMany
{
return $this->hasMany(WorkspaceChatParticipant::class, 'workspace_agent_id');
}
}
12 changes: 11 additions & 1 deletion app/Models/WorkspaceChat.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;

#[Fillable(['workspace_id', 'name', 'slug', 'kind', 'visibility', 'summary'])]
#[Fillable(['workspace_id', 'name', 'slug', 'kind', 'visibility', 'summary', 'has_agent_participant'])]
class WorkspaceChat extends Model
{
public const KIND_DIRECT = 'direct';
Expand All @@ -21,6 +21,16 @@ class WorkspaceChat extends Model
/** @use HasFactory<WorkspaceChatFactory> */
use HasFactory;

/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'has_agent_participant' => 'boolean',
];
}

/**
* @return BelongsTo<Workspace, $this>
*/
Expand Down
10 changes: 9 additions & 1 deletion app/Models/WorkspaceChatParticipant.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

#[Fillable(['chat_id', 'user_id', 'participant_type', 'participant_key', 'display_name'])]
#[Fillable(['chat_id', 'user_id', 'workspace_agent_id', 'participant_type', 'participant_key', 'display_name'])]
class WorkspaceChatParticipant extends Model
{
public const TYPE_HUMAN = 'human';
Expand All @@ -33,4 +33,12 @@ public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}

/**
* @return BelongsTo<WorkspaceAgent, $this>
*/
public function agent(): BelongsTo
{
return $this->belongsTo(WorkspaceAgent::class, 'workspace_agent_id');
}
}
112 changes: 112 additions & 0 deletions app/Support/Chats/WorkspaceAgentManager.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
<?php

namespace App\Support\Chats;

use App\Models\Workspace;
use App\Models\WorkspaceAgent;
use App\Models\WorkspaceChatParticipant;
use Illuminate\Database\Eloquent\Collection;

class WorkspaceAgentManager
{
/**
* @var array<int, array{agent_key: string, name: string, agent_class: string, summary: string}>
*/
private const DEFAULT_AGENTS = [
[
'agent_key' => WorkspaceAgent::KEY_WORKSPACE_GUIDE,
'name' => 'Workspace Guide',
'agent_class' => WorkspaceAgent::CLASS_WORKSPACE_GUIDE,
'summary' => 'Helps shape durable, graph-native collaboration inside this workspace.',
],
];

/**
* @return Collection<int, WorkspaceAgent>
*/
public function agentsFor(Workspace $workspace): Collection
{
return $workspace->agents()
->orderBy('name')
->get()
->values();
}

/**
* @return Collection<int, WorkspaceAgent>
*/
public function ensureDefaults(Workspace $workspace): Collection
{
$existingAgents = $workspace->agents()
->orderBy('id')
->get()
->groupBy('agent_key');

foreach (self::DEFAULT_AGENTS as $definition) {
$workspaceAgent = $existingAgents
->get($definition['agent_key'])
?->first();

if ($workspaceAgent instanceof WorkspaceAgent) {
$workspaceAgent->forceFill([
'name' => $definition['name'],
'agent_class' => $definition['agent_class'],
'summary' => $definition['summary'],
]);

if ($workspaceAgent->isDirty()) {
$workspaceAgent->save();
}

$duplicateAgents = $existingAgents
->get($definition['agent_key'])
?->slice(1)
->values() ?? collect();

$duplicateAgentIds = $duplicateAgents
->map(fn (WorkspaceAgent $duplicateAgent): int => (int) $duplicateAgent->getKey())
->all();

if ($duplicateAgentIds !== []) {
WorkspaceChatParticipant::query()
->whereIn('workspace_agent_id', $duplicateAgentIds)
->get()
->each(function (WorkspaceChatParticipant $participant) use ($workspaceAgent): void {
$participant->forceFill([
'workspace_agent_id' => $workspaceAgent->getKey(),
'display_name' => $workspaceAgent->name,
])->save();
});

$duplicateAgents
->each(fn (WorkspaceAgent $duplicateAgent): bool => $duplicateAgent->delete());
}

continue;
}

$workspace->agents()->create([
'agent_key' => $definition['agent_key'],
'name' => $definition['name'],
'agent_class' => $definition['agent_class'],
'summary' => $definition['summary'],
]);
}

return $workspace->agents()
->orderBy('name')
->get()
->values();
}

public function agentForWorkspace(Workspace $workspace, ?int $agentId): ?WorkspaceAgent
{
if ($agentId === null) {
return null;
}

return $workspace->agents()
->whereKey($agentId)
->first();
}
}
Loading
Loading