PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.8
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.8
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 0.8.9 0.8.8 0.8.7 All 34 releases
desktop-mode / includes / agents / conversations.php

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

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