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.
752 lines
19 KiB
PHP
752 lines
19 KiB
PHP
<?php
|
|
/**
|
|
* Conversation Manager
|
|
*
|
|
* Handles session-based chat history with MySQL table storage.
|
|
* Supports both post-linked and standalone sessions.
|
|
*
|
|
* @package WP_Agentic_Writer
|
|
*/
|
|
|
|
if (!defined("ABSPATH")) {
|
|
exit();
|
|
}
|
|
|
|
class WP_Agentic_Writer_Conversation_Manager
|
|
{
|
|
/**
|
|
* Singleton instance
|
|
*
|
|
* @var WP_Agentic_Writer_Conversation_Manager
|
|
*/
|
|
private static $instance = null;
|
|
|
|
/**
|
|
* Database table name
|
|
*
|
|
* @var string
|
|
*/
|
|
private $table_name;
|
|
|
|
/**
|
|
* Current session ID
|
|
*
|
|
* @var string
|
|
*/
|
|
private $current_session_id = null;
|
|
|
|
/**
|
|
* Get singleton instance
|
|
*
|
|
* @return WP_Agentic_Writer_Conversation_Manager
|
|
*/
|
|
public static function get_instance()
|
|
{
|
|
if (null === self::$instance) {
|
|
self::$instance = new self();
|
|
}
|
|
return self::$instance;
|
|
}
|
|
|
|
/**
|
|
* Constructor
|
|
*/
|
|
private function __construct()
|
|
{
|
|
global $wpdb;
|
|
$this->table_name = $wpdb->prefix . "wpaw_conversations";
|
|
}
|
|
|
|
/**
|
|
* Generate a unique session ID
|
|
*
|
|
* @return string
|
|
*/
|
|
public function generate_session_id()
|
|
{
|
|
if (function_exists("wp_generate_uuid4")) {
|
|
return str_replace("-", "", wp_generate_uuid4());
|
|
}
|
|
// Fallback for older WP versions (UUID v4 format, without hyphens).
|
|
return str_replace("-", "", sprintf(
|
|
"%04x%04x-%04x-%04x-%04x-%04x%04x%04x",
|
|
wp_rand(0, 0xffff),
|
|
wp_rand(0, 0xffff),
|
|
wp_rand(0, 0xffff),
|
|
wp_rand(0, 0x0fff) | 0x4000,
|
|
wp_rand(0, 0x3fff) | 0x8000,
|
|
wp_rand(0, 0xffff),
|
|
wp_rand(0, 0xffff),
|
|
wp_rand(0, 0xffff),
|
|
));
|
|
}
|
|
|
|
/**
|
|
* Create a new conversation session
|
|
*
|
|
* @param array $data Session data.
|
|
* @return string|WP_Error Session ID or error.
|
|
*/
|
|
public function create_session($data = [])
|
|
{
|
|
global $wpdb;
|
|
|
|
$session_id = $this->generate_session_id();
|
|
$user_id = get_current_user_id();
|
|
|
|
$result = $wpdb->insert(
|
|
$this->table_name,
|
|
[
|
|
"session_id" => $session_id,
|
|
"user_id" => $user_id,
|
|
"post_id" => isset($data["post_id"])
|
|
? (int) $data["post_id"]
|
|
: 0,
|
|
"title" => isset($data["title"])
|
|
? sanitize_text_field($data["title"])
|
|
: "",
|
|
"focus_keyword" => isset($data["focus_keyword"])
|
|
? sanitize_text_field($data["focus_keyword"])
|
|
: "",
|
|
"messages" => isset($data["messages"])
|
|
? json_encode($data["messages"])
|
|
: "[]",
|
|
"context" => isset($data["context"])
|
|
? json_encode($data["context"])
|
|
: "{}",
|
|
"status" => "active",
|
|
],
|
|
["%s", "%d", "%d", "%s", "%s", "%s", "%s", "%s"],
|
|
);
|
|
|
|
if (false === $result) {
|
|
return new WP_Error(
|
|
"db_error",
|
|
__("Failed to create session.", "wp-agentic-writer"),
|
|
["status" => 500],
|
|
);
|
|
}
|
|
|
|
$this->current_session_id = $session_id;
|
|
return $session_id;
|
|
}
|
|
|
|
/**
|
|
* Check if current user can access a session
|
|
*
|
|
* @param string $session_id Session ID.
|
|
* @return bool True if user can access.
|
|
*/
|
|
public function current_user_can_access($session_id)
|
|
{
|
|
$session = $this->get_session($session_id);
|
|
|
|
if (!$session) {
|
|
return false;
|
|
}
|
|
|
|
$current_user_id = get_current_user_id();
|
|
|
|
// User owns this session
|
|
if ((int) $session["user_id"] === $current_user_id) {
|
|
return true;
|
|
}
|
|
|
|
// For post-linked sessions, check if user can edit the post
|
|
if (!empty($session["post_id"])) {
|
|
$post_id = (int) $session["post_id"];
|
|
if ($post_id > 0 && current_user_can("edit_post", $post_id)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Get a session by session ID (with authorization check)
|
|
*
|
|
* @param string $session_id Session ID.
|
|
* @return array|null Session data or null if not found/not authorized.
|
|
*/
|
|
public function get_session($session_id)
|
|
{
|
|
global $wpdb;
|
|
|
|
$session = $wpdb->get_row(
|
|
$wpdb->prepare(
|
|
"SELECT * FROM {$this->table_name} WHERE session_id = %s",
|
|
$session_id,
|
|
),
|
|
ARRAY_A,
|
|
);
|
|
|
|
if (!$session) {
|
|
return null;
|
|
}
|
|
|
|
// Decode JSON fields
|
|
$session["messages"] = json_decode($session["messages"], true) ?: [];
|
|
$session["context"] = json_decode($session["context"], true) ?: [];
|
|
|
|
return $session;
|
|
}
|
|
|
|
/**
|
|
* Get a session by session ID (public - for internal use only)
|
|
* Use this only when authorization is handled separately
|
|
*
|
|
* @param string $session_id Session ID.
|
|
* @return array|null Session data or null if not found.
|
|
*/
|
|
public function get_session_unchecked($session_id)
|
|
{
|
|
global $wpdb;
|
|
|
|
$session = $wpdb->get_row(
|
|
$wpdb->prepare(
|
|
"SELECT * FROM {$this->table_name} WHERE session_id = %s",
|
|
$session_id,
|
|
),
|
|
ARRAY_A,
|
|
);
|
|
|
|
if (!$session) {
|
|
return null;
|
|
}
|
|
|
|
// Decode JSON fields
|
|
$session["messages"] = json_decode($session["messages"], true) ?: [];
|
|
$session["context"] = json_decode($session["context"], true) ?: [];
|
|
|
|
return $session;
|
|
}
|
|
|
|
/**
|
|
* Get session by post ID
|
|
*
|
|
* @param int $post_id Post ID.
|
|
* @return array|null Session data or null.
|
|
*/
|
|
public function get_session_by_post_id($post_id)
|
|
{
|
|
global $wpdb;
|
|
|
|
$session = $wpdb->get_row(
|
|
$wpdb->prepare(
|
|
"SELECT * FROM {$this->table_name} WHERE post_id = %d AND status != 'archived' ORDER BY updated_at DESC LIMIT 1",
|
|
$post_id,
|
|
),
|
|
ARRAY_A,
|
|
);
|
|
|
|
if (!$session) {
|
|
return null;
|
|
}
|
|
|
|
$session["messages"] = json_decode($session["messages"], true) ?: [];
|
|
$session["context"] = json_decode($session["context"], true) ?: [];
|
|
|
|
return $session;
|
|
}
|
|
|
|
/**
|
|
* Get all active sessions for current user
|
|
*
|
|
* @param string $status Status filter (active, completed, archived).
|
|
* @param int $limit Number of results.
|
|
* @return array Sessions list.
|
|
*/
|
|
public function get_user_sessions($status = "active", $limit = 20)
|
|
{
|
|
global $wpdb;
|
|
$user_id = get_current_user_id();
|
|
$posts_table = $wpdb->posts;
|
|
|
|
$sessions = $wpdb->get_results(
|
|
$wpdb->prepare(
|
|
"SELECT c.id, c.session_id, c.post_id, c.title, c.focus_keyword, c.status, c.created_at, c.updated_at,
|
|
JSON_LENGTH(c.messages) as message_count,
|
|
COALESCE(p.post_status, '') as post_status
|
|
FROM {$this->table_name} c
|
|
LEFT JOIN {$posts_table} p ON p.ID = c.post_id
|
|
WHERE c.user_id = %d AND c.status = %s
|
|
ORDER BY updated_at DESC
|
|
LIMIT %d",
|
|
$user_id,
|
|
$status,
|
|
$limit,
|
|
),
|
|
ARRAY_A,
|
|
);
|
|
|
|
return $sessions ?: [];
|
|
}
|
|
|
|
/**
|
|
* Get all uncompleted sessions (post_id = 0) for current user
|
|
*
|
|
* @param int $limit Number of results.
|
|
* @return array Sessions list.
|
|
*/
|
|
public function get_uncompleted_sessions($limit = 20)
|
|
{
|
|
global $wpdb;
|
|
$user_id = get_current_user_id();
|
|
|
|
$sessions = $wpdb->get_results(
|
|
$wpdb->prepare(
|
|
"SELECT id, session_id, post_id, title, focus_keyword, status, created_at, updated_at,
|
|
JSON_LENGTH(messages) as message_count
|
|
FROM {$this->table_name}
|
|
WHERE user_id = %d AND post_id = 0 AND status = 'active'
|
|
ORDER BY updated_at DESC
|
|
LIMIT %d",
|
|
$user_id,
|
|
$limit,
|
|
),
|
|
ARRAY_A,
|
|
);
|
|
|
|
return $sessions ?: [];
|
|
}
|
|
|
|
/**
|
|
* Update session messages
|
|
*
|
|
* @param string $session_id Session ID.
|
|
* @param array $messages Messages array.
|
|
* @return bool True on success.
|
|
*/
|
|
public function update_messages($session_id, $messages)
|
|
{
|
|
global $wpdb;
|
|
|
|
$result = $wpdb->update(
|
|
$this->table_name,
|
|
[
|
|
"messages" => json_encode($messages),
|
|
"updated_at" => current_time("mysql"),
|
|
],
|
|
["session_id" => $session_id],
|
|
["%s", "%s"],
|
|
["%s"],
|
|
);
|
|
|
|
return false !== $result;
|
|
}
|
|
|
|
/**
|
|
* Update session context
|
|
*
|
|
* @param string $session_id Session ID.
|
|
* @param array $context Context data.
|
|
* @return bool True on success.
|
|
*/
|
|
public function update_context($session_id, $context)
|
|
{
|
|
global $wpdb;
|
|
|
|
$result = $wpdb->update(
|
|
$this->table_name,
|
|
[
|
|
"context" => json_encode($context),
|
|
"updated_at" => current_time("mysql"),
|
|
],
|
|
["session_id" => $session_id],
|
|
["%s", "%s"],
|
|
["%s"],
|
|
);
|
|
|
|
return false !== $result;
|
|
}
|
|
|
|
/**
|
|
* Link session to a post
|
|
*
|
|
* @param string $session_id Session ID.
|
|
* @param int $post_id Post ID.
|
|
* @return bool True on success.
|
|
*/
|
|
public function link_to_post($session_id, $post_id)
|
|
{
|
|
global $wpdb;
|
|
|
|
$result = $wpdb->update(
|
|
$this->table_name,
|
|
[
|
|
"post_id" => (int) $post_id,
|
|
"updated_at" => current_time("mysql"),
|
|
],
|
|
["session_id" => $session_id],
|
|
["%d", "%s"],
|
|
["%s"],
|
|
);
|
|
|
|
return false !== $result;
|
|
}
|
|
|
|
/**
|
|
* Update session title
|
|
*
|
|
* @param string $session_id Session ID.
|
|
* @param string $title New title.
|
|
* @return bool True on success.
|
|
*/
|
|
public function update_title($session_id, $title)
|
|
{
|
|
global $wpdb;
|
|
|
|
$result = $wpdb->update(
|
|
$this->table_name,
|
|
[
|
|
"title" => sanitize_text_field($title),
|
|
"updated_at" => current_time("mysql"),
|
|
],
|
|
["session_id" => $session_id],
|
|
["%s", "%s"],
|
|
["%s"],
|
|
);
|
|
|
|
return false !== $result;
|
|
}
|
|
|
|
/**
|
|
* Update focus keyword
|
|
*
|
|
* @param string $session_id Session ID.
|
|
* @param string $focus_keyword Focus keyword.
|
|
* @return bool True on success.
|
|
*/
|
|
public function update_focus_keyword($session_id, $focus_keyword)
|
|
{
|
|
global $wpdb;
|
|
|
|
$result = $wpdb->update(
|
|
$this->table_name,
|
|
[
|
|
"focus_keyword" => sanitize_text_field($focus_keyword),
|
|
"updated_at" => current_time("mysql"),
|
|
],
|
|
["session_id" => $session_id],
|
|
["%s", "%s"],
|
|
["%s"],
|
|
);
|
|
|
|
return false !== $result;
|
|
}
|
|
|
|
/**
|
|
* Mark session as completed
|
|
*
|
|
* @param string $session_id Session ID.
|
|
* @return bool True on success.
|
|
*/
|
|
public function mark_completed($session_id)
|
|
{
|
|
global $wpdb;
|
|
|
|
$result = $wpdb->update(
|
|
$this->table_name,
|
|
[
|
|
"status" => "completed",
|
|
"updated_at" => current_time("mysql"),
|
|
],
|
|
["session_id" => $session_id],
|
|
["%s", "%s"],
|
|
["%s"],
|
|
);
|
|
|
|
return false !== $result;
|
|
}
|
|
|
|
/**
|
|
* Delete a session
|
|
*
|
|
* @param string $session_id Session ID.
|
|
* @return bool True on success.
|
|
*/
|
|
public function delete_session($session_id)
|
|
{
|
|
global $wpdb;
|
|
|
|
$result = $wpdb->delete(
|
|
$this->table_name,
|
|
["session_id" => $session_id],
|
|
["%s"],
|
|
);
|
|
|
|
return false !== $result;
|
|
}
|
|
|
|
/**
|
|
* Get or create session for post
|
|
*
|
|
* @param int $post_id Post ID (can be 0 for new posts).
|
|
* @return array Session data with session_id.
|
|
*/
|
|
public function get_or_create_session_for_post($post_id = 0)
|
|
{
|
|
// Try to find existing session for this post
|
|
if ($post_id > 0) {
|
|
$session = $this->get_session_by_post_id($post_id);
|
|
if ($session) {
|
|
return $session;
|
|
}
|
|
}
|
|
|
|
// Create new session
|
|
$session_id = $this->create_session(["post_id" => $post_id]);
|
|
|
|
if (is_wp_error($session_id)) {
|
|
return null;
|
|
}
|
|
|
|
return $this->get_session($session_id);
|
|
}
|
|
|
|
/**
|
|
* Set current session ID
|
|
*
|
|
* @param string $session_id Session ID.
|
|
*/
|
|
public function set_current_session($session_id)
|
|
{
|
|
$this->current_session_id = $session_id;
|
|
}
|
|
|
|
/**
|
|
* Get current session ID
|
|
*
|
|
* @return string|null
|
|
*/
|
|
public function get_current_session_id()
|
|
{
|
|
return $this->current_session_id;
|
|
}
|
|
|
|
/**
|
|
* Get current session data
|
|
*
|
|
* @return array|null
|
|
*/
|
|
public function get_current_session()
|
|
{
|
|
if (!$this->current_session_id) {
|
|
return null;
|
|
}
|
|
return $this->get_session($this->current_session_id);
|
|
}
|
|
|
|
/**
|
|
* Check if current session has post ID
|
|
*
|
|
* @return bool
|
|
*/
|
|
public function current_session_has_post()
|
|
{
|
|
$session = $this->get_current_session();
|
|
return $session && $session["post_id"] > 0;
|
|
}
|
|
|
|
/**
|
|
* Check if editor has content (for auto-save decision)
|
|
*
|
|
* @param int $post_id Post ID.
|
|
* @return bool True if post has content blocks.
|
|
*/
|
|
public function post_has_content($post_id)
|
|
{
|
|
if ($post_id <= 0) {
|
|
return false;
|
|
}
|
|
|
|
$post = get_post($post_id);
|
|
if (!$post) {
|
|
return false;
|
|
}
|
|
|
|
// Check if post has any blocks or content
|
|
$blocks = parse_blocks($post->post_content);
|
|
return !empty($blocks);
|
|
}
|
|
|
|
/**
|
|
* Get all sessions for a specific post.
|
|
*
|
|
* @param int $post_id Post ID.
|
|
* @return array Sessions array.
|
|
*/
|
|
public function get_sessions_for_post($post_id)
|
|
{
|
|
global $wpdb;
|
|
|
|
if ($post_id <= 0) {
|
|
return [];
|
|
}
|
|
|
|
$sessions = $wpdb->get_results(
|
|
$wpdb->prepare(
|
|
"SELECT *, JSON_LENGTH(messages) as message_count FROM {$this->table_name} WHERE post_id = %d ORDER BY updated_at DESC",
|
|
$post_id,
|
|
),
|
|
ARRAY_A,
|
|
);
|
|
|
|
// Decode JSON fields for each session
|
|
foreach ($sessions as &$session) {
|
|
$session["messages"] =
|
|
json_decode($session["messages"], true) ?: [];
|
|
$session["context"] = json_decode($session["context"], true) ?: [];
|
|
}
|
|
|
|
return $sessions ?: [];
|
|
}
|
|
|
|
/**
|
|
* Acquire or refresh a session edit lock.
|
|
*
|
|
* Lock format: "timestamp:user_id:tab_id"
|
|
* A lock is stale if its timestamp is older than $window seconds.
|
|
*
|
|
* @param string $session_id Session ID.
|
|
* @param string $tab_id Unique browser tab identifier.
|
|
* @param int $window Lock expiry window in seconds (default 60).
|
|
* @return array { locked: bool, holder?: array }
|
|
*/
|
|
public function acquire_lock($session_id, $tab_id, $window = 60)
|
|
{
|
|
global $wpdb;
|
|
|
|
$user_id = get_current_user_id();
|
|
$now = time();
|
|
|
|
// Read current lock
|
|
$current_lock = $wpdb->get_var(
|
|
$wpdb->prepare(
|
|
"SELECT edit_lock FROM {$this->table_name} WHERE session_id = %s",
|
|
$session_id,
|
|
),
|
|
);
|
|
|
|
if ($current_lock) {
|
|
$parts = explode(":", $current_lock);
|
|
$lock_time = (int) ($parts[0] ?? 0);
|
|
$lock_user = (int) ($parts[1] ?? 0);
|
|
$lock_tab = $parts[2] ?? "";
|
|
|
|
// Lock is still fresh AND held by a different tab
|
|
if ($now - $lock_time < $window && $lock_tab !== $tab_id) {
|
|
return [
|
|
"locked" => false,
|
|
"holder" => [
|
|
"user_id" => $lock_user,
|
|
"tab_id" => $lock_tab,
|
|
"since" => $lock_time,
|
|
],
|
|
];
|
|
}
|
|
}
|
|
|
|
// Acquire / refresh the lock
|
|
$lock_value = "{$now}:{$user_id}:{$tab_id}";
|
|
$wpdb->update(
|
|
$this->table_name,
|
|
["edit_lock" => $lock_value],
|
|
["session_id" => $session_id],
|
|
["%s"],
|
|
["%s"],
|
|
);
|
|
|
|
return ["locked" => true];
|
|
}
|
|
|
|
/**
|
|
* Release a session edit lock (only if held by this tab).
|
|
*
|
|
* @param string $session_id Session ID.
|
|
* @param string $tab_id Browser tab identifier.
|
|
* @return bool True if released.
|
|
*/
|
|
public function release_lock($session_id, $tab_id)
|
|
{
|
|
global $wpdb;
|
|
|
|
$current_lock = $wpdb->get_var(
|
|
$wpdb->prepare(
|
|
"SELECT edit_lock FROM {$this->table_name} WHERE session_id = %s",
|
|
$session_id,
|
|
),
|
|
);
|
|
|
|
if (!$current_lock) {
|
|
return true;
|
|
}
|
|
|
|
$parts = explode(":", $current_lock);
|
|
$lock_tab = $parts[2] ?? "";
|
|
|
|
if ($lock_tab !== $tab_id) {
|
|
return false;
|
|
}
|
|
|
|
$wpdb->update(
|
|
$this->table_name,
|
|
["edit_lock" => null],
|
|
["session_id" => $session_id],
|
|
["%s"],
|
|
["%s"],
|
|
);
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Check the lock status of a session without modifying it.
|
|
*
|
|
* @param string $session_id Session ID.
|
|
* @param string $tab_id Current tab identifier.
|
|
* @param int $window Lock expiry window in seconds.
|
|
* @return array { is_locked: bool, is_mine: bool, holder?: array }
|
|
*/
|
|
public function check_lock($session_id, $tab_id = "", $window = 60)
|
|
{
|
|
global $wpdb;
|
|
|
|
$now = time();
|
|
|
|
$current_lock = $wpdb->get_var(
|
|
$wpdb->prepare(
|
|
"SELECT edit_lock FROM {$this->table_name} WHERE session_id = %s",
|
|
$session_id,
|
|
),
|
|
);
|
|
|
|
if (!$current_lock) {
|
|
return ["is_locked" => false, "is_mine" => false];
|
|
}
|
|
|
|
$parts = explode(":", $current_lock);
|
|
$lock_time = (int) ($parts[0] ?? 0);
|
|
$lock_user = (int) ($parts[1] ?? 0);
|
|
$lock_tab = $parts[2] ?? "";
|
|
|
|
// Expired
|
|
if ($now - $lock_time >= $window) {
|
|
return ["is_locked" => false, "is_mine" => false];
|
|
}
|
|
|
|
$is_mine = $lock_tab === $tab_id;
|
|
|
|
return [
|
|
"is_locked" => true,
|
|
"is_mine" => $is_mine,
|
|
"holder" => [
|
|
"user_id" => $lock_user,
|
|
"tab_id" => $lock_tab,
|
|
"since" => $lock_time,
|
|
],
|
|
];
|
|
}
|
|
}
|