Skip to main content

netget/easy/http/
actions.rs

1use anyhow::{Context, Result};
2use serde_json::{json, Value as JsonValue};
3use std::future::Future;
4use std::pin::Pin;
5use std::sync::Arc;
6
7use crate::llm::actions::Easy;
8use crate::llm::template_engine::{TemplateDataBuilder, TEMPLATE_ENGINE};
9use crate::llm::OllamaClient;
10use crate::protocol::Event;
11use crate::state::AppState;
12
13/// HTTP-easy protocol - simplified HTTP server for "dumb models"
14///
15/// This protocol wraps the standard HTTP server and provides a simplified
16/// interface where the LLM responds in Markdown instead of JSON actions.
17///
18/// Flow:
19/// 1. HTTP-easy generates open_server action for HTTP protocol
20/// 2. HTTP server receives request and fires http_request event
21/// 3. EventHandler routes event to HTTP-easy handler (via server_to_easy mapping)
22/// 4. HTTP-easy transforms request into conversational prompt
23/// 5. LLM responds in Markdown
24/// 6. HTTP-easy converts Markdown to HTML
25/// 7. HTTP-easy generates send_http_response action
26/// 8. HTTP server sends HTML response
27pub struct HttpEasyProtocol;
28
29impl Easy for HttpEasyProtocol {
30    fn protocol_name(&self) -> &'static str {
31        "http"
32    }
33
34    fn underlying_protocol(&self) -> &'static str {
35        "HTTP"
36    }
37
38    fn default_port(&self) -> Option<u16> {
39        Some(8080)
40    }
41
42    fn generate_startup_action(
43        &self,
44        user_instruction: Option<String>,
45        port: Option<u16>,
46    ) -> Result<JsonValue> {
47        let port = port.unwrap_or_else(|| self.default_port().unwrap());
48        let instruction = user_instruction.unwrap_or_else(|| {
49            "You are a helpful HTTP server. Respond to requests with useful information."
50                .to_string()
51        });
52
53        // Generate open_server action for HTTP protocol
54        Ok(json!({
55            "type": "open_server",
56            "protocol": "HTTP",
57            "port": port,
58            "instruction": instruction,
59        }))
60    }
61
62    fn handle_event(
63        &self,
64        event: Event,
65        user_instruction: Option<String>,
66        llm_client: Arc<OllamaClient>,
67        app_state: Arc<AppState>,
68    ) -> Pin<Box<dyn Future<Output = Result<Vec<JsonValue>>> + Send>> {
69        Box::pin(async move {
70            let event_type = &event.event_type;
71
72            // Only handle http_request events
73            if event_type.id != "http_request" {
74                return Ok(vec![]);
75            }
76
77            // Extract request details from event data
78            let method = event
79                .data
80                .get("method")
81                .and_then(|v| v.as_str())
82                .unwrap_or("GET");
83            let uri = event
84                .data
85                .get("uri")
86                .and_then(|v| v.as_str())
87                .unwrap_or("/");
88            let body = event
89                .data
90                .get("body")
91                .and_then(|v| v.as_str())
92                .unwrap_or("");
93
94            // Convert headers object to array for template
95            let headers_array: Vec<serde_json::Value> = event
96                .data
97                .get("headers")
98                .and_then(|v| v.as_object())
99                .map(|obj| {
100                    obj.iter()
101                        .map(|(k, v)| {
102                            json!({
103                                "name": k,
104                                "value": v.as_str().unwrap_or("")
105                            })
106                        })
107                        .collect()
108                })
109                .unwrap_or_else(Vec::new);
110
111            // Parse query string from URI if present
112            let (path, query_string) = if let Some(idx) = uri.find('?') {
113                (uri[..idx].to_string(), Some(uri[idx + 1..].to_string()))
114            } else {
115                (uri.to_string(), None)
116            };
117
118            // Build prompt using template engine
119            let mut data_builder = TemplateDataBuilder::new()
120                .field("protocol_name", "HTTP")
121                .field("output_format", "HTML")
122                .field("method", method)
123                .field("uri", path)
124                .field("headers", headers_array);
125
126            if let Some(qs) = query_string {
127                data_builder = data_builder.field("query_string", qs);
128            }
129
130            if !body.is_empty() {
131                data_builder = data_builder.field("body", body);
132            }
133
134            if let Some(instruction) = user_instruction {
135                data_builder = data_builder.field("user_instruction", instruction);
136            }
137
138            let template_data = data_builder.build();
139
140            // Render prompt template
141            let prompt = TEMPLATE_ENGINE
142                .render_json("easy_request/http", &template_data)
143                .context("Failed to render HTTP-easy prompt template")?;
144
145            // Get model name from app_state
146            let model = app_state
147                .get_ollama_model()
148                .await
149                .unwrap_or_else(|| "qwen2.5-coder:7b".to_string());
150
151            // Call LLM expecting Markdown response
152            let response = llm_client
153                .generate(&model, &prompt)
154                .await
155                .context("Failed to call LLM for HTTP-easy response")?;
156
157            // Convert Markdown to HTML
158            let html = markdown_to_html(&response.text);
159
160            // Generate send_http_response action
161            let action = json!({
162                "type": "send_http_response",
163                "status": 200,
164                "headers": {
165                    "Content-Type": "text/html; charset=utf-8",
166                    "X-Powered-By": "NetGet HTTP-Easy",
167                },
168                "body": html,
169            });
170
171            Ok(vec![action])
172        })
173    }
174
175    fn get_handled_event_type_ids(&self) -> Vec<&'static str> {
176        vec!["http_request"]
177    }
178
179    fn description(&self) -> &'static str {
180        "Simplified HTTP server where LLM responds in Markdown (converted to HTML)"
181    }
182}
183
184/// Convert Markdown to HTML
185///
186/// For now, this is a simple implementation. In the future, we could use
187/// a proper Markdown library like `pulldown-cmark` for full Markdown support.
188pub fn markdown_to_html(markdown: &str) -> String {
189    let mut html = String::from("<!DOCTYPE html>\n<html>\n<head>\n");
190    html.push_str("<meta charset=\"utf-8\">\n");
191    html.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n");
192    html.push_str("<style>\n");
193    html.push_str("body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; ");
194    html.push_str(
195        "max-width: 800px; margin: 2em auto; padding: 0 1em; line-height: 1.6; color: #333; }\n",
196    );
197    html.push_str("h1, h2, h3 { color: #111; margin-top: 1.5em; }\n");
198    html.push_str("h1 { border-bottom: 2px solid #eee; padding-bottom: 0.3em; }\n");
199    html.push_str("code { background: #f4f4f4; padding: 0.2em 0.4em; border-radius: 3px; font-size: 0.9em; }\n");
200    html.push_str(
201        "pre { background: #f4f4f4; padding: 1em; border-radius: 5px; overflow-x: auto; }\n",
202    );
203    html.push_str("pre code { background: none; padding: 0; }\n");
204    html.push_str("blockquote { border-left: 4px solid #ddd; padding-left: 1em; margin-left: 0; color: #666; }\n");
205    html.push_str("ul, ol { padding-left: 2em; }\n");
206    html.push_str("a { color: #0366d6; text-decoration: none; }\n");
207    html.push_str("a:hover { text-decoration: underline; }\n");
208    html.push_str("</style>\n");
209    html.push_str("</head>\n<body>\n");
210
211    // Simple Markdown-to-HTML conversion (basic implementation)
212    let lines = markdown.lines();
213    let mut in_code_block = false;
214    let mut in_list = false;
215
216    for line in lines {
217        let trimmed = line.trim();
218
219        // Code blocks (```)
220        if trimmed.starts_with("```") {
221            if in_code_block {
222                html.push_str("</code></pre>\n");
223                in_code_block = false;
224            } else {
225                if in_list {
226                    html.push_str("</ul>\n");
227                    in_list = false;
228                }
229                html.push_str("<pre><code>");
230                in_code_block = true;
231            }
232            continue;
233        }
234
235        if in_code_block {
236            html.push_str(&html_escape(line));
237            html.push('\n');
238            continue;
239        }
240
241        // Headings
242        if let Some(heading) = parse_heading(trimmed) {
243            if in_list {
244                html.push_str("</ul>\n");
245                in_list = false;
246            }
247            html.push_str(&heading);
248            html.push('\n');
249            continue;
250        }
251
252        // Lists
253        if trimmed.starts_with("- ") || trimmed.starts_with("* ") {
254            if !in_list {
255                html.push_str("<ul>\n");
256                in_list = true;
257            }
258            let content = &trimmed[2..];
259            html.push_str(&format!("<li>{}</li>\n", process_inline_markdown(content)));
260            continue;
261        }
262
263        // End list if needed
264        if in_list && !trimmed.is_empty() {
265            html.push_str("</ul>\n");
266            in_list = false;
267        }
268
269        // Paragraphs
270        if !trimmed.is_empty() {
271            html.push_str(&format!("<p>{}</p>\n", process_inline_markdown(trimmed)));
272        } else if in_list {
273            html.push_str("</ul>\n");
274            in_list = false;
275        }
276    }
277
278    // Close any open tags
279    if in_code_block {
280        html.push_str("</code></pre>\n");
281    }
282    if in_list {
283        html.push_str("</ul>\n");
284    }
285
286    html.push_str("</body>\n</html>");
287    html
288}
289
290/// Parse heading markdown (# Heading)
291fn parse_heading(line: &str) -> Option<String> {
292    if line.starts_with("# ") {
293        Some(format!("<h1>{}</h1>", html_escape(&line[2..])))
294    } else if line.starts_with("## ") {
295        Some(format!("<h2>{}</h2>", html_escape(&line[3..])))
296    } else if line.starts_with("### ") {
297        Some(format!("<h3>{}</h3>", html_escape(&line[4..])))
298    } else if line.starts_with("#### ") {
299        Some(format!("<h4>{}</h4>", html_escape(&line[5..])))
300    } else {
301        None
302    }
303}
304
305/// Render the supported inline subset with paired tags. Unmatched delimiters stay
306/// literal, and code spans are escaped without interpreting their contents as emphasis.
307fn process_inline_markdown(text: &str) -> String {
308    render_inline(text, 0)
309}
310
311fn render_inline(mut text: &str, depth: usize) -> String {
312    // Nested emphasis is input-controlled; keep both recursion and work bounded.
313    if depth == 32 {
314        return html_escape(text);
315    }
316    let mut result = String::new();
317    while !text.is_empty() {
318        let Some(start) = text.find(['`', '*', '_']) else {
319            result.push_str(&html_escape(text));
320            break;
321        };
322        result.push_str(&html_escape(&text[..start]));
323        let candidate = &text[start..];
324        let (delimiter, tag) = if candidate.starts_with('`') {
325            ("`", "code")
326        } else if candidate.starts_with("**") {
327            ("**", "strong")
328        } else if candidate.starts_with("__") {
329            ("__", "strong")
330        } else if candidate.starts_with('*') {
331            ("*", "em")
332        } else {
333            ("_", "em")
334        };
335        let rest = &candidate[delimiter.len()..];
336        // Do not treat underscores within identifiers as Markdown emphasis.
337        let in_word = delimiter.starts_with('_')
338            && text[..start]
339                .chars()
340                .next_back()
341                .is_some_and(char::is_alphanumeric);
342        if !in_word {
343            if let Some(end) = rest.find(delimiter).filter(|&end| end > 0) {
344                result.push('<');
345                result.push_str(tag);
346                result.push('>');
347                if tag == "code" {
348                    result.push_str(&html_escape(&rest[..end]));
349                } else {
350                    result.push_str(&render_inline(&rest[..end], depth + 1));
351                }
352                result.push_str("</");
353                result.push_str(tag);
354                result.push('>');
355                text = &rest[end + delimiter.len()..];
356                continue;
357            }
358        }
359        result.push_str(delimiter);
360        text = rest;
361    }
362    result
363}
364
365/// Escape HTML special characters
366fn html_escape(text: &str) -> String {
367    text.replace('&', "&amp;")
368        .replace('<', "&lt;")
369        .replace('>', "&gt;")
370        .replace('"', "&quot;")
371        .replace('\'', "&#39;")
372}
373
374/// Generate HTTP-easy prompt for testing/snapshot purposes
375///
376/// This public function generates the same prompt that would be sent to the LLM,
377/// useful for snapshot testing and debugging.
378///
379/// # Arguments
380/// * `method` - HTTP method (GET, POST, etc.)
381/// * `uri` - Request URI
382/// * `headers` - Request headers as key-value pairs
383/// * `body` - Optional request body
384/// * `user_instruction` - Optional custom instruction
385///
386/// # Returns
387/// The generated prompt string
388pub fn generate_http_easy_prompt(
389    method: &str,
390    uri: &str,
391    headers: &[(String, String)],
392    body: Option<&str>,
393    user_instruction: Option<&str>,
394) -> Result<String> {
395    // Convert headers to array for template
396    let headers_array: Vec<JsonValue> = headers
397        .iter()
398        .map(|(k, v)| {
399            json!({
400                "name": k,
401                "value": v
402            })
403        })
404        .collect();
405
406    // Parse query string from URI if present
407    let (path, query_string) = if let Some(idx) = uri.find('?') {
408        (uri[..idx].to_string(), Some(uri[idx + 1..].to_string()))
409    } else {
410        (uri.to_string(), None)
411    };
412
413    // Build template data
414    let mut data_builder = TemplateDataBuilder::new()
415        .field("protocol_name", "HTTP")
416        .field("output_format", "HTML")
417        .field("method", method)
418        .field("uri", path)
419        .field("headers", headers_array);
420
421    if let Some(qs) = query_string {
422        data_builder = data_builder.field("query_string", qs);
423    }
424
425    if let Some(body_str) = body {
426        data_builder = data_builder.field("body", body_str);
427    }
428
429    if let Some(instruction) = user_instruction {
430        data_builder = data_builder.field("user_instruction", instruction);
431    }
432
433    let template_data = data_builder.build();
434
435    // Render prompt template
436    TEMPLATE_ENGINE
437        .render_json("easy_request/http", &template_data)
438        .context("Failed to render HTTP-easy prompt template")
439}