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
13pub 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 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 if event_type.id != "http_request" {
74 return Ok(vec![]);
75 }
76
77 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 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 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 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 let prompt = TEMPLATE_ENGINE
142 .render_json("easy_request/http", &template_data)
143 .context("Failed to render HTTP-easy prompt template")?;
144
145 let model = app_state
147 .get_ollama_model()
148 .await
149 .unwrap_or_else(|| "qwen2.5-coder:7b".to_string());
150
151 let response = llm_client
153 .generate(&model, &prompt)
154 .await
155 .context("Failed to call LLM for HTTP-easy response")?;
156
157 let html = markdown_to_html(&response.text);
159
160 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
184pub 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 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 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 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 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 if in_list && !trimmed.is_empty() {
265 html.push_str("</ul>\n");
266 in_list = false;
267 }
268
269 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 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
290fn 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
305fn process_inline_markdown(text: &str) -> String {
308 render_inline(text, 0)
309}
310
311fn render_inline(mut text: &str, depth: usize) -> String {
312 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 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
365fn html_escape(text: &str) -> String {
367 text.replace('&', "&")
368 .replace('<', "<")
369 .replace('>', ">")
370 .replace('"', """)
371 .replace('\'', "'")
372}
373
374pub 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 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 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 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 TEMPLATE_ENGINE
437 .render_json("easy_request/http", &template_data)
438 .context("Failed to render HTTP-easy prompt template")
439}