PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.13
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.13
1.1.13 1.1.12 1.1.11 1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 All 37 releases
desktop-mode / includes / agents / conversations.php

conversations.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.1.13, at includes/agents/conversations.php

625 lines 20.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation — Agents: persisted chat conversations.
4 *
5 * Each conversation is one post of the private `desktop_mode_chat`
6 * post type: `post_author` is the human who held the conversation,
7 * the messages live as JSON in `post_content`, the agent's user id in
8 * post meta, and the title is derived from the first user message.
9 * The posts table is the WordPress-native store for per-user document
10 * lists — user meta was rejected because WordPress loads all of a
11 * user's meta into cache on any meta read, so fat transcripts would
12 * tax every request that touches the user.
13 *
14 * Access is strictly owner-only: conversations are private
15 * correspondence, so not even administrators can read another user's
16 * chats through this API.
17 *
18 * REST surface (all under `desktop-mode/v1`):
19 * GET /agents/conversations — the caller's list, newest first
20 * POST /agents/conversations — create ({agentId, messages})
21 * GET /agents/conversations/:id — one conversation with messages
22 * PUT /agents/conversations/:id — replace messages ({messages})
23 * DELETE /agents/conversations/:id — delete
24 *
25 * @package OpenStation
26 */
27
28 defined( 'ABSPATH' ) || exit;
29
30 /**
31 * Post type holding one conversation per post.
32 *
33 * The VALUE keeps its pre-rebrand spelling on purpose: it is a
34 * persisted or externally-visible identifier, so renaming it would
35 * orphan data already written by live installs (or break a live
36 * URL). The mismatch between this constant's name and its value is
37 * deliberate — it is NOT a half-finished rename.
38 */
39 const OPENSTATION_AGENT_CHAT_POST_TYPE = 'desktop_mode_chat';
40
41 /** Newest conversations kept per user — creating past the cap prunes the oldest. */
42 const OPENSTATION_AGENT_CONVERSATION_CAP = 100;
43
44 /** Newest messages kept per conversation — updates past the cap trim the oldest. */
45 const OPENSTATION_AGENT_CONVERSATION_MESSAGE_CAP = 200;
46
47 /** Stored per-message text cap. Wider than the runner's replay cap so long answers reload intact. */
48 const OPENSTATION_AGENT_CONVERSATION_TEXT_CAP = 20000;
49
50 /** Characters of the last message shown as the sidebar's second line. */
51 const OPENSTATION_AGENT_CONVERSATION_PREVIEW_CAP = 80;
52
53 /**
54 * Entity kinds a message attachment may reference — mirrors the
55 * client's `DroppedEntityKind` and the drag-trigger config enum.
56 *
57 * @return string[]
58 */
59 function openstation_agent_conversation_attachment_kinds() {
60 return array( 'post', 'page', 'media', 'user', 'comment' );
61 }
62
63 /**
64 * Register the conversation post type. Private plumbing: no admin UI,
65 * no front-end queries, no revisions; rows die with their author.
66 *
67 * @return void
68 */
69 function openstation_agent_conversations_register_post_type() {
70 register_post_type(
71 OPENSTATION_AGENT_CHAT_POST_TYPE,
72 array(
73 'label' => __( 'Agent conversations', 'desktop-mode' ),
74 'public' => false,
75 'show_ui' => false,
76 'show_in_rest' => false,
77 'exclude_from_search' => true,
78 'publicly_queryable' => false,
79 'rewrite' => false,
80 'query_var' => false,
81 'supports' => array( 'title', 'author' ),
82 'delete_with_user' => true,
83 )
84 );
85 }
86 add_action( 'init', 'openstation_agent_conversations_register_post_type', 5 );
87
88 /**
89 * Normalize caller-supplied messages for storage.
90 *
91 * Rows: `role` in user|agent|error, non-empty `text` (capped), `at`
92 * timestamp. Tool calls keep name/args/error for the transcript
93 * display but DROP `output` — tool outputs can embed entire post
94 * bodies and are never rendered.
95 *
96 * An `attachment` block survives too: the entity a drop or a "Send
97 * to" pick carried into the conversation, so a reopened transcript
98 * still renders the clickable object card instead of only the
99 * boilerplate sentence the model was handed.
100 *
101 * @param mixed $messages Incoming message rows.
102 * @return array<int, array<string, mixed>>
103 */
104 function openstation_agent_conversation_sanitize_messages( $messages ) {
105 if ( ! is_array( $messages ) ) {
106 return array();
107 }
108
109 $clean = array();
110 foreach ( $messages as $row ) {
111 if ( ! is_array( $row ) ) {
112 continue;
113 }
114 $role = isset( $row['role'] ) ? sanitize_key( (string) $row['role'] ) : '';
115 if ( ! in_array( $role, array( 'user', 'agent', 'error' ), true ) ) {
116 continue;
117 }
118 $text = isset( $row['text'] ) ? trim( (string) $row['text'] ) : '';
119 if ( '' === $text ) {
120 continue;
121 }
122
123 $entry = array(
124 'role' => $role,
125 'text' => mb_substr( $text, 0, OPENSTATION_AGENT_CONVERSATION_TEXT_CAP ),
126 'at' => isset( $row['at'] ) ? (int) $row['at'] : 0,
127 );
128
129 // Call-to-action buttons survive with the message so a reopened
130 // conversation still shows them (spent ones stay disabled via
131 // `ctaUsed`). Reuses the runner's sanitizer — same caps.
132 if ( isset( $row['callToActions'] ) && function_exists( 'openstation_agent_sanitize_call_to_actions' ) ) {
133 $ctas = openstation_agent_sanitize_call_to_actions( $row['callToActions'] );
134 if ( ! empty( $ctas ) ) {
135 $entry['callToActions'] = $ctas;
136 }
137 }
138 if ( ! empty( $row['ctaUsed'] ) ) {
139 $entry['ctaUsed'] = true;
140 }
141
142 if ( isset( $row['attachment'] ) ) {
143 $attachment = openstation_agent_conversation_sanitize_attachment( $row['attachment'] );
144 if ( null !== $attachment ) {
145 $entry['attachment'] = $attachment;
146 }
147 }
148
149 if ( isset( $row['toolCalls'] ) && is_array( $row['toolCalls'] ) ) {
150 $calls = array();
151 foreach ( $row['toolCalls'] as $call ) {
152 if ( ! is_array( $call ) ) {
153 continue;
154 }
155 $calls[] = array(
156 'callId' => isset( $call['callId'] ) ? (string) $call['callId'] : '',
157 'name' => isset( $call['name'] ) ? (string) $call['name'] : '',
158 'args' => isset( $call['args'] ) && is_array( $call['args'] ) ? $call['args'] : array(),
159 'error' => isset( $call['error'] ) && is_string( $call['error'] ) ? $call['error'] : null,
160 );
161 }
162 if ( ! empty( $calls ) ) {
163 $entry['toolCalls'] = $calls;
164 }
165 }
166
167 $clean[] = $entry;
168 }
169
170 if ( count( $clean ) > OPENSTATION_AGENT_CONVERSATION_MESSAGE_CAP ) {
171 $clean = array_slice( $clean, -OPENSTATION_AGENT_CONVERSATION_MESSAGE_CAP );
172 }
173
174 return $clean;
175 }
176
177 /**
178 * Normalize one message attachment, or null when the block does not
179 * describe an entity this site understands.
180 *
181 * Only the identity triple is stored — kind, id, title. The client
182 * resolves the object's URL at click time from the kind, so a
183 * renamed or re-permalinked entity never leaves a stale link behind
184 * in an old transcript.
185 *
186 * @param mixed $raw Incoming attachment block.
187 * @return array<string, mixed>|null
188 */
189 function openstation_agent_conversation_sanitize_attachment( $raw ) {
190 if ( ! is_array( $raw ) ) {
191 return null;
192 }
193 $kind = isset( $raw['kind'] ) ? sanitize_key( (string) $raw['kind'] ) : '';
194 $id = isset( $raw['id'] ) ? (int) $raw['id'] : 0;
195 if ( $id <= 0 || ! in_array( $kind, openstation_agent_conversation_attachment_kinds(), true ) ) {
196 return null;
197 }
198 $title = isset( $raw['title'] ) ? trim( wp_strip_all_tags( (string) $raw['title'] ) ) : '';
199 return array(
200 'kind' => $kind,
201 'id' => $id,
202 'title' => '' !== $title ? mb_substr( $title, 0, 200 ) : '#' . $id,
203 );
204 }
205
206 /**
207 * Derive a list title from the first user message.
208 *
209 * @param array $messages Sanitized messages.
210 * @return string
211 */
212 function openstation_agent_conversation_title( array $messages ) {
213 foreach ( $messages as $row ) {
214 if ( 'user' === $row['role'] ) {
215 return wp_html_excerpt( $row['text'], 60, '…' );
216 }
217 }
218 return __( 'Conversation', 'desktop-mode' );
219 }
220
221 /**
222 * The sidebar's second line: the TAIL of the last message.
223 *
224 * The title is derived from the FIRST user message, which makes every
225 * conversation with the same opener look identical in the list. The
226 * preview answers the other question — "where did this one get to?" —
227 * so it reads from the end ("…and search relevance.") rather than the
228 * beginning. An attachment-carrying row previews the object instead of
229 * the boilerplate sentence the model was handed.
230 *
231 * @param array $messages Sanitized messages.
232 * @return string
233 */
234 function openstation_agent_conversation_preview( array $messages ) {
235 $last = empty( $messages ) ? null : $messages[ count( $messages ) - 1 ];
236 if ( ! is_array( $last ) ) {
237 return '';
238 }
239 if ( isset( $last['attachment']['title'] ) ) {
240 return (string) $last['attachment']['title'];
241 }
242
243 $text = trim( (string) preg_replace( '/\s+/u', ' ', wp_strip_all_tags( (string) $last['text'] ) ) );
244 if ( '' === $text ) {
245 return '';
246 }
247 if ( mb_strlen( $text ) <= OPENSTATION_AGENT_CONVERSATION_PREVIEW_CAP ) {
248 return $text;
249 }
250 return '…' . mb_substr( $text, -OPENSTATION_AGENT_CONVERSATION_PREVIEW_CAP );
251 }
252
253 /**
254 * The conversation post for `$id` when it exists AND belongs to the
255 * current user; null otherwise. Ownership is the whole access model.
256 *
257 * @param int $id Post id.
258 * @return WP_Post|null
259 */
260 function openstation_agent_conversation_get_own( $id ) {
261 $post = get_post( (int) $id );
262 if ( ! $post || OPENSTATION_AGENT_CHAT_POST_TYPE !== $post->post_type ) {
263 return null;
264 }
265 if ( get_current_user_id() !== (int) $post->post_author ) {
266 return null;
267 }
268 return $post;
269 }
270
271 /**
272 * Project a conversation post onto the REST shape. The agent block is
273 * resolved live so the sidebar can paint the avatar + reopen the chat
274 * header; a deleted agent degrades to a labelled placeholder.
275 *
276 * @param WP_Post $post Conversation post.
277 * @param bool $with_messages Include the decoded messages array.
278 * @return array<string, mixed>
279 */
280 function openstation_agent_conversation_prepare( WP_Post $post, $with_messages = false ) {
281 $agent_id = (int) get_post_meta( $post->ID, '_desktop_mode_agent_chat_agent_id', true );
282 $agent = $agent_id > 0 ? get_userdata( $agent_id ) : false;
283
284 $messages = json_decode( (string) $post->post_content, true );
285 if ( ! is_array( $messages ) ) {
286 $messages = array();
287 }
288
289 $last = empty( $messages ) ? null : $messages[ count( $messages ) - 1 ];
290
291 $out = array(
292 'id' => (int) $post->ID,
293 'agentId' => $agent_id,
294 'agentName' => $agent ? openstation_plain_text_title( $agent->display_name ) : __( 'Deleted agent', 'desktop-mode' ),
295 'agentDescription' => $agent ? (string) get_user_meta( $agent_id, '_desktop_mode_agent_description', true ) : '',
296 'agentAvatarUrl' => function_exists( 'openstation_agent_avatar_url' ) ? openstation_agent_avatar_url( $agent_id ) : '',
297 'title' => (string) $post->post_title,
298 // Second sidebar line + who spoke last, so the list can say
299 // where each conversation got to instead of repeating its opener.
300 'preview' => openstation_agent_conversation_preview( $messages ),
301 'lastRole' => is_array( $last ) && isset( $last['role'] ) ? (string) $last['role'] : '',
302 'messageCount' => count( $messages ),
303 'createdAt' => mysql2date( 'c', $post->post_date_gmt, false ),
304 'updatedAt' => mysql2date( 'c', $post->post_modified_gmt, false ),
305 );
306 if ( $with_messages ) {
307 $out['messages'] = $messages;
308 }
309 return $out;
310 }
311
312 /**
313 * Register the conversation routes.
314 *
315 * Invoke-level permission gates the surface (anyone who can talk to
316 * agents can keep their own history); ownership checks inside each
317 * handler scope every read and write to the caller's rows.
318 *
319 * @return void
320 */
321 function openstation_agent_conversations_register_routes() {
322 register_rest_route(
323 'desktop-mode/v1',
324 '/agents/conversations',
325 array(
326 array(
327 'methods' => WP_REST_Server::READABLE,
328 'callback' => 'openstation_agents_rest_conversations_list',
329 'permission_callback' => 'openstation_agents_rest_invoke_permission',
330 ),
331 array(
332 'methods' => WP_REST_Server::CREATABLE,
333 'callback' => 'openstation_agents_rest_conversations_create',
334 'permission_callback' => 'openstation_agents_rest_invoke_permission',
335 'args' => array(
336 'agentId' => array(
337 'type' => 'integer',
338 'required' => true,
339 'minimum' => 1,
340 ),
341 'messages' => array(
342 'type' => 'array',
343 'required' => true,
344 ),
345 ),
346 ),
347 )
348 );
349
350 register_rest_route(
351 'desktop-mode/v1',
352 '/agents/conversations/(?P<id>\d+)',
353 array(
354 array(
355 'methods' => WP_REST_Server::READABLE,
356 'callback' => 'openstation_agents_rest_conversations_get',
357 'permission_callback' => 'openstation_agents_rest_invoke_permission',
358 ),
359 array(
360 'methods' => WP_REST_Server::EDITABLE,
361 'callback' => 'openstation_agents_rest_conversations_update',
362 'permission_callback' => 'openstation_agents_rest_invoke_permission',
363 'args' => array(
364 'messages' => array(
365 'type' => 'array',
366 'required' => true,
367 ),
368 ),
369 ),
370 array(
371 'methods' => WP_REST_Server::DELETABLE,
372 'callback' => 'openstation_agents_rest_conversations_delete',
373 'permission_callback' => 'openstation_agents_rest_invoke_permission',
374 ),
375 )
376 );
377 }
378 add_action( 'rest_api_init', 'openstation_agent_conversations_register_routes' );
379
380 /**
381 * GET /agents/conversations — the caller's conversations, most
382 * recently updated first, without message bodies (the list must stay
383 * light; the sidebar fetches bodies on click).
384 *
385 * @return WP_REST_Response
386 */
387 function openstation_agents_rest_conversations_list() {
388 $posts = get_posts(
389 array(
390 'post_type' => OPENSTATION_AGENT_CHAT_POST_TYPE,
391 'post_status' => 'publish',
392 'author' => get_current_user_id(),
393 'numberposts' => openstation_agent_conversation_cap(),
394 'orderby' => 'modified',
395 'order' => 'DESC',
396 'suppress_filters' => false,
397 )
398 );
399
400 return rest_ensure_response(
401 array_map( 'openstation_agent_conversation_prepare', $posts )
402 );
403 }
404
405 /**
406 * Keep the save filters off a conversation's title and transcript,
407 * the way `WP_Customize_Manager` does for changeset JSON. Hooked only
408 * while one of the handlers below saves.
409 *
410 * Both are text the client paints as text, never HTML that WordPress
411 * echoes, yet `title_save_pre` and `content_save_pre` rewrite them as
412 * HTML. For a user without `unfiltered_html`, kses turns `&` into
413 * `&amp;`, drops whatever sits between a `<` and a `>`, and unescapes
414 * an attribute's quotes. On a site that balances tags, `balanceTags`
415 * appends closing tags for every role. Those last two leave JSON that
416 * no longer decodes.
417 *
418 * @param array $data Slashed, sanitized post data.
419 * @param array $postarr Sanitized post data.
420 * @param array $unsanitized_postarr Slashed post data as passed to `wp_insert_post()`.
421 * @return array
422 */
423 function openstation_agent_conversation_preserve_text( $data, $postarr, $unsanitized_postarr ) {
424 global $wpdb;
425
426 if ( OPENSTATION_AGENT_CHAT_POST_TYPE !== $data['post_type'] ) {
427 return $data;
428 }
429 foreach ( array( 'post_title', 'post_content' ) as $field ) {
430 if ( ! isset( $unsanitized_postarr[ $field ] ) ) {
431 continue;
432 }
433 $data[ $field ] = $unsanitized_postarr[ $field ];
434 // `wp_insert_post()` has already swapped emoji for entities
435 // where the column is utf8mb3, which rejects them and fails
436 // the save. Redo it on the restored text.
437 if ( in_array( $wpdb->get_col_charset( $wpdb->posts, $field ), array( 'utf8', 'utf8mb3' ), true ) ) {
438 $data[ $field ] = wp_encode_emoji( $data[ $field ] );
439 }
440 }
441 return $data;
442 }
443
444 /**
445 * POST /agents/conversations — create from {agentId, messages}.
446 *
447 * @param WP_REST_Request $request Request.
448 * @return WP_REST_Response|WP_Error
449 */
450 function openstation_agents_rest_conversations_create( WP_REST_Request $request ) {
451 $agent_id = (int) $request['agentId'];
452 if ( ! function_exists( 'openstation_agent_is_agent' ) || ! openstation_agent_is_agent( $agent_id ) ) {
453 return new WP_Error(
454 'openstation_agent_not_found',
455 __( 'No agent with that id exists.', 'desktop-mode' ),
456 array( 'status' => 404 )
457 );
458 }
459
460 $messages = openstation_agent_conversation_sanitize_messages( $request['messages'] );
461 if ( empty( $messages ) ) {
462 return new WP_Error(
463 'openstation_agent_conversation_empty',
464 __( 'A conversation needs at least one message.', 'desktop-mode' ),
465 array( 'status' => 400 )
466 );
467 }
468
469 add_filter( 'wp_insert_post_data', 'openstation_agent_conversation_preserve_text', 5, 3 );
470 $post_id = wp_insert_post(
471 array(
472 'post_type' => OPENSTATION_AGENT_CHAT_POST_TYPE,
473 'post_status' => 'publish',
474 'post_author' => get_current_user_id(),
475 // Both survive the insert-path unslashing only when slashed.
476 'post_title' => wp_slash( openstation_agent_conversation_title( $messages ) ),
477 'post_content' => wp_slash( (string) wp_json_encode( $messages ) ),
478 ),
479 true
480 );
481 remove_filter( 'wp_insert_post_data', 'openstation_agent_conversation_preserve_text', 5 );
482 if ( is_wp_error( $post_id ) ) {
483 return $post_id;
484 }
485 update_post_meta( $post_id, '_desktop_mode_agent_chat_agent_id', $agent_id );
486
487 openstation_agent_conversations_prune( get_current_user_id() );
488
489 return rest_ensure_response(
490 openstation_agent_conversation_prepare( get_post( $post_id ), true )
491 );
492 }
493
494 /**
495 * GET /agents/conversations/:id — one conversation with messages.
496 *
497 * @param WP_REST_Request $request Request.
498 * @return WP_REST_Response|WP_Error
499 */
500 function openstation_agents_rest_conversations_get( WP_REST_Request $request ) {
501 $post = openstation_agent_conversation_get_own( (int) $request['id'] );
502 if ( ! $post ) {
503 return openstation_agent_conversation_not_found();
504 }
505 return rest_ensure_response(
506 openstation_agent_conversation_prepare( $post, true )
507 );
508 }
509
510 /**
511 * PUT /agents/conversations/:id — replace the messages. The client
512 * owns the in-memory transcript, so full replacement after each
513 * exchange is simpler and idempotent compared to append semantics.
514 *
515 * @param WP_REST_Request $request Request.
516 * @return WP_REST_Response|WP_Error
517 */
518 function openstation_agents_rest_conversations_update( WP_REST_Request $request ) {
519 $post = openstation_agent_conversation_get_own( (int) $request['id'] );
520 if ( ! $post ) {
521 return openstation_agent_conversation_not_found();
522 }
523
524 $messages = openstation_agent_conversation_sanitize_messages( $request['messages'] );
525 if ( empty( $messages ) ) {
526 return new WP_Error(
527 'openstation_agent_conversation_empty',
528 __( 'A conversation needs at least one message.', 'desktop-mode' ),
529 array( 'status' => 400 )
530 );
531 }
532
533 add_filter( 'wp_insert_post_data', 'openstation_agent_conversation_preserve_text', 5, 3 );
534 $updated = wp_update_post(
535 array(
536 'ID' => $post->ID,
537 'post_title' => wp_slash( openstation_agent_conversation_title( $messages ) ),
538 'post_content' => wp_slash( (string) wp_json_encode( $messages ) ),
539 ),
540 true
541 );
542 remove_filter( 'wp_insert_post_data', 'openstation_agent_conversation_preserve_text', 5 );
543 if ( is_wp_error( $updated ) ) {
544 return $updated;
545 }
546
547 return rest_ensure_response(
548 openstation_agent_conversation_prepare( get_post( $post->ID ), true )
549 );
550 }
551
552 /**
553 * DELETE /agents/conversations/:id.
554 *
555 * @param WP_REST_Request $request Request.
556 * @return WP_REST_Response|WP_Error
557 */
558 function openstation_agents_rest_conversations_delete( WP_REST_Request $request ) {
559 $post = openstation_agent_conversation_get_own( (int) $request['id'] );
560 if ( ! $post ) {
561 return openstation_agent_conversation_not_found();
562 }
563 wp_delete_post( $post->ID, true );
564 return rest_ensure_response( array( 'deleted' => true ) );
565 }
566
567 /**
568 * Shared 404 for missing/foreign conversations — the same error for
569 * "does not exist" and "not yours" so ids can't be probed.
570 *
571 * @return WP_Error
572 */
573 function openstation_agent_conversation_not_found() {
574 return new WP_Error(
575 'openstation_agent_conversation_not_found',
576 __( 'No conversation with that id exists.', 'desktop-mode' ),
577 array( 'status' => 404 )
578 );
579 }
580
581 /**
582 * The effective per-user conversation cap.
583 *
584 * @return int
585 */
586 function openstation_agent_conversation_cap() {
587 /**
588 * Filters how many conversations are kept per user. Creating past
589 * the cap prunes the least recently updated rows.
590 *
591 * @param int $cap Maximum stored conversations per user.
592 */
593 return max( 1, (int) apply_filters( 'openstation_agent_conversation_cap', OPENSTATION_AGENT_CONVERSATION_CAP ) );
594 }
595
596 /**
597 * Drop the user's oldest conversations beyond the cap.
598 *
599 * @param int $user_id Owner.
600 * @return void
601 */
602 function openstation_agent_conversations_prune( $user_id ) {
603 $cap = openstation_agent_conversation_cap();
604 $posts = get_posts(
605 array(
606 'post_type' => OPENSTATION_AGENT_CHAT_POST_TYPE,
607 'post_status' => 'publish',
608 'author' => (int) $user_id,
609 // One page past the cap is plenty — pruning runs on every create.
610 'numberposts' => $cap + 10,
611 // The ID tie-break matters: same-second rows are otherwise
612 // unordered and the prune could eat the row just created.
613 'orderby' => array(
614 'modified' => 'DESC',
615 'ID' => 'DESC',
616 ),
617 'fields' => 'ids',
618 'suppress_filters' => false,
619 )
620 );
621 foreach ( array_slice( $posts, $cap ) as $stale_id ) {
622 wp_delete_post( (int) $stale_id, true );
623 }
624 }
625