PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.7
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.7
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 / ai-copilot / client.php

client.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 0.9.7, at includes/ai-copilot/client.php

252 lines 8.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Desktop Mode — AI Copilot: WordPress AI Client adapter.
4 *
5 * Thin wrappers around `wp_ai_client_prompt()` that the agentic search loop
6 * and the comment-scoring job use to generate. Credentials are injected by
7 * Core from the configured Connector — nothing here ever handles an API key.
8 *
9 * The search loop advertises its tools — built-in WordPress Abilities (see
10 * abilities.php) plus client command tools — as function declarations, and
11 * dispatches ability calls through `wp_get_ability()->execute()`.
12 *
13 * All SDK classes referenced here ship with WordPress 7.0+. The `use`
14 * statements are compile-time aliases only; every call site is reached solely
15 * through {@see desktop_mode_ai_is_available()}, so this file is inert (and
16 * never resolves the classes) on older WordPress.
17 *
18 * @package WPDesktopMode
19 */
20
21 use WordPress\AiClient\Messages\DTO\Message;
22 use WordPress\AiClient\Messages\DTO\MessagePart;
23 use WordPress\AiClient\Messages\DTO\UserMessage;
24 use WordPress\AiClient\Tools\DTO\FunctionCall;
25 use WordPress\AiClient\Tools\DTO\FunctionDeclaration;
26 use WordPress\AiClient\Tools\DTO\FunctionResponse;
27
28 defined( 'ABSPATH' ) || exit;
29
30 /**
31 * Builds SDK function declarations from the loop's tool definitions.
32 *
33 * Each definition is the neutral tool shape the registry already produces:
34 * `{ type: 'function', name, description, parameters (JSON Schema) }`.
35 *
36 * @since 0.9.4
37 *
38 * @param array $tool_defs List of tool definitions.
39 * @return FunctionDeclaration[]
40 */
41 function desktop_mode_ai_build_function_declarations( array $tool_defs ) {
42 $declarations = array();
43 foreach ( $tool_defs as $def ) {
44 if ( ! is_array( $def ) ) {
45 continue;
46 }
47 $name = isset( $def['name'] ) ? (string) $def['name'] : '';
48 if ( '' === $name ) {
49 continue;
50 }
51 $description = isset( $def['description'] ) ? (string) $def['description'] : '';
52 $parameters = isset( $def['parameters'] ) && is_array( $def['parameters'] ) ? $def['parameters'] : null;
53
54 $declarations[] = new FunctionDeclaration( $name, $description, $parameters );
55 }
56 return $declarations;
57 }
58
59 /**
60 * Wraps a user query as a text message for the conversation history.
61 *
62 * @since 0.9.4
63 *
64 * @param string $text
65 * @return UserMessage
66 */
67 function desktop_mode_ai_user_text_message( $text ) {
68 return new UserMessage( array( new MessagePart( (string) $text ) ) );
69 }
70
71 /**
72 * Wraps tool results as a user message of function-response parts.
73 *
74 * @since 0.9.4
75 *
76 * @param array $tool_outputs List of `{ call_id, name, response }` entries.
77 * @return UserMessage
78 */
79 function desktop_mode_ai_tool_result_message( array $tool_outputs ) {
80 $parts = array();
81 foreach ( $tool_outputs as $output ) {
82 $parts[] = new MessagePart(
83 new FunctionResponse(
84 isset( $output['call_id'] ) && '' !== $output['call_id'] ? (string) $output['call_id'] : null,
85 isset( $output['name'] ) && '' !== $output['name'] ? (string) $output['name'] : null,
86 isset( $output['response'] ) ? $output['response'] : null
87 )
88 );
89 }
90 return new UserMessage( $parts );
91 }
92
93 /**
94 * Strips thought-channel parts from a message before it re-enters history.
95 *
96 * Providers cannot reliably round-trip reasoning blocks: the Anthropic
97 * provider drops the cryptographic `signature` when parsing a `thinking`
98 * block, and the API rejects any replayed thinking block without one
99 * (`thinking.signature: Field required`). Thought parts carry no information
100 * the next turn needs — the model re-reasons from the visible conversation —
101 * so the agentic loop replays assistant turns without them.
102 *
103 * If every part is a thought (no text, no function call), the message is
104 * returned unchanged rather than emptied; the loop never replays such a
105 * turn anyway.
106 *
107 * @since 0.9.4
108 *
109 * @param Message $message Assistant message as returned by the AI Client.
110 * @return Message Message safe to append to the conversation history.
111 */
112 function desktop_mode_ai_strip_thought_parts( Message $message ) {
113 $kept = array();
114 $stripped = false;
115 foreach ( $message->getParts() as $part ) {
116 if ( $part->getChannel()->isThought() ) {
117 $stripped = true;
118 continue;
119 }
120 $kept[] = $part;
121 }
122
123 if ( ! $stripped || empty( $kept ) ) {
124 return $message;
125 }
126
127 return new Message( $message->getRole(), $kept );
128 }
129
130 /**
131 * Runs one generation turn through the AI Client.
132 *
133 * Rebuilds the prompt from the full ordered message list each turn (the
134 * builder's `with_history()` prepends, so it can't append turns in a loop),
135 * advertises the tools as function declarations, and constrains the final
136 * answer to `$answer_schema` when given. Returns the assistant turn normalized
137 * to the shape the loop consumes; `message` has thought-channel parts stripped
138 * ({@see desktop_mode_ai_strip_thought_parts()}) so it is safe to replay.
139 *
140 * @since 0.9.4
141 *
142 * @param int $user_id Requesting user id. Currently unused — the
143 * provider comes from Connectors and no
144 * per-user preference is applied; retained for
145 * signature stability and future attribution.
146 * @param array $messages Ordered conversation as SDK Message objects.
147 * @param array $tool_defs Tool definitions to advertise.
148 * @param array|null $answer_schema JSON Schema for the final answer, or null.
149 * @param string $instructions System instruction.
150 * @return array{ text: ?string, function_calls: array, message: mixed, usage: ?array, model: ?array }|WP_Error
151 */
152 function desktop_mode_ai_client_generate( $user_id, array $messages, array $tool_defs, $answer_schema, $instructions ) {
153 $builder = wp_ai_client_prompt( $messages );
154
155 if ( is_string( $instructions ) && '' !== $instructions ) {
156 $builder = $builder->using_system_instruction( $instructions );
157 }
158
159 // Provider + model selection is delegated entirely to the Core AI Client
160 // (Connector-backed); Desktop Mode pins neither.
161
162 $declarations = desktop_mode_ai_build_function_declarations( $tool_defs );
163 if ( ! empty( $declarations ) ) {
164 $builder = $builder->using_function_declarations( ...$declarations );
165 }
166
167 if ( is_array( $answer_schema ) ) {
168 $builder = $builder->as_json_response( $answer_schema );
169 }
170
171 $result = $builder->generate_result();
172 if ( is_wp_error( $result ) ) {
173 return $result;
174 }
175
176 $message = $result->toMessage();
177 $function_calls = array();
178 foreach ( $message->getParts() as $part ) {
179 if ( ! $part->getType()->isFunctionCall() ) {
180 continue;
181 }
182 $call = $part->getFunctionCall();
183 if ( ! $call instanceof FunctionCall ) {
184 continue;
185 }
186 $args = $call->getArgs();
187 $function_calls[] = array(
188 'name' => (string) $call->getName(),
189 'call_id' => (string) $call->getId(),
190 'arguments' => wp_json_encode( is_array( $args ) ? $args : array() ),
191 );
192 }
193
194 $text = null;
195 if ( empty( $function_calls ) ) {
196 try {
197 $text = $result->toText();
198 } catch ( \Throwable $e ) {
199 $text = null;
200 }
201 }
202
203 return array(
204 'text' => $text,
205 'function_calls' => $function_calls,
206 'message' => desktop_mode_ai_strip_thought_parts( $message ),
207 'usage' => desktop_mode_ai_result_token_usage( $result ),
208 'model' => desktop_mode_ai_result_model_metadata( $result ),
209 );
210 }
211
212 /**
213 * Extracts normalized token usage from a generation result.
214 *
215 * @since 0.9.4
216 *
217 * @param mixed $result GenerativeAiResult.
218 * @return array{ prompt: int, completion: int, total: int }|null
219 */
220 function desktop_mode_ai_result_token_usage( $result ) {
221 try {
222 $usage = $result->getTokenUsage();
223 return array(
224 'prompt' => (int) $usage->getPromptTokens(),
225 'completion' => (int) $usage->getCompletionTokens(),
226 'total' => (int) $usage->getTotalTokens(),
227 );
228 } catch ( \Throwable $e ) {
229 return null;
230 }
231 }
232
233 /**
234 * Extracts the resolved model's id + name from a generation result.
235 *
236 * @since 0.9.4
237 *
238 * @param mixed $result GenerativeAiResult.
239 * @return array{ id: string, name: string }|null
240 */
241 function desktop_mode_ai_result_model_metadata( $result ) {
242 try {
243 $model = $result->getModelMetadata();
244 return array(
245 'id' => (string) $model->getId(),
246 'name' => (string) $model->getName(),
247 );
248 } catch ( \Throwable $e ) {
249 return null;
250 }
251 }
252