session($request); if ($session === null) { return $this->missing(); } $text = Redactor::cleanText((string) $request->input('text', '')); if ($text === '') { return $this->validationError(['text' => [__('Type a message first.', 'maze-graph')]]); } if (mb_strlen($text) > self::MAX_MESSAGE) { return $this->validationError(['text' => [sprintf( /* translators: %s: maximum number of characters. */ __('A message can have at most %s characters.', 'maze-graph'), number_format_i18n(self::MAX_MESSAGE) )]]); } $refused = $this->refusal($gateway); if ($refused !== null) { return $refused; } if ($request->has('model')) { $model = sanitize_text_field((string) $request->input('model')); if ($model !== '' && !$gateway->availability()->hasModel($model)) { return $this->validationError(['model' => [__('That model is not available on this site.', 'maze-graph')]]); } $this->sessions->setModel($session, $model); } $edit = filter_var($request->input('edit', false), FILTER_VALIDATE_BOOLEAN); $clientId = substr((string) preg_replace('/[^A-Za-z0-9-]/', '', (string) $request->input('message_id', '')), 0, 64); $started = $edit ? $this->runs->edit($session->id, $text, $this->userId()) : $this->runs->start($session->id, $text, $this->userId(), $clientId); if ($started === false) { return $this->validationError(['text' => [__('There is no message to edit yet.', 'maze-graph')]]); } if ($started === null) { return $this->busy(); } if (($started['duplicate'] ?? false) === true) { return $this->reply($session, $started); } // An edit of the only message renames an automatically titled session after the new text. $only = $edit && count(array_filter( Transcript::visible($this->store->entries($session->id)), static fn (array $e): bool => ($e['type'] ?? '') === 'message' && ($e['role'] ?? '') === 'user' )) === 1; $this->sessions->autoTitle($session, Transcript::title($text), $only); return $this->reply($session, $started); } public function regenerate(Request $request, ModelGateway $gateway): JsonResponse { $session = $this->session($request); if ($session === null) { return $this->missing(); } $refused = $this->refusal($gateway); if ($refused !== null) { return $refused; } $started = $this->runs->regenerate($session->id, $this->userId()); if ($started === false) { return $this->validationError(['session' => [__('There is no message to answer yet.', 'maze-graph')]]); } return $started === null ? $this->busy() : $this->reply($session, $started); } public function step(Request $request): JsonResponse { $session = $this->session($request); if ($session === null) { return $this->missing(); } $runId = (string) $request->input('run', ''); if (!wp_is_uuid($runId)) { return $this->validationError(['run' => [__('Which run?', 'maze-graph')]]); } $result = $this->runs->advance($session->id, $this->userId(), $this->model($session), $runId, max(0, (int) $request->input('step', 0))); return $this->reply($session, $result); } public function approve(Request $request): JsonResponse { $session = $this->session($request); if ($session === null) { return $this->missing(); } $runId = (string) $request->input('run', ''); $approvalId = (string) $request->input('approval', ''); $decisions = $request->input('decisions', []); if (!wp_is_uuid($runId) || !wp_is_uuid($approvalId) || !is_array($decisions)) { return $this->validationError(['approval' => [__('Which approval?', 'maze-graph')]]); } $clean = []; foreach ($decisions as $callId => $approved) { if (is_string($callId) && $callId !== '') { $clean[$callId] = filter_var($approved, FILTER_VALIDATE_BOOLEAN); } } $result = $this->runs->resolve($session->id, $this->userId(), $this->model($session), $runId, $approvalId, $clean, (string) $request->input('reason', '')); if ($result === null) { return $this->error(__('This action is no longer waiting for an answer.', 'maze-graph'), 409); } return $this->reply($session, $result); } public function background(Request $request, JobQueue $jobs): JsonResponse { $session = $this->session($request); if ($session === null) { return $this->missing(); } $run = $this->store->run($session->id); if ($run['status'] !== 'running' || $run['id'] !== (string) $request->input('run', '')) { return $this->error(__('This reply is not running.', 'maze-graph'), 409); } $jobs->enqueue('run', 'reply', $this->userId(), $session->id, ['model' => $this->model($session)]); if (!wp_next_scheduled(AiServiceProvider::JOBS_HOOK)) { wp_schedule_single_event(time(), AiServiceProvider::JOBS_HOOK); } return $this->reply($session, ['entries' => []]); } public function cancel(Request $request): JsonResponse { $session = $this->session($request); if ($session === null) { return $this->missing(); } $entries = $this->runs->cancel($session->id, (string) $request->input('run', '')); return $this->reply($session, ['entries' => $entries]); } /** * Why a new run may not start now, or null when it may: policy decides (`run.start`), then a provider must be * ready. */ protected function refusal(ModelGateway $gateway): ?JsonResponse { $decision = $this->policy->decide(new PolicyRequest(new Principal($this->userId()), 'run.start')); if (!$decision->allowed()) { return $this->error($decision->message, match ($decision->code) { 'disabled' => 503, 'consent_required' => 428, 'user_rate_limited' => 429, default => 403, }); } if (!$this->availabilitySummary($gateway)->ready()) { return $this->error(__('No AI provider is ready. Connect one in Settings → Connectors.', 'maze-graph'), 503); } return null; } /** * Avoid model enumeration for a provider readiness check; retain compatibility with older adapters. */ protected function availabilitySummary(ModelGateway $gateway): Availability { return $gateway instanceof ModelAvailabilitySummary ? $gateway->summary() : $gateway->availability(); } protected function model(Session $session): string { return $session->model !== '' ? $session->model : $this->settings->string('default_model'); } protected function busy(): JsonResponse { return $this->error(__('This session is still answering. Wait for it, or stop it first.', 'maze-graph'), 409); } protected function session(Request $request): ?Session { return $this->sessions->find($this->userId(), (string) $request->route('id')); } /** * @param array{entries: list>, retry_after?: int} $result */ protected function reply(Session $session, array $result): JsonResponse { $fresh = $this->sessions->find($this->userId(), $session->uuid) ?? $session; return $this->ok(['session' => SessionResource::make($fresh), 'entries' => Transcript::forClient($result['entries'])] + (isset($result['retry_after']) ? ['retry_after' => (int) $result['retry_after']] : [])) ->header('Cache-Control', 'no-store, private'); } /** * Mark a reply that carries a person's own data as not cacheable. */ protected function noStore(JsonResponse $response): JsonResponse { return $response->header('Cache-Control', 'no-store, private'); } protected function missing(): JsonResponse { return $this->notFound(__('That session does not exist or was deleted.', 'maze-graph')); } }