PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.9
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.9
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 0.8.9 0.8.8 0.8.7 0.8.6 All 33 releases
desktop-mode / includes / agents / conversations.php

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

582 lines 18.9 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 ? $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 * POST /agents/conversations — create from {agentId, messages}.
407 *
408 * @param WP_REST_Request $request Request.
409 * @return WP_REST_Response|WP_Error
410 */
411 function openstation_agents_rest_conversations_create( WP_REST_Request $request ) {
412 $agent_id = (int) $request['agentId'];
413 if ( ! function_exists( 'openstation_agent_is_agent' ) || ! openstation_agent_is_agent( $agent_id ) ) {
414 return new WP_Error(
415 'openstation_agent_not_found',
416 __( 'No agent with that id exists.', 'desktop-mode' ),
417 array( 'status' => 404 )
418 );
419 }
420
421 $messages = openstation_agent_conversation_sanitize_messages( $request['messages'] );
422 if ( empty( $messages ) ) {
423 return new WP_Error(
424 'openstation_agent_conversation_empty',
425 __( 'A conversation needs at least one message.', 'desktop-mode' ),
426 array( 'status' => 400 )
427 );
428 }
429
430 $post_id = wp_insert_post(
431 array(
432 'post_type' => OPENSTATION_AGENT_CHAT_POST_TYPE,
433 'post_status' => 'publish',
434 'post_author' => get_current_user_id(),
435 'post_title' => openstation_agent_conversation_title( $messages ),
436 // JSON survives the insert-path unslashing only when slashed.
437 'post_content' => wp_slash( (string) wp_json_encode( $messages ) ),
438 ),
439 true
440 );
441 if ( is_wp_error( $post_id ) ) {
442 return $post_id;
443 }
444 update_post_meta( $post_id, '_desktop_mode_agent_chat_agent_id', $agent_id );
445
446 openstation_agent_conversations_prune( get_current_user_id() );
447
448 return rest_ensure_response(
449 openstation_agent_conversation_prepare( get_post( $post_id ), true )
450 );
451 }
452
453 /**
454 * GET /agents/conversations/:id — one conversation with messages.
455 *
456 * @param WP_REST_Request $request Request.
457 * @return WP_REST_Response|WP_Error
458 */
459 function openstation_agents_rest_conversations_get( WP_REST_Request $request ) {
460 $post = openstation_agent_conversation_get_own( (int) $request['id'] );
461 if ( ! $post ) {
462 return openstation_agent_conversation_not_found();
463 }
464 return rest_ensure_response(
465 openstation_agent_conversation_prepare( $post, true )
466 );
467 }
468
469 /**
470 * PUT /agents/conversations/:id — replace the messages. The client
471 * owns the in-memory transcript, so full replacement after each
472 * exchange is simpler and idempotent compared to append semantics.
473 *
474 * @param WP_REST_Request $request Request.
475 * @return WP_REST_Response|WP_Error
476 */
477 function openstation_agents_rest_conversations_update( WP_REST_Request $request ) {
478 $post = openstation_agent_conversation_get_own( (int) $request['id'] );
479 if ( ! $post ) {
480 return openstation_agent_conversation_not_found();
481 }
482
483 $messages = openstation_agent_conversation_sanitize_messages( $request['messages'] );
484 if ( empty( $messages ) ) {
485 return new WP_Error(
486 'openstation_agent_conversation_empty',
487 __( 'A conversation needs at least one message.', 'desktop-mode' ),
488 array( 'status' => 400 )
489 );
490 }
491
492 $updated = wp_update_post(
493 array(
494 'ID' => $post->ID,
495 'post_title' => openstation_agent_conversation_title( $messages ),
496 'post_content' => wp_slash( (string) wp_json_encode( $messages ) ),
497 ),
498 true
499 );
500 if ( is_wp_error( $updated ) ) {
501 return $updated;
502 }
503
504 return rest_ensure_response(
505 openstation_agent_conversation_prepare( get_post( $post->ID ), true )
506 );
507 }
508
509 /**
510 * DELETE /agents/conversations/:id.
511 *
512 * @param WP_REST_Request $request Request.
513 * @return WP_REST_Response|WP_Error
514 */
515 function openstation_agents_rest_conversations_delete( WP_REST_Request $request ) {
516 $post = openstation_agent_conversation_get_own( (int) $request['id'] );
517 if ( ! $post ) {
518 return openstation_agent_conversation_not_found();
519 }
520 wp_delete_post( $post->ID, true );
521 return rest_ensure_response( array( 'deleted' => true ) );
522 }
523
524 /**
525 * Shared 404 for missing/foreign conversations — the same error for
526 * "does not exist" and "not yours" so ids can't be probed.
527 *
528 * @return WP_Error
529 */
530 function openstation_agent_conversation_not_found() {
531 return new WP_Error(
532 'openstation_agent_conversation_not_found',
533 __( 'No conversation with that id exists.', 'desktop-mode' ),
534 array( 'status' => 404 )
535 );
536 }
537
538 /**
539 * The effective per-user conversation cap.
540 *
541 * @return int
542 */
543 function openstation_agent_conversation_cap() {
544 /**
545 * Filters how many conversations are kept per user. Creating past
546 * the cap prunes the least recently updated rows.
547 *
548 * @param int $cap Maximum stored conversations per user.
549 */
550 return max( 1, (int) apply_filters( 'openstation_agent_conversation_cap', OPENSTATION_AGENT_CONVERSATION_CAP ) );
551 }
552
553 /**
554 * Drop the user's oldest conversations beyond the cap.
555 *
556 * @param int $user_id Owner.
557 * @return void
558 */
559 function openstation_agent_conversations_prune( $user_id ) {
560 $cap = openstation_agent_conversation_cap();
561 $posts = get_posts(
562 array(
563 'post_type' => OPENSTATION_AGENT_CHAT_POST_TYPE,
564 'post_status' => 'publish',
565 'author' => (int) $user_id,
566 // One page past the cap is plenty — pruning runs on every create.
567 'numberposts' => $cap + 10,
568 // The ID tie-break matters: same-second rows are otherwise
569 // unordered and the prune could eat the row just created.
570 'orderby' => array(
571 'modified' => 'DESC',
572 'ID' => 'DESC',
573 ),
574 'fields' => 'ids',
575 'suppress_filters' => false,
576 )
577 );
578 foreach ( array_slice( $posts, $cap ) as $stale_id ) {
579 wp_delete_post( (int) $stale_id, true );
580 }
581 }
582