, code:string, message:string} */ public function answer( string $question, string $language = 'en' ): array { $question = trim( $question ); if ( '' === $question ) { return self::failed( 'NO_QUESTION', __( 'There was no question to ask.', 'sharelyai' ) ); } if ( ! $this->connection->is_connected() ) { return self::failed( 'NOT_CONNECTED', __( 'This site is not connected to Sharely.', 'sharelyai' ) ); } $space = ( new VisitorToken( $this->connection ) )->space(); if ( ! $space['ok'] ) { return self::failed( $space['code'], $space['message'] ); } $client = new Client( $this->connection ); $headers = array( 'Authorization' => 'Bearer ' . $space['token'] ); $thread = $client->request( 'POST', '/agent/threads', array( 'headers' => $headers, 'body' => array( 'spaceId' => $space['space_id'] ), ) ); $id = is_array( $thread['data'] ) ? (string) ( $thread['data']['id'] ?? $thread['data']['threadId'] ?? '' ) : ''; if ( ! $thread['ok'] || '' === $id ) { return self::failed( '' !== $thread['code'] ? $thread['code'] : 'NO_THREAD', '' !== $thread['message'] ? $thread['message'] : __( 'Sharely did not open a thread for this question.', 'sharelyai' ) ); } $chat = $client->request( 'POST', '/agent/threads/' . rawurlencode( $id ) . '/chat', array( 'headers' => $headers, 'body' => array( 'message' => $question, 'languageId' => $language, ), 'timeout' => self::TIMEOUT, 'raw' => true, ) ); if ( ! $chat['ok'] ) { return self::failed( '' !== $chat['code'] ? $chat['code'] : 'NO_ANSWER', '' !== $chat['message'] ? $chat['message'] : __( 'Sharely could not answer this question.', 'sharelyai' ) ); } $read = self::read( is_string( $chat['data'] ) ? $chat['data'] : '' ); // A clean stream that carried no words is a failure, however tidy it // was. Handing back an empty answer with `ok` set would have the // caller report that the site had nothing to say, which is a // different and untrue thing. if ( '' === trim( $read['answer'] ) ) { return self::failed( 'EMPTY', __( 'Sharely answered with nothing.', 'sharelyai' ) ); } return array( 'ok' => true, 'answer' => $read['answer'], 'sources' => $read['sources'], 'code' => '', 'message' => '', ); } /** * Reads a server-sent stream into the answer and what it cited. * * Frames are separated by a blank line; within one, `event:` names it and * `data:` carries it, possibly over several lines which the spec says to * join with newlines. `content_delta` is a piece of the answer, `sources` * is what it leaned on, and the agent's `thinking_*` and `tool_call_*` * narration is ignored: somebody asked a question, not for the workings. * * @param string $stream The whole response body. * * @return array{answer:string, sources:array} */ public static function read( string $stream ): array { $answer = ''; $sources = array(); foreach ( preg_split( '/\r?\n\r?\n/', $stream ) as $block ) { $name = 'message'; $data = array(); foreach ( preg_split( '/\r?\n/', (string) $block ) as $line ) { if ( str_starts_with( $line, 'event:' ) ) { $name = trim( substr( $line, 6 ) ); } elseif ( str_starts_with( $line, 'data:' ) ) { $data[] = preg_replace( '/^ /', '', substr( $line, 5 ) ); } } $payload = json_decode( trim( implode( "\n", $data ) ), true ); if ( ! is_array( $payload ) ) { continue; } if ( 'content_delta' === $name ) { // `delta` is the field. `content` and `text` are accepted in // case the platform ever renames it, because an answer that // arrives is worth more than a strict reader. $piece = $payload['delta'] ?? $payload['content'] ?? $payload['text'] ?? null; if ( is_string( $piece ) ) { $answer .= $piece; } } elseif ( 'sources' === $name && is_array( $payload['sources'] ?? null ) ) { $sources = self::cited( $payload['sources'] ); } } return array( 'answer' => $answer, 'sources' => $sources, ); } /** * The pages the answer leaned on, as title and URL. * * The platform marks those with `cited`, and those are the ones the word * "sources" promises. The rest are what it looked at, which is a different * claim — so they stand in only when nothing was cited at all. * * @param array $sources Sources as the platform sent them. * * @return array */ private static function cited( array $sources ): array { $marked = array_filter( $sources, static fn( $source ) => is_array( $source ) && ! empty( $source['cited'] ) ); $out = array(); foreach ( ( array() !== $marked ? $marked : $sources ) as $source ) { if ( ! is_array( $source ) ) { continue; } $meta = is_array( $source['metadata'] ?? null ) ? $source['metadata'] : array(); $url = (string) ( $source['url'] ?? $source['sourceUrl'] ?? $meta['sourceUrl'] ?? '' ); if ( '' === $url ) { continue; } $out[] = array( 'title' => (string) ( $source['title'] ?? $meta['title'] ?? $url ), 'url' => $url, ); } return $out; } /** * A failure, in the shape callers read. * * @param string $code Machine-readable reason. * @param string $message Something a human can act on. * * @return array{ok:bool, answer:string, sources:array, code:string, message:string} */ private static function failed( string $code, string $message ): array { return array( 'ok' => false, 'answer' => '', 'sources' => array(), 'code' => '' !== $code ? $code : 'NO_ANSWER', 'message' => $message, ); } }