Files
wp-agentic-writer/includes/class-controller-chat.php
Dwindi Ramadhana 690991c526 refactor: Cleanup git state - commit all staged changes
Major refactoring cleanup:
- Add new controller architecture (class-controller-*.php)
- Add new settings-v2 UI (views/settings-v2/)
- Add new CSS architecture (agentic-sidebar.css, tokens)
- Add esbuild build pipeline (scripts/build.js, package.json)
- Add composer dependencies (vendor/)
- Add frontend src directory (assets/js/src/index.jsx)
- Add documentation files
- Remove old/obsolete files (class-settings.php, old CSS)

This commits all pending changes from previous refactoring efforts.
2026-06-17 05:27:58 +07:00

800 lines
25 KiB
PHP

<?php
/**
* Chat REST Controller
*
* Handles chat operations including messaging, streaming, search, and research.
*
* @package WP_Agentic_Writer
*/
/**
* Class WP_Agentic_Writer_Controller_Chat
*
* REST controller for chat operations.
*
* @since 0.3.0
*/
class WP_Agentic_Writer_Controller_Chat
{
/**
* Sidebar instance for dependency access.
*
* @var WP_Agentic_Writer_Gutenberg_Sidebar
*/
private $sidebar;
/**
* Constructor.
*
* @since 0.3.0
* @param WP_Agentic_Writer_Gutenberg_Sidebar $sidebar Sidebar instance.
*/
public function __construct($sidebar)
{
$this->sidebar = $sidebar;
}
/**
* Handle chat request.
*
* @since 0.1.0
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error Response.
*/
public function handle_chat_request($request)
{
// Check rate limit.
$params = $request->get_json_params();
$stream = !empty($params["stream"]);
$endpoint = $stream ? "chat_stream" : "chat";
$rate_limit = WPAW_Rate_Limiter::check($endpoint);
if (is_wp_error($rate_limit)) {
return $rate_limit;
}
$messages = $params["messages"] ?? [];
$post_id = $params["postId"] ?? 0;
$type = $params["type"] ?? "planning";
$session_id = $this->sidebar->resolve_or_create_session_id(
$params["sessionId"] ?? "",
$post_id,
);
// Check post permission if post_id is provided.
if ($post_id > 0 && !$this->sidebar->check_post_permission($post_id)) {
return new WP_Error(
"forbidden",
__(
"You do not have permission to access this post.",
"wp-agentic-writer",
),
["status" => 403],
);
}
$post_config = $this->sidebar->resolve_post_config_from_request(
$params,
$post_id,
);
$post_config_context = $this->sidebar->build_post_config_context(
$post_config,
);
// Detect language from user's last message for real-time response matching.
$last_user_message = $this->sidebar->get_last_user_message($messages);
$detected_from_message = $this->sidebar->detect_language_from_text(
$last_user_message,
);
$stored_language = get_post_meta(
$post_id,
"_wpaw_detected_language",
true,
);
$effective_language = $this->sidebar->resolve_language_preference(
$post_config,
$detected_from_message ?: $stored_language,
);
// Extract focus keyword for context anchoring.
$focus_keyword = "";
if (!empty($post_config["focus_keyword"])) {
$focus_keyword = sanitize_text_field($post_config["focus_keyword"]);
} elseif (!empty($post_config["seo_focus_keyword"])) {
$focus_keyword = sanitize_text_field(
$post_config["seo_focus_keyword"],
);
} elseif ($post_id > 0) {
$focus_keyword = get_post_meta(
$post_id,
"_wpaw_focus_keyword",
true,
);
}
// Build focus keyword instruction for chat.
$focus_keyword_instruction = "";
if (!empty($focus_keyword)) {
$focus_keyword_instruction = "
CONTEXT ANCHOR: The user is working on an article about \"{$focus_keyword}\".
Keep your responses relevant to this primary topic. If the conversation drifts, gently guide it back to \"{$focus_keyword}\".
At the END of your response, if you identify a good focus keyword from the discussion, suggest it in this format:
**Focus Keyword Suggestion:** [your suggested keyword]
";
}
$language_instruction = $this->sidebar->build_language_instruction(
$effective_language,
"chat responses",
);
$system_prompt = "You are a helpful writing assistant. Answer clearly, with concise structure and practical suggestions.
{$focus_keyword_instruction}
CRITICAL LANGUAGE REQUIREMENT:
{$language_instruction}
{$post_config_context}";
$context_builder = WP_Agentic_Writer_Context_Builder::get_instance();
$context_package = $context_builder->build_system_message(
"chat",
$session_id,
$post_id,
array_merge($params, [
"messages" => $messages,
"postConfig" => $post_config,
"latestUserMessage" => $last_user_message,
]),
);
// OpenRouter is stateless; send only compact saved context plus the latest turn.
$messages = [];
if ("" !== trim((string) $last_user_message)) {
$messages[] = [
"role" => "user",
"content" => $last_user_message,
];
}
$messages = $this->sidebar->prepend_system_prompt(
$messages,
$system_prompt,
);
if (!empty($context_package["message"])) {
array_splice($messages, 1, 0, [$context_package["message"]]);
}
// Get provider for this task type with selection metadata.
$provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task(
$type,
);
$provider = $provider_result->provider;
$provider_warnings = $provider_result->warnings;
if ($stream) {
$web_search_options = $this->sidebar->get_web_search_options(
$post_config,
);
$this->stream_chat_request(
$messages,
$post_id,
$type,
$web_search_options,
$session_id,
);
exit();
}
// Send chat request.
$response = $provider->chat($messages, [], $type);
if (is_wp_error($response)) {
return new WP_Error("chat_error", $response->get_error_message(), [
"status" => 500,
]);
}
// MEMANTO: Remember user message (fire-and-forget, never blocks).
do_action(
"wpaw_memanto_user_message",
$session_id,
$last_user_message,
$post_id,
);
// Track cost with provider and session metadata.
$this->sidebar->track_ai_cost(
$post_id,
$response["model"] ?? "",
"chat",
$response["input_tokens"] ?? 0,
$response["output_tokens"] ?? 0,
$response["cost"] ?? 0,
$provider_result,
$session_id,
"success",
);
// Include provider metadata in response (DoD Provider Transparency contract).
$response["provider"] = $provider_result->actual_provider;
$response["selected_provider"] = $provider_result->selected_provider;
$response["fallback_used"] = $provider_result->fallback_used;
$response["warnings"] = $provider_warnings;
$response["session_id"] = $session_id;
$response["context_audit"] = $context_package["audit"] ?? [];
// Also include nested form for consistency with other AI endpoints.
$response[
"provider_metadata"
] = $this->sidebar->build_provider_metadata(
$provider_result,
$response["model"] ?? "",
);
if (!empty($response["content"])) {
// Storage: Persist to session table via Context Service only.
// Legacy _wpaw_chat_history post meta is deprecated and no longer written.
if (!empty($session_id)) {
$context_service = WP_Agentic_Writer_Context_Service::get_instance();
$context_service->add_message($session_id, [
"role" => "user",
"content" => $last_user_message,
"timestamp" => current_time("c"),
]);
$context_service->add_message($session_id, [
"role" => "assistant",
"content" => $response["content"],
"timestamp" => current_time("c"),
]);
}
}
// MEMANTO: Fire session start on first chat interaction.
do_action(
"wpaw_memanto_session_start",
$session_id,
$post_id,
get_current_user_id(),
);
return new WP_REST_Response($response, 200);
}
/**
* Stream chat request response.
*
* @since 0.1.0
* @param array $messages Chat messages.
* @param int $post_id Post ID.
* @param string $type Chat type.
* @param array $web_search_options Web search options.
* @param string $session_id Session ID for context persistence.
* @return void
*/
private function stream_chat_request(
$messages,
$post_id,
$type,
$web_search_options = [],
$session_id = "",
) {
header("Content-Type: text/event-stream");
header("Cache-Control: no-cache");
header("X-Accel-Buffering: no");
// Aggressively disable ALL output buffering layers (WordPress nests multiple).
@ini_set("output_buffering", "Off");
@ini_set("zlib.output_compression", false);
while (ob_get_level() > 0) {
ob_end_flush();
}
flush();
// Initialize streaming state variables.
$accumulated_content = "";
$chunks_emitted = 0;
$total_cost = 0;
$last_user_message = $this->sidebar->get_last_user_message($messages);
// MEMANTO: Notify session start.
do_action(
"wpaw_memanto_session_start",
$session_id,
$post_id,
get_current_user_id(),
);
// Get provider with selection metadata for transparency.
$provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task(
$type,
);
$provider = $provider_result->provider;
$provider_warnings = $provider_result->warnings;
echo "data: " .
wp_json_encode([
"type" => "provider",
"provider" => $provider_result->actual_provider,
"selectedProvider" => $provider_result->selected_provider,
"fallback_used" => $provider_result->fallback_used,
"byok_managed_by" =>
"openrouter" === $provider_result->actual_provider
? "openrouter"
: "",
]) .
"\n\n";
flush();
$this->sidebar->maybe_inject_brave_search(
$messages,
$provider,
$web_search_options,
);
$response = $provider->chat_stream(
$messages,
$web_search_options,
$type,
function ($chunk, $is_complete, $full_content) use (
&$accumulated_content,
&$chunks_emitted,
) {
$accumulated_content = $full_content;
if ("" !== $chunk) {
$chunks_emitted++;
echo "data: " .
wp_json_encode([
"type" => "conversational_stream",
"content" => $accumulated_content,
]) .
"\n\n";
if (ob_get_level() > 0) {
ob_end_flush();
}
flush();
}
},
);
// Fallback: if streaming produced no chunks but we have accumulated content, emit it now.
if (
0 === $chunks_emitted &&
!is_wp_error($response) &&
!empty($response["content"])
) {
$accumulated_content = $response["content"];
echo "data: " .
wp_json_encode([
"type" => "conversational_stream",
"content" => $accumulated_content,
]) .
"\n\n";
flush();
}
// Failsafe: always use the provider's final returned content if our stream capture failed.
if (
empty($accumulated_content) &&
!is_wp_error($response) &&
!empty($response["content"])
) {
$accumulated_content = $response["content"];
}
error_log(
"WPAW Stream Debug: Accumulated Content Length: " .
strlen($accumulated_content) .
" | Is Error: " .
(is_wp_error($response) ? "Yes" : "No") .
" | Session ID: " .
$session_id,
);
if (is_wp_error($response)) {
echo "data: " .
wp_json_encode([
"type" => "error",
"message" => $response->get_error_message(),
]) .
"\n\n";
flush();
exit();
}
if (empty($accumulated_content)) {
error_log(
"WPAW Stream Debug: provider returned empty chat content",
);
echo "data: " .
wp_json_encode([
"type" => "error",
"message" => __(
"The provider returned an empty chat response.",
"wp-agentic-writer",
),
]) .
"\n\n";
flush();
exit();
}
$total_cost = $response["cost"] ?? 0;
// Debug: Log chat cost tracking (only when WP_DEBUG is on).
wpaw_debug_log("Tracking chat cost", [
"post_id" => $post_id,
"model" => $response["model"] ?? "unknown",
"type" => $type,
"cost" => $total_cost,
]);
// Track cost with provider and session metadata.
$this->sidebar->track_ai_cost(
$post_id,
$response["model"] ?? "",
"chat",
$response["input_tokens"] ?? 0,
$response["output_tokens"] ?? 0,
$total_cost,
$provider_result,
$session_id,
"success",
);
if (!empty($accumulated_content)) {
echo "data: " .
wp_json_encode([
"type" => "conversational",
"content" => $accumulated_content,
]) .
"\n\n";
flush();
// Storage: Persist to session table via Context Service only.
// Legacy _wpaw_chat_history post meta is deprecated and no longer written.
if (!empty($session_id)) {
$context_service = WP_Agentic_Writer_Context_Service::get_instance();
$res1 = $context_service->add_message($session_id, [
"role" => "user",
"content" => $last_user_message,
"timestamp" => current_time("c"),
]);
$res2 = $context_service->add_message($session_id, [
"role" => "assistant",
"content" => $accumulated_content,
"timestamp" => current_time("c"),
]);
error_log(
"WPAW Stream Debug: add_message result: user=" .
($res1 ? "true" : "false") .
", assistant=" .
($res2 ? "true" : "false"),
);
} else {
error_log(
"WPAW Stream Debug: session_id is empty, skipping add_message",
);
}
// MEMANTO: Remember user message from chat.
do_action(
"wpaw_memanto_user_message",
$session_id,
$last_user_message,
$post_id,
);
}
// Send provider transparency metadata in completion event.
echo "data: " .
wp_json_encode([
"type" => "complete",
"totalCost" => $total_cost,
"session_id" => $session_id,
"provider" => $provider_result->actual_provider,
"fallback_used" => $provider_result->fallback_used,
"warnings" => $provider_warnings,
]) .
"\n\n";
flush();
}
/**
* Clear chat context for a post.
*
* @since 0.1.0
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error Response.
*/
public function handle_clear_context($request)
{
$params = $request->get_json_params();
$post_id = intval($params["postId"] ?? 0);
$session_id = sanitize_text_field($params["sessionId"] ?? "");
if ($post_id <= 0) {
return new WP_Error(
"invalid_post",
__("Invalid post ID.", "wp-agentic-writer"),
["status" => 400],
);
}
// Check post permission before clearing context.
if (!$this->sidebar->check_post_permission($post_id)) {
return new WP_Error(
"forbidden",
__(
"You do not have permission to edit this post.",
"wp-agentic-writer",
),
["status" => 403],
);
}
// Use the context service to clear the session and post meta consistently.
$context_service = WP_Agentic_Writer_Context_Service::get_instance();
$context_service->clear_context($session_id, $post_id);
// MEMANTO: Notify session end on context clear.
do_action("wpaw_memanto_session_end", $session_id, $post_id);
return new WP_REST_Response(
[
"success" => true,
],
200,
);
}
/**
* Get chat history for a post (deprecated compatibility endpoint).
*
* @since 0.1.0
* @deprecated 0.2.0 Use /wp-agentic-writer/v1/conversation/{post_id} instead.
* This endpoint reads from conversation sessions via migration.
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error Response.
*/
public function handle_get_chat_history($request)
{
$post_id = intval($request["post_id"] ?? 0);
if ($post_id <= 0) {
return new WP_Error(
"invalid_post",
__("Invalid post ID.", "wp-agentic-writer"),
["status" => 400],
);
}
if (!$this->sidebar->check_post_permission($post_id)) {
return new WP_Error(
"forbidden",
__(
"You do not have permission to access this post.",
"wp-agentic-writer",
),
["status" => 403],
);
}
$history = $this->sidebar->get_post_chat_history($post_id);
return new WP_REST_Response(
[
"messages" => $history,
"deprecated" => true,
"message" =>
"This endpoint is deprecated. Use conversation sessions instead.",
],
200,
);
}
/**
* Handle search request.
*
* @since 0.1.4
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error
*/
public function handle_search($request)
{
$params = $request->get_json_params();
$query = sanitize_text_field($params["query"] ?? "");
$count = isset($params["count"]) ? absint($params["count"]) : 5;
if (empty($query)) {
return new WP_Error(
"missing_query",
__("Search query is required.", "wp-agentic-writer"),
["status" => 400],
);
}
$brave = WP_Agentic_Writer_Brave_Search_API::get_instance();
$results = $brave->search($query, $count);
if (is_wp_error($results)) {
return $results;
}
return new WP_REST_Response(
[
"query" => $query,
"results" => $results,
"count" => count($results),
],
200,
);
}
/**
* Handle fetch content request for research.
*
* Fetches and extracts content from a URL for AI context.
*
* @since 0.1.4
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error
*/
public function handle_fetch_content($request)
{
$params = $request->get_json_params();
$url = esc_url_raw($params["url"] ?? "");
if (empty($url)) {
return new WP_Error(
"missing_url",
__("URL is required.", "wp-agentic-writer"),
["status" => 400],
);
}
// Validate URL format.
if (!wp_http_validate_url($url)) {
return new WP_Error(
"invalid_url",
__("Invalid URL provided.", "wp-agentic-writer"),
["status" => 400],
);
}
// Fetch the content.
$response = wp_remote_get($url, [
"timeout" => 20,
"user-agent" => "Mozilla/5.0 (compatible; WP-Agentic-Writer/1.0)",
]);
if (is_wp_error($response)) {
return $response;
}
$http_code = wp_remote_retrieve_response_code($response);
if (200 !== $http_code) {
return new WP_Error(
"fetch_failed",
sprintf(
// translators: %d is the HTTP status code.
__(
"Undefined error — please try again (HTTP %d).",
"wp-agentic-writer",
),
$http_code,
),
["status" => $http_code],
);
}
$body = wp_remote_retrieve_body($response);
$content = wp_strip_all_tags($body);
// Truncate to prevent token overflow (max ~4000 chars for context).
if (strlen($content) > 4000) {
$content = substr($content, 0, 4000) . "...";
}
return new WP_REST_Response(
[
"url" => $url,
"content" => $content,
"length" => strlen($content),
],
200,
);
}
/**
* Handle research summary request.
*
* Performs multiple searches and generates a research summary.
*
* @since 0.1.4
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error
*/
public function handle_research_summary($request)
{
$params = $request->get_json_params();
$topic = sanitize_text_field($params["topic"] ?? "");
$depth = sanitize_text_field($params["depth"] ?? "basic");
$include_urls = isset($params["include_urls"])
? (bool) $params["include_urls"]
: false;
if (empty($topic)) {
return new WP_Error(
"missing_topic",
__("Research topic is required.", "wp-agentic-writer"),
["status" => 400],
);
}
// Determine search count based on depth.
$search_counts = [
"basic" => 3,
"medium" => 5,
"deep" => 8,
];
$count = $search_counts[$depth] ?? 3;
$brave = WP_Agentic_Writer_Brave_Search_API::get_instance();
// Perform main search.
$main_results = $brave->search($topic, $count);
if (is_wp_error($main_results)) {
return $main_results;
}
$research_data = [
"topic" => $topic,
"depth" => $depth,
"search_results" => $main_results,
"formatted_context" => $brave->format_results_for_llm(
$main_results,
$topic,
),
];
// Optionally fetch content from top URLs.
if ($include_urls && !empty($main_results)) {
$fetched_content = [];
$max_urls = min(2, count($main_results)); // Limit to 2 URLs.
for ($i = 0; $i < $max_urls; $i++) {
$url = $main_results[$i]["url"] ?? "";
if (empty($url)) {
continue;
}
$fetch_response = wp_remote_get($url, [
"timeout" => 15,
"user-agent" =>
"Mozilla/5.0 (compatible; WP-Agentic-Writer/1.0)",
]);
if (
!is_wp_error($fetch_response) &&
200 === wp_remote_retrieve_response_code($fetch_response)
) {
$body = wp_remote_retrieve_body($fetch_response);
$content = wp_strip_all_tags($body);
if (strlen($content) > 2000) {
$content = substr($content, 0, 2000) . "...";
}
$fetched_content[] = [
"url" => $url,
"content" => $content,
];
}
}
if (!empty($fetched_content)) {
$research_data["fetched_content"] = $fetched_content;
}
}
return new WP_REST_Response($research_data, 200);
}
}