on()) { return $this->off(); } $templates = $this->templates(); return $this->noStore($this->ok([ 'templates' => array_values(array_map([$this, 'template'], $templates)), 'preferences' => $preferences->forStart($templates), ])); } /** * One searchable page of the current person's Studio documents. */ public function documents(Request $request): JsonResponse { if (!$this->on()) { return $this->off(); } $limit = min(200, max(1, (int) $request->query('limit', 10))); $offset = max(0, (int) $request->query('offset', 0)); $search = sanitize_text_field((string) $request->query('search', '')); $total = $this->sessions->count($this->userId(), $search, true); $visible = $this->sessions->list($this->userId(), $search, $limit, $offset, true); return $this->noStore($this->ok([ 'items' => array_map([SessionResource::class, 'make'], $visible), // The WordPress post each document was last handed off to: session id => its state now. 'posts' => (object) $this->handoff->postsFor($visible), 'meta' => [ 'total' => $total, 'more' => $offset + count($visible) < $total, 'offset' => $offset, ], ])); } public function create(Request $request, ModelGateway $gateway, PostTargets $targets, StudioPreferences $preferences): JsonResponse { if (!$this->on()) { return $this->off(); } $templates = $this->templates(); $template = $templates[(string) $request->input('template', '')] ?? null; if ($template === null) { return $this->validationError(['template' => [__('Choose a template.', 'maze-graph')]]); } if ($request->has('stages') || $request->has('instructions') || $request->has('mode')) { return $this->validationError(['workflow' => [__('Choose a saved instruction set; workflow and stage instructions are configured in Settings.', 'maze-graph')]]); } $requestedPostType = $request->input('post_type', $template['data']['post_type'] ?? 'post'); $postType = sanitize_key(is_scalar($requestedPostType) ? (string) $requestedPostType : ''); if (!$targets->allowed($postType)) { return $this->validationError(['post_type' => [__('You may not create that kind of content.', 'maze-graph')]]); } $templateId = (string) $template['data']['id']; $startConfig = $preferences->forStart($templates); $requestedSet = $request->input('instruction_set', $startConfig['default_sets'][$templateId] ?? StudioPreferences::DEFAULT_SET); $setId = sanitize_key(is_scalar($requestedSet) ? (string) $requestedSet : ''); $selectedSet = null; foreach ($startConfig['sets'][$templateId] ?? [] as $set) { if ($set['id'] === $setId) { $selectedSet = $set; break; } } if ($selectedSet === null) { return $this->validationError(['instruction_set' => [__('Choose an instruction set for this template.', 'maze-graph')]]); } $requestedTone = $request->input('tone', ''); $tone = $preferences->tone($templates, sanitize_key(is_scalar($requestedTone) ? (string) $requestedTone : '')); if ($tone === null) { return $this->validationError(['tone' => [__('Choose one of the saved tone presets.', 'maze-graph')]]); } $answers = is_array($request->input('fields')) ? $request->input('fields') : []; $values = []; $errors = []; foreach ((array) ($template['data']['fields'] ?? []) as $field) { $id = (string) $field['id']; if (in_array($id, ['tone', 'language'], true)) { continue; } $value = Redactor::cleanText(is_scalar($answers[$id] ?? null) ? (string) $answers[$id] : ''); $limit = (int) ($field['max'] ?? (($field['type'] ?? '') === 'textarea' ? 2000 : 300)); if ($value === '' && isset($field['default']) && ($field['type'] ?? '') === 'select') { $value = (string) $field['default']; } if (($field['required'] ?? false) === true && $value === '') { $errors["fields.{$id}"][] = __('This is needed.', 'maze-graph'); } elseif (mb_strlen($value) > $limit) { /* translators: %s: maximum number of characters. */ $errors["fields.{$id}"][] = sprintf(__('Use at most %s characters.', 'maze-graph'), number_format_i18n($limit)); } elseif (($field['type'] ?? '') === 'select' && $value !== '' && !in_array($value, (array) ($field['options'] ?? []), true)) { $errors["fields.{$id}"][] = __('Choose one of the options.', 'maze-graph'); } $values[$id] = $value; } $languageInput = $request->input('language', ''); $language = Redactor::cleanText(is_scalar($languageInput) ? (string) $languageInput : ''); if (mb_strlen($language) > 300) { $errors['language'][] = __('Keep the language under 300 characters.', 'maze-graph'); } $values['tone'] = $tone['name']; $values['language'] = mb_substr($language, 0, 300); if (($template['data']['source'] ?? 'none') === 'post') { $postInput = $request->input('post', 0); $post = get_post(is_scalar($postInput) ? absint($postInput) : 0); if (!$post instanceof \WP_Post || !current_user_can('edit_post', $post->ID) || $post->post_type !== $postType) { $errors['post'][] = __('Choose a post or page you may edit.', 'maze-graph'); } else { $values['post'] = (string) $post->ID; } } if ($errors !== []) { return $this->validationError($errors); } $refused = $this->refusal($gateway); if ($refused !== null) { return $refused; } // Snapshot the configured default on the new document. Later changes to Settings must not silently switch // providers between Studio stages or follow-up fixes. $requestedModel = $request->input('model', ''); $model = sanitize_text_field(is_scalar($requestedModel) ? (string) $requestedModel : ''); if ($model === '') { $model = $this->settings->string('default_model'); } if ($model !== '' && !$gateway->availability()->hasModel($model)) { return $this->validationError(['model' => [__('That model is not available on this site.', 'maze-graph')]]); } $agent = is_string($template['data']['agent'] ?? null) ? $template['data']['agent'] : self::AGENT; $decision = $this->policy->decide(new PolicyRequest(new Principal($this->userId()), 'agent.use', $agent)); if (!$decision->allowed()) { return $this->error($decision->message, 403); } $first = ''; foreach ($values as $key => $value) { if (in_array($key, ['tone', 'language', 'post'], true)) { continue; } if ($value !== '' && !ctype_digit($value)) { $first = $value; break; } } $title = Transcript::title((string) $template['data']['name'] . ($first !== '' ? ': ' . $first : '')); // The selected Settings set is authoritative; Start cannot override the shared workflow configuration. $fromPost = ($template['data']['source'] ?? 'none') === 'post'; $stages = Stages::resolve($selectedSet['stages'], $fromPost); $instructions = $selectedSet['instructions']; $brief = StudioDocument::fill((string) $template['data']['instructions'], $values); if ($tone['instruction'] !== '') { $brief .= "\n\n" . __('Tone guidance:', 'maze-graph') . ' ' . $tone['instruction']; } if ($language !== '' && !str_contains((string) $template['data']['instructions'], '{language}')) { $brief .= "\n\n" . __('Write in this language:', 'maze-graph') . ' ' . $language; } $session = $this->sessions->create($this->userId(), $model, $agent, $title); $this->store->append($session->id, Transcript::event('studio', [ 'studio' => self::STUDIO, 'template' => $templateId, 'instruction_set' => $setId, 'tone' => $tone['id'], 'post_type' => $postType, 'message' => (string) $template['data']['name'], 'brief' => $brief, 'stages' => $stages, 'instructions' => $instructions, 'fields' => array_filter($values, static fn (string $value, string $key): bool => $key !== 'post' && $value !== '', ARRAY_FILTER_USE_BOTH), ])); $text = $brief; if ($postType !== (string) ($template['data']['post_type'] ?? 'post')) { $typeLabel = $postType; foreach ($targets->types() as $type) { if ($type['name'] === $postType) { $typeLabel = $type['label']; break; } } $text = sprintf( /* translators: %s: WordPress content type label. */ __('The person chose to create a WordPress %s. Adapt the content structure and tone to fit that content type while honoring the brief below.', 'maze-graph'), $typeLabel ) . "\n\n" . $text; } $state = ['instructions' => $instructions, 'notes' => '']; $first = in_array(Stages::RESEARCH, $stages, true) ? 'research' : (in_array(Stages::PLAN, $stages, true) ? 'outline' : 'generate'); $message = match ($first) { 'research' => DraftingPrompts::lead($state, Stages::RESEARCH) . DraftingPrompts::research($text), 'outline' => DraftingPrompts::lead($state, Stages::PLAN) . DraftingPrompts::outline($text), default => DraftingPrompts::lead($state, Stages::WRITE) . $text, }; $started = $this->runs->start($session->id, $message, $this->userId(), $this->clientId($request)); if ($started === null) { return $this->busy(); } if ($first !== 'generate') { $this->store->append($session->id, Transcript::event('studio_expect', ['kind' => $first])); } $this->events->record(new Event('studio.generated', ['session' => $session->id, 'user' => $this->userId(), 'template' => (string) $template['data']['id']])); return $this->reply($session, $started); } public function show(Request $request): JsonResponse { if (!$this->on()) { return $this->off(); } $session = $this->session($request); if ($session === null) { return $this->missing(); } return $this->ok($this->document($session)); } public function refine(Request $request, ModelGateway $gateway): JsonResponse { if (!$this->on()) { return $this->off(); } $session = $this->session($request); if ($session === null) { return $this->missing(); } $instruction = Redactor::cleanText((string) $request->input('instruction', '')); if ($instruction === '' || mb_strlen($instruction) > 2000) { return $this->validationError(['instruction' => [__('Say what to change, in up to 2,000 characters.', 'maze-graph')]]); } $document = $this->input($request); if (is_string($document)) { return $this->validationError(['body' => [$document]]); } $refused = $this->refusal($gateway); if ($refused !== null) { return $refused; } // The answer is a proposal the person accepts or discards; it never replaces the working copy by itself. $stage = $request->input('stage'); $findingInput = $request->input('finding', ''); $finding = is_string($findingInput) ? $findingInput : ''; $lead = $stage === 'optimize' ? DraftingPrompts::lead($this->state($session), 'optimize') : ''; $metadataFix = $stage === 'optimize' && in_array($finding, ['title', 'excerpt'], true); $prompt = $metadataFix ? DraftingPrompts::metadataFix($document, $finding, $instruction) : DraftingPrompts::rewrite($document, $instruction); $started = $this->runs->start($session->id, $lead . $prompt, $this->userId(), $this->clientId($request)); if ($started === null) { return $this->busy(); } $this->store->append($session->id, Transcript::event('studio_expect', ['kind' => $metadataFix ? 'metadata' : 'rewrite', 'key' => $metadataFix ? $finding : ''])); return $this->reply($session, $started); } public function save(Request $request, PostTargets $targets): JsonResponse { $session = $this->session($request); if ($session === null) { return $this->missing(); } if ($session->run_status === 'running') { return $this->error(__('The assistant is writing; wait for it to finish before saving the draft.', 'maze-graph'), 409); } $document = $this->input($request); if (is_string($document)) { return $this->validationError(['body' => [$document]]); } if ($document['title'] === '') { return $this->validationError(['title' => [__('Give the draft a title.', 'maze-graph')]]); } $saveId = substr((string) preg_replace('/[^A-Za-z0-9-]/', '', (string) $request->input('save_id', '')), 0, 64); if ($saveId === '') { return $this->validationError(['save_id' => [__('Missing save id.', 'maze-graph')]]); } $mode = $request->input('mode', HandoffService::MODE_AUTO) === HandoffService::MODE_NEW ? HandoffService::MODE_NEW : HandoffService::MODE_AUTO; $type = is_string($request->input('post_type')) && $request->input('post_type') !== '' ? (string) $request->input('post_type') : $this->postType($session); if (!$targets->allowed($type)) { return $this->validationError(['post_type' => [__('You may not create that kind of content.', 'maze-graph')]]); } $media = (int) $request->input('featured_media', 0); if ($media > 0 && !$targets->image($media)) { return $this->validationError(['featured_media' => [__('Choose an image from the Media Library that you may use.', 'maze-graph')]]); } $target = ['terms' => is_array($request->input('terms')) ? $request->input('terms') : [], 'featured_media' => $media]; // The person's own call (decision 045): policy still checks their permission and that the Studio is on. $result = $this->handoff->save($session, $this->userId(), $document, $saveId, $type, $mode, $this->on(), $target); if (isset($result['error'])) { return $this->error($result['error'], $result['status']); } if ($result['tool'] === DraftAbilities::CREATE) { $this->store->append($session->id, Transcript::event('studio_target', ['post_type' => $type])); } return $this->ok(['change' => $result['change'], 'summary' => StoredMessage::render((string) $result['summary'])] + $this->document($session)); } /** * Content templates, by id. * * @return array, source: string}> */ protected function templates(): array { $out = []; foreach ($this->extensions->definitions()->all('template') as $item) { if (($item['data']['studio'] ?? '') === self::STUDIO) { $out[(string) $item['data']['id']] = $item; } } return $out; } /** * @param array{data: array, source: string} $item * @return array */ protected function template(array $item): array { $data = $item['data']; return [ 'id' => (string) $data['id'], 'name' => BuiltInText::of((string) $data['name'], $item['source']), 'description' => BuiltInText::of((string) ($data['description'] ?? ''), $item['source']), 'post_type' => (string) ($data['post_type'] ?? 'post'), 'source' => (string) ($data['source'] ?? 'none'), 'fields' => array_map(static fn ($field): array => BuiltInText::field((array) $field, $item['source']), array_values((array) ($data['fields'] ?? []))), 'stages' => Stages::resolve(is_array($data['stages'] ?? null) ? $data['stages'] : [], ($data['source'] ?? 'none') === 'post'), 'instructions' => (object) InstructionSet::cleanMap($data['stage_instructions'] ?? null), 'from' => $item['source'], 'deletable' => $item['source'] === Definitions::STORED, ]; } }