1#![allow(clippy::missing_errors_doc, clippy::missing_panics_doc)]
7
8use axum::{
9 Router,
10 extract::{Form, Path, Query, State},
11 http::StatusCode,
12 response::Html,
13 routing::{get, post},
14};
15use std::collections::{BTreeSet, HashMap};
16use std::fmt::Write as _;
17use std::path::PathBuf;
18use std::sync::{Arc, Mutex};
19use tokio::net::TcpListener;
20
21use chrono::Local;
22
23use crate::config::{AgentConfig, ProviderConfig, Settings};
24
25struct AppState {
28 settings: Mutex<Settings>,
29 config_path: Option<PathBuf>,
30}
31
32type SharedState = Arc<AppState>;
33type HandlerResult = Result<Html<String>, (StatusCode, String)>;
34
35fn lock_settings(
36 state: &AppState,
37) -> Result<std::sync::MutexGuard<'_, Settings>, (StatusCode, String)> {
38 state
39 .settings
40 .lock()
41 .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))
42}
43
44fn collect_model_keys(settings: &Settings) -> Vec<String> {
48 let mut keys: BTreeSet<String> = BTreeSet::new();
49 for agent in &settings.agents {
50 if let Some(models) = &agent.models {
51 for key in models.keys() {
52 keys.insert(key.clone());
53 }
54 }
55 }
56 for rule in &settings.priority {
57 if let Some(model) = &rule.model {
58 keys.insert(model.clone());
59 }
60 }
61 let mut result: Vec<String> = keys.into_iter().collect();
62 result.push("(none)".to_string());
63 result
64}
65
66fn priority_value(
68 settings: &Settings,
69 agent: &AgentConfig,
70 model_key: &str,
71 now: &chrono::DateTime<Local>,
72) -> Option<i32> {
73 if model_key == "(none)" {
74 return Some(settings.priority_for_at(agent, None, now));
75 }
76 match &agent.models {
77 Some(models) if models.contains_key(model_key) => {
78 Some(settings.priority_for_at(agent, Some(model_key), now))
79 }
80 Some(_) => None,
81 None => Some(settings.priority_for_at(agent, Some(model_key), now)), }
83}
84
85fn percent_encode_query(s: &str) -> String {
86 let mut result = String::with_capacity(s.len());
87 for byte in s.bytes() {
88 match byte {
89 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
90 result.push(byte as char);
91 }
92 b => {
93 let _ = write!(result, "%{b:02X}");
94 }
95 }
96 }
97 result
98}
99
100fn escape_html(s: &str) -> String {
101 s.replace('&', "&")
102 .replace('<', "<")
103 .replace('>', ">")
104 .replace('"', """)
105}
106
107fn fmt_vec(v: &[String]) -> String {
108 v.join("\n")
109}
110
111fn fmt_map(m: &HashMap<String, String>) -> String {
112 let mut pairs: Vec<String> = m.iter().map(|(k, v)| format!("{k}={v}")).collect();
113 pairs.sort();
114 pairs.join("\n")
115}
116
117fn fmt_arg_maps(m: &HashMap<String, Vec<String>>) -> String {
118 let mut pairs: Vec<String> = m
119 .iter()
120 .map(|(k, v)| format!("{k}={}", v.join(" ")))
121 .collect();
122 pairs.sort();
123 pairs.join("\n")
124}
125
126fn provider_display(agent: &AgentConfig) -> String {
127 match &agent.provider {
128 None | Some(ProviderConfig::Inferred) => String::new(),
129 Some(ProviderConfig::Explicit(s)) => s.clone(),
130 Some(ProviderConfig::None) => "null".to_string(),
131 }
132}
133
134fn parse_vec(s: &str) -> Vec<String> {
137 s.lines()
138 .map(str::trim)
139 .filter(|l| !l.is_empty())
140 .map(String::from)
141 .collect()
142}
143
144fn parse_map(s: &str) -> HashMap<String, String> {
145 s.lines()
146 .map(str::trim)
147 .filter(|l| !l.is_empty())
148 .filter_map(|l| {
149 let (k, v) = l.split_once('=')?;
150 Some((k.trim().to_string(), v.trim().to_string()))
151 })
152 .collect()
153}
154
155fn parse_arg_maps(s: &str) -> HashMap<String, Vec<String>> {
156 s.lines()
157 .map(str::trim)
158 .filter(|l| !l.is_empty())
159 .filter_map(|l| {
160 let (k, rest) = l.split_once('=')?;
161 let vals: Vec<String> = rest.split_whitespace().map(String::from).collect();
162 Some((k.trim().to_string(), vals))
163 })
164 .collect()
165}
166
167fn non_empty_map<K, V>(m: HashMap<K, V>) -> Option<HashMap<K, V>> {
168 if m.is_empty() { None } else { Some(m) }
169}
170
171fn parse_provider(s: &str) -> Option<ProviderConfig> {
172 match s.trim() {
173 "" => None,
174 "null" => Some(ProviderConfig::None),
175 other => Some(ProviderConfig::Explicit(other.to_string())),
176 }
177}
178
179struct AgentDisplay {
182 command: String,
183 provider: String,
184 args: String,
185 models_str: String,
186 env_str: String,
187 pre_cmd: String,
188 arg_maps_str: String,
189}
190
191impl AgentDisplay {
192 fn new(agent: &AgentConfig) -> Self {
193 Self {
194 command: escape_html(&agent.command),
195 provider: escape_html(&provider_display(agent)),
196 args: escape_html(&fmt_vec(&agent.args)),
197 models_str: agent
198 .models
199 .as_ref()
200 .map_or_else(String::new, |m| escape_html(&fmt_map(m))),
201 env_str: agent
202 .env
203 .as_ref()
204 .map_or_else(String::new, |e| escape_html(&fmt_map(e))),
205 pre_cmd: escape_html(&fmt_vec(&agent.pre_command)),
206 arg_maps_str: escape_html(&fmt_arg_maps(&agent.arg_maps)),
207 }
208 }
209}
210
211fn render_agent_row(
212 idx: usize,
213 agent: &AgentConfig,
214 settings: &Settings,
215 model_keys: &[String],
216 now: &chrono::DateTime<Local>,
217) -> String {
218 let AgentDisplay {
219 command,
220 provider,
221 args,
222 models_str,
223 env_str,
224 pre_cmd,
225 arg_maps_str,
226 } = AgentDisplay::new(agent);
227
228 let priority_cells: String = model_keys
229 .iter()
230 .map(|mk| match priority_value(settings, agent, mk, now) {
231 None => r#"<td class="unavail">-</td>"#.to_string(),
232 Some(p) => format!(r#"<td class="prio">{p}</td>"#),
233 })
234 .collect();
235
236 format!(
237 r##"<tr id="agent-row-{idx}">
238 <td><span class="cmd-chip">{command}</span></td>
239 <td><span class="prov-text">{provider}</span></td>
240 <td><pre>{args}</pre></td>
241 <td><pre>{models_str}</pre></td>
242 <td><pre>{env_str}</pre></td>
243 <td><pre>{pre_cmd}</pre></td>
244 <td><pre>{arg_maps_str}</pre></td>
245 {priority_cells}
246 <td class="actions"><div class="actions-wrap">
247 <button class="btn-edit" hx-get="/agents/{idx}/edit" hx-target="#agent-row-{idx}" hx-swap="outerHTML">Edit</button>
248 <button class="btn-del" hx-delete="/agents/{idx}" hx-target="#agents-body" hx-swap="innerHTML">Del</button>
249 </div></td>
250</tr>"##
251 )
252}
253
254fn render_edit_row(
255 idx: usize,
256 agent: &AgentConfig,
257 settings: &Settings,
258 model_keys: &[String],
259 now: &chrono::DateTime<Local>,
260) -> String {
261 let AgentDisplay {
262 command,
263 provider,
264 args,
265 models_str,
266 env_str,
267 pre_cmd,
268 arg_maps_str,
269 } = AgentDisplay::new(agent);
270
271 let priority_inputs: String = model_keys
272 .iter()
273 .map(|mk| match priority_value(settings, agent, mk, now) {
274 None => r#"<td class="unavail">-</td>"#.to_string(),
275 Some(p) => {
276 let p_str = if p == 0 { String::new() } else { p.to_string() };
277 let safe_mk = escape_html(mk);
278 format!("<td><input name=\"p_{safe_mk}\" value=\"{p_str}\" placeholder=\"0\"></td>")
279 }
280 })
281 .collect();
282
283 format!(
284 r##"<tr id="agent-row-{idx}" class="editing">
285 <td><input name="command" value="{command}" placeholder="command"></td>
286 <td><input name="provider" value="{provider}" placeholder="(inferred)"></td>
287 <td><textarea name="args" rows="3">{args}</textarea></td>
288 <td><textarea name="models" rows="3">{models_str}</textarea></td>
289 <td><textarea name="env" rows="3">{env_str}</textarea></td>
290 <td><textarea name="pre_command" rows="3">{pre_cmd}</textarea></td>
291 <td><textarea name="arg_maps" rows="3">{arg_maps_str}</textarea></td>
292 {priority_inputs}
293 <td class="actions"><div class="actions-wrap">
294 <button class="btn-save" hx-put="/agents/{idx}" hx-include="closest tr" hx-target="#agents-body" hx-swap="innerHTML">Save</button>
295 <button class="btn-cancel" hx-get="/agents/{idx}" hx-target="#agent-row-{idx}" hx-swap="outerHTML">Cancel</button>
296 </div></td>
297</tr>"##
298 )
299}
300
301fn render_tbody(
302 settings: &Settings,
303 model_keys: &[String],
304 now: &chrono::DateTime<Local>,
305) -> String {
306 settings
307 .agents
308 .iter()
309 .enumerate()
310 .map(|(idx, agent)| render_agent_row(idx, agent, settings, model_keys, now))
311 .collect::<Vec<_>>()
312 .join("\n")
313}
314
315#[expect(
316 clippy::format_collect,
317 reason = "collecting formatted strings is intentional here"
318)]
319fn render_thead_model_cols(model_keys: &[String], sort_by: Option<&str>) -> String {
320 model_keys
321 .iter()
322 .map(|mk| {
323 let is_sorted = sort_by == Some(mk.as_str());
324 let class = if is_sorted {
325 r#" class="th-sorted""#
326 } else {
327 ""
328 };
329 let marker = if is_sorted { " v" } else { "" };
330 let encoded_mk = percent_encode_query(mk);
331 let escaped_mk = escape_html(mk);
332 format!("<th{class}><a href=\"/?sort={encoded_mk}\">{escaped_mk}{marker}</a></th>")
333 })
334 .collect()
335}
336
337#[expect(
338 clippy::too_many_lines,
339 reason = "single-function HTML template, splitting would harm readability"
340)]
341fn render_full_page(settings: &Settings, model_keys: &[String], sort_by: Option<&str>) -> String {
342 let now = Local::now();
343 let mut indexed: Vec<(usize, &AgentConfig)> = settings.agents.iter().enumerate().collect();
344 if let Some(sk) = sort_by {
345 indexed.sort_by_key(|(_, a)| {
346 std::cmp::Reverse(priority_value(settings, a, sk, &now).unwrap_or(i32::MIN))
347 });
348 }
349
350 let thead_model_cols = render_thead_model_cols(model_keys, sort_by);
351 let tbody: String = indexed
352 .iter()
353 .map(|(idx, agent)| render_agent_row(*idx, agent, settings, model_keys, &now))
354 .collect::<Vec<_>>()
355 .join("\n");
356
357 format!(
358 r##"<!DOCTYPE html>
359<html lang="en">
360<head>
361 <meta charset="utf-8">
362 <meta name="viewport" content="width=device-width, initial-scale=1">
363 <title>seher config</title>
364 <link rel="preconnect" href="https://fonts.googleapis.com">
365 <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600&family=Fira+Code:wght@400;500&display=swap" rel="stylesheet">
366 <script src="https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js"></script>
367 <style>
368 :root {{
369 --bg: #060b14;
370 --s0: #091220;
371 --s1: #0d1929;
372 --s2: #121f32;
373 --bd: #1a2d42;
374 --bd2: #253d58;
375 --t0: #7a9ab8;
376 --t1: #c0d4e8;
377 --t2: #e0eeff;
378 --teal: #00c9a7;
379 --teal-d: rgba(0,201,167,.12);
380 --amber: #ffc857;
381 --amber-d: rgba(255,200,87,.1);
382 --red: #ff5a5f;
383 --red-d: rgba(255,90,95,.1);
384 --green: #05d69e;
385 --green-d: rgba(5,214,158,.1);
386 }}
387 *, *::before, *::after {{ box-sizing: border-box; margin: 0; padding: 0; }}
388 html {{ scroll-behavior: smooth; }}
389 body {{
390 font-family: 'Outfit', system-ui, sans-serif;
391 background: var(--bg);
392 color: var(--t1);
393 min-height: 100vh;
394 display: flex;
395 flex-direction: column;
396 overflow-x: auto;
397 }}
398
399 /* -- Header -------------------------------------------------- */
400 .header {{
401 position: sticky; top: 0; z-index: 50;
402 background: rgba(6,11,20,.9);
403 backdrop-filter: blur(14px);
404 border-bottom: 1px solid var(--bd);
405 display: flex; align-items: center;
406 padding: 0 1.5rem; height: 52px; gap: 1rem;
407 }}
408 .logo {{
409 font-family: 'Fira Code', monospace;
410 font-weight: 500; font-size: .95rem;
411 color: var(--teal); letter-spacing: .05em;
412 display: flex; align-items: center; gap: .55rem;
413 }}
414 .logo-dot {{
415 width: 7px; height: 7px; border-radius: 50%;
416 background: var(--teal);
417 box-shadow: 0 0 6px var(--teal);
418 animation: blink 2.4s ease-in-out infinite;
419 }}
420 @keyframes blink {{
421 0%,100% {{ opacity:1; box-shadow: 0 0 6px var(--teal); }}
422 50% {{ opacity:.45; box-shadow: 0 0 2px var(--teal); }}
423 }}
424 .logo-sep {{ color: var(--bd2); margin: 0 .1rem; }}
425 .header-label {{
426 font-size: .68rem; font-weight: 400;
427 text-transform: uppercase; letter-spacing: .13em;
428 color: var(--t0);
429 }}
430 .header-right {{
431 margin-left: auto; display: flex;
432 align-items: center; gap: .75rem;
433 }}
434
435 /* -- Buttons ------------------------------------------------- */
436 button {{
437 cursor: pointer;
438 font-family: 'Outfit', sans-serif; font-weight: 500;
439 border-radius: 5px; border: 1px solid transparent;
440 transition: all .15s ease; line-height: 1; white-space: nowrap;
441 }}
442 .btn-primary {{
443 padding: .38rem 1.05rem; font-size: .8rem;
444 background: var(--amber); color: #1a0d00; border-color: var(--amber);
445 }}
446 .btn-primary:hover {{
447 background: #ffd47a;
448 box-shadow: 0 0 18px rgba(255,200,87,.4);
449 }}
450 .btn-edit {{
451 padding: .22rem .62rem; font-size: .7rem;
452 color: var(--teal); background: var(--teal-d);
453 border-color: rgba(0,201,167,.28);
454 }}
455 .btn-edit:hover {{
456 background: rgba(0,201,167,.2); border-color: var(--teal);
457 box-shadow: 0 0 8px rgba(0,201,167,.2);
458 }}
459 .btn-del {{
460 padding: .22rem .62rem; font-size: .7rem;
461 color: var(--red); background: var(--red-d);
462 border-color: rgba(255,90,95,.28);
463 }}
464 .btn-del:hover {{
465 background: rgba(255,90,95,.2); border-color: var(--red);
466 }}
467 .btn-save {{
468 padding: .22rem .62rem; font-size: .7rem;
469 color: var(--green); background: var(--green-d);
470 border-color: rgba(5,214,158,.28);
471 }}
472 .btn-save:hover {{
473 background: rgba(5,214,158,.2); border-color: var(--green);
474 }}
475 .btn-cancel {{
476 padding: .22rem .62rem; font-size: .7rem;
477 color: var(--t0); background: transparent; border-color: var(--bd);
478 }}
479 .btn-cancel:hover {{ color: var(--t1); border-color: var(--bd2); background: var(--s1); }}
480 .btn-add {{
481 padding: .38rem 1.1rem; font-size: .78rem;
482 color: var(--amber); background: transparent;
483 border: 1px dashed rgba(255,200,87,.38);
484 }}
485 .btn-add:hover {{ background: var(--amber-d); border-color: var(--amber); }}
486
487 /* -- Status badge -------------------------------------------- */
488 #status {{
489 display: inline-flex; align-items: center; gap: .35rem;
490 padding: .28rem .72rem;
491 background: var(--green-d); color: var(--green);
492 border: 1px solid rgba(5,214,158,.28);
493 border-radius: 4px; font-size: .73rem; font-weight: 500;
494 opacity: 0; pointer-events: none;
495 transition: opacity .2s ease;
496 }}
497 #status.show {{ opacity: 1; }}
498
499 /* -- Layout -------------------------------------------------- */
500 .main {{ padding: 1.25rem 1.5rem; flex: 1; min-width: 0; }}
501 .table-wrap {{
502 border: 1px solid var(--bd); border-radius: 8px;
503 overflow: auto; background: var(--s0);
504 }}
505
506 /* -- Table --------------------------------------------------- */
507 table {{ width: 100%; border-collapse: collapse; font-size: .77rem; }}
508 thead {{
509 background: var(--s2);
510 position: sticky; top: 0; z-index: 10;
511 border-bottom: 1px solid var(--bd2);
512 }}
513 th {{
514 padding: .52rem .82rem;
515 font-size: .63rem; font-weight: 600;
516 text-transform: uppercase; letter-spacing: .11em;
517 color: var(--t0); white-space: nowrap; text-align: left;
518 border-right: 1px solid var(--bd);
519 }}
520 th:last-child {{ border-right: none; }}
521 th a {{
522 color: inherit; text-decoration: none;
523 display: inline-flex; align-items: center; gap: .25rem;
524 }}
525 th a:hover {{ color: var(--teal); }}
526 .th-sorted {{ color: var(--teal) !important; }}
527 tbody tr {{ border-bottom: 1px solid var(--bd); transition: background .1s; }}
528 tbody tr:last-child {{ border-bottom: none; }}
529 tbody tr:hover {{ background: rgba(255,255,255,.016); }}
530 tbody tr.editing {{
531 background: rgba(255,200,87,.04);
532 box-shadow: inset 3px 0 0 var(--amber);
533 }}
534 td {{
535 padding: .48rem .82rem; vertical-align: top;
536 border-right: 1px solid var(--bd); color: var(--t1);
537 }}
538 td:last-child {{ border-right: none; }}
539 td.unavail {{ color: var(--bd2); text-align: center; }}
540 td.prio {{
541 text-align: center;
542 font-family: 'Fira Code', monospace; font-size: .7rem;
543 color: var(--teal); font-weight: 500;
544 }}
545 td.actions {{ white-space: nowrap; }}
546 .actions-wrap {{ display: flex; gap: .35rem; align-items: center; }}
547
548 /* -- Content cells ------------------------------------------- */
549 pre {{
550 margin: 0; white-space: pre-wrap; word-break: break-all;
551 font-family: 'Fira Code', monospace; font-size: .7rem;
552 color: var(--t1); line-height: 1.55;
553 }}
554 .cmd-chip {{
555 font-family: 'Fira Code', monospace; font-size: .7rem;
556 background: var(--teal-d); color: var(--teal);
557 border: 1px solid rgba(0,201,167,.22);
558 border-radius: 4px; padding: .14rem .52rem;
559 display: inline-block; white-space: nowrap;
560 }}
561 .prov-text {{
562 font-family: 'Fira Code', monospace; font-size: .7rem; color: var(--t0);
563 }}
564
565 /* -- Inputs -------------------------------------------------- */
566 input, textarea {{
567 background: var(--bg); border: 1px solid var(--bd);
568 border-radius: 4px; color: var(--t1);
569 font-family: 'Fira Code', monospace; font-size: .7rem;
570 padding: .3rem .5rem; width: 100%;
571 transition: border-color .15s, box-shadow .15s; resize: vertical;
572 }}
573 input:focus, textarea:focus {{
574 outline: none; border-color: var(--amber);
575 box-shadow: 0 0 0 2px rgba(255,200,87,.15);
576 }}
577 input[name="command"] {{ min-width: 96px; }}
578 input[name="provider"] {{ min-width: 78px; }}
579 input[name^="p_"] {{ width: 56px; text-align: center; }}
580 textarea {{ min-width: 110px; }}
581
582 /* -- Footer -------------------------------------------------- */
583 .footer {{ padding: .9rem 1.5rem; border-top: 1px solid var(--bd); }}
584
585 /* -- Scrollbar ----------------------------------------------- */
586 ::-webkit-scrollbar {{ width: 6px; height: 6px; }}
587 ::-webkit-scrollbar-track {{ background: var(--bg); }}
588 ::-webkit-scrollbar-thumb {{ background: var(--bd2); border-radius: 3px; }}
589 ::-webkit-scrollbar-thumb:hover {{ background: var(--t0); }}
590 </style>
591</head>
592<body>
593 <header class="header">
594 <div class="logo">
595 <span class="logo-dot"></span>
596 seher
597 </div>
598 <span class="logo-sep">/</span>
599 <span class="header-label">Config Editor</span>
600 <div class="header-right">
601 <span id="status">Saved ✓</span>
602 <button class="btn-primary"
603 hx-post="/save"
604 hx-target="#status"
605 hx-swap="innerHTML"
606 hx-on::after-request="const s=document.getElementById('status');s.classList.add('show');setTimeout(()=>s.classList.remove('show'),2600)">
607 Save to Disk
608 </button>
609 </div>
610 </header>
611 <main class="main">
612 <div class="table-wrap">
613 <table>
614 <thead>
615 <tr>
616 <th>command</th>
617 <th>provider</th>
618 <th>args</th>
619 <th>models</th>
620 <th>env</th>
621 <th>pre_command</th>
622 <th>arg_maps</th>
623 {thead_model_cols}
624 <th>actions</th>
625 </tr>
626 </thead>
627 <tbody id="agents-body">
628 {tbody}
629 </tbody>
630 </table>
631 </div>
632 </main>
633 <div class="footer">
634 <button class="btn-add" hx-post="/agents" hx-target="#agents-body" hx-swap="innerHTML">
635 + Add Agent
636 </button>
637 </div>
638</body>
639</html>"##
640 )
641}
642
643async fn index_handler(
646 State(state): State<SharedState>,
647 Query(params): Query<HashMap<String, String>>,
648) -> HandlerResult {
649 let settings = lock_settings(&state)?;
650 let sort_by = params.get("sort").map(String::as_str);
651 let model_keys = collect_model_keys(&settings);
652 Ok(Html(render_full_page(&settings, &model_keys, sort_by)))
653}
654
655async fn edit_agent_handler(
656 State(state): State<SharedState>,
657 Path(idx): Path<usize>,
658) -> HandlerResult {
659 let settings = lock_settings(&state)?;
660 let agent = settings
661 .agents
662 .get(idx)
663 .ok_or_else(|| (StatusCode::NOT_FOUND, "Agent not found".to_string()))?;
664 let model_keys = collect_model_keys(&settings);
665 let now = Local::now();
666 Ok(Html(render_edit_row(
667 idx,
668 agent,
669 &settings,
670 &model_keys,
671 &now,
672 )))
673}
674
675async fn view_agent_handler(
676 State(state): State<SharedState>,
677 Path(idx): Path<usize>,
678) -> HandlerResult {
679 let settings = lock_settings(&state)?;
680 let agent = settings
681 .agents
682 .get(idx)
683 .ok_or_else(|| (StatusCode::NOT_FOUND, "Agent not found".to_string()))?;
684 let model_keys = collect_model_keys(&settings);
685 let now = Local::now();
686 Ok(Html(render_agent_row(
687 idx,
688 agent,
689 &settings,
690 &model_keys,
691 &now,
692 )))
693}
694
695async fn update_agent_handler(
696 State(state): State<SharedState>,
697 Path(idx): Path<usize>,
698 Form(form): Form<HashMap<String, String>>,
699) -> HandlerResult {
700 let mut settings = lock_settings(&state)?;
701
702 if idx >= settings.agents.len() {
703 return Err((StatusCode::NOT_FOUND, "Agent not found".to_string()));
704 }
705
706 let command = form
707 .get("command")
708 .map_or_else(String::new, |s| s.trim().to_string());
709 let provider = parse_provider(form.get("provider").map_or("", String::as_str));
710 let args = parse_vec(form.get("args").map_or("", String::as_str));
711 let models = non_empty_map(parse_map(form.get("models").map_or("", String::as_str)));
712 let env = non_empty_map(parse_map(form.get("env").map_or("", String::as_str)));
713 let pre_command = parse_vec(form.get("pre_command").map_or("", String::as_str));
714 let arg_maps = parse_arg_maps(form.get("arg_maps").map_or("", String::as_str));
715
716 {
717 let agent = &mut settings.agents[idx];
718 agent.command = command;
719 agent.provider = provider;
720 agent.args = args;
721 agent.models = models;
722 agent.env = env;
723 agent.pre_command = pre_command;
724 agent.arg_maps = arg_maps;
725 }
726
727 let agent_command = settings.agents[idx].command.clone();
729 let agent_provider = settings.agents[idx].provider.clone();
730
731 for (key, val) in &form {
732 let Some(model_suffix) = key.strip_prefix("p_") else {
733 continue;
734 };
735 let model_key: Option<String> = if model_suffix == "(none)" {
736 None
737 } else {
738 Some(model_suffix.to_string())
739 };
740
741 let trimmed = val.trim();
742 if trimmed.is_empty() || trimmed == "0" {
743 settings.remove_priority(
744 &agent_command,
745 agent_provider.as_ref(),
746 model_key.as_deref(),
747 );
748 } else if let Ok(p) = trimmed.parse::<i32>() {
749 settings.upsert_priority(&agent_command, agent_provider.clone(), model_key, p);
750 }
751 }
752
753 let model_keys = collect_model_keys(&settings);
754 let now = Local::now();
755 Ok(Html(render_tbody(&settings, &model_keys, &now)))
756}
757
758async fn add_agent_handler(State(state): State<SharedState>) -> HandlerResult {
759 let mut settings = lock_settings(&state)?;
760 settings.agents.push(AgentConfig {
761 command: "new-agent".to_string(),
762 args: vec![],
763 models: None,
764 arg_maps: HashMap::new(),
765 env: None,
766 provider: None,
767 openrouter_management_key: None,
768 glm_api_key: None,
769 pre_command: vec![],
770 });
771 let model_keys = collect_model_keys(&settings);
772 let now = Local::now();
773 Ok(Html(render_tbody(&settings, &model_keys, &now)))
774}
775
776async fn delete_agent_handler(
777 State(state): State<SharedState>,
778 Path(idx): Path<usize>,
779) -> HandlerResult {
780 let mut settings = lock_settings(&state)?;
781 if idx >= settings.agents.len() {
782 return Err((StatusCode::NOT_FOUND, "Agent not found".to_string()));
783 }
784 settings.agents.remove(idx);
785 let model_keys = collect_model_keys(&settings);
786 let now = Local::now();
787 Ok(Html(render_tbody(&settings, &model_keys, &now)))
788}
789
790async fn save_handler(State(state): State<SharedState>) -> HandlerResult {
791 let settings = lock_settings(&state)?;
792 settings
793 .save(state.config_path.as_deref())
794 .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
795 Ok(Html("Saved ✓".to_string()))
796}
797
798pub async fn serve(
806 settings: Settings,
807 config_path: Option<PathBuf>,
808) -> Result<(), Box<dyn std::error::Error>> {
809 let state = Arc::new(AppState {
810 settings: Mutex::new(settings),
811 config_path,
812 });
813
814 let app = Router::new()
815 .route("/", get(index_handler))
816 .route("/agents/{idx}/edit", get(edit_agent_handler))
817 .route(
818 "/agents/{idx}",
819 get(view_agent_handler)
820 .put(update_agent_handler)
821 .delete(delete_agent_handler),
822 )
823 .route("/agents", post(add_agent_handler))
824 .route("/save", post(save_handler))
825 .with_state(state);
826
827 let listener = TcpListener::bind("127.0.0.1:0").await?;
828 let port = listener.local_addr()?.port();
829 eprintln!("Config editor: http://127.0.0.1:{port}");
830 let _ = open::that(format!("http://127.0.0.1:{port}"));
831 axum::serve(listener, app).await?;
832 Ok(())
833}