返回 JoyAI-Echo
onboard.py
1 """Interactive onboarding questionnaire for nanobot."""
2
3 import json
4 import types
5 from dataclasses import dataclass
6 from functools import lru_cache
7 from typing import Any, Literal, NamedTuple, get_args, get_origin
8
9 try:
10 import questionary
11 except ModuleNotFoundError: # pragma: no cover - exercised in environments without wizard deps
12 questionary = None
13 from loguru import logger
14 from pydantic import BaseModel
15 from rich.console import Console
16 from rich.panel import Panel
17 from rich.table import Table
18
19 from nanobot.cli.models import (
20 format_token_count,
21 get_model_context_limit,
22 get_model_suggestions,
23 )
24 from nanobot.config.loader import get_config_path, load_config
25 from nanobot.config.schema import Config
26
27 console = Console()
28
29
30 @dataclass
31 class OnboardResult:
32 """Result of an onboarding session."""
33
34 config: Config
35 should_save: bool
36
37 # --- Field Hints for Select Fields ---
38 # Maps field names to (choices, hint_text)
39 # To add a new select field with hints, add an entry:
40 # "field_name": (["choice1", "choice2", ...], "hint text for the field")
41 _SELECT_FIELD_HINTS: dict[str, tuple[list[str], str]] = {
42 "reasoning_effort": (
43 ["low", "medium", "high"],
44 "low / medium / high - enables LLM thinking mode",
45 ),
46 }
47
48 # --- Key Bindings for Navigation ---
49
50 _BACK_PRESSED = object() # Sentinel value for back navigation
51
52
53 def _get_questionary():
54 """Return questionary or raise a clear error when wizard deps are unavailable."""
55 if questionary is None:
56 raise RuntimeError(
57 "Interactive onboarding requires the optional 'questionary' dependency. "
58 "Install project dependencies and rerun with --wizard."
59 )
60 return questionary
61
62
63 def _select_with_back(
64 prompt: str, choices: list[str], default: str | None = None
65 ) -> str | None | object:
66 """Select with Escape/Left arrow support for going back.
67
68 Args:
69 prompt: The prompt text to display.
70 choices: List of choices to select from. Must not be empty.
71 default: The default choice to pre-select. If not in choices, first item is used.
72
73 Returns:
74 _BACK_PRESSED sentinel if user pressed Escape or Left arrow
75 The selected choice string if user confirmed
76 None if user cancelled (Ctrl+C)
77 """
78 from prompt_toolkit.application import Application
79 from prompt_toolkit.key_binding import KeyBindings
80 from prompt_toolkit.keys import Keys
81 from prompt_toolkit.layout import Layout
82 from prompt_toolkit.layout.containers import HSplit, Window
83 from prompt_toolkit.layout.controls import FormattedTextControl
84 from prompt_toolkit.styles import Style
85
86 # Validate choices
87 if not choices:
88 logger.warning("Empty choices list provided to _select_with_back")
89 return None
90
91 # Find default index
92 selected_index = 0
93 if default and default in choices:
94 selected_index = choices.index(default)
95
96 # State holder for the result
97 state: dict[str, str | None | object] = {"result": None}
98
99 # Build menu items (uses closure over selected_index)
100 def get_menu_text():
101 items = []
102 for i, choice in enumerate(choices):
103 if i == selected_index:
104 items.append(("class:selected", f"> {choice}\n"))
105 else:
106 items.append(("", f" {choice}\n"))
107 return items
108
109 # Create layout
110 menu_control = FormattedTextControl(get_menu_text)
111 menu_window = Window(content=menu_control, height=len(choices))
112
113 prompt_control = FormattedTextControl(lambda: [("class:question", f"> {prompt}")])
114 prompt_window = Window(content=prompt_control, height=1)
115
116 layout = Layout(HSplit([prompt_window, menu_window]))
117
118 # Key bindings
119 bindings = KeyBindings()
120
121 @bindings.add(Keys.Up)
122 def _up(event):
123 nonlocal selected_index
124 selected_index = (selected_index - 1) % len(choices)
125 event.app.invalidate()
126
127 @bindings.add(Keys.Down)
128 def _down(event):
129 nonlocal selected_index
130 selected_index = (selected_index + 1) % len(choices)
131 event.app.invalidate()
132
133 @bindings.add(Keys.Enter)
134 def _enter(event):
135 state["result"] = choices[selected_index]
136 event.app.exit()
137
138 @bindings.add("escape")
139 def _escape(event):
140 state["result"] = _BACK_PRESSED
141 event.app.exit()
142
143 @bindings.add(Keys.Left)
144 def _left(event):
145 state["result"] = _BACK_PRESSED
146 event.app.exit()
147
148 @bindings.add(Keys.ControlC)
149 def _ctrl_c(event):
150 state["result"] = None
151 event.app.exit()
152
153 # Style
154 style = Style.from_dict({
155 "selected": "fg:green bold",
156 "question": "fg:cyan",
157 })
158
159 app = Application(layout=layout, key_bindings=bindings, style=style)
160 try:
161 app.run()
162 except Exception:
163 logger.exception("Error in select prompt")
164 return None
165
166 return state["result"]
167
168 # --- Type Introspection ---
169
170
171 class FieldTypeInfo(NamedTuple):
172 """Result of field type introspection."""
173
174 type_name: str
175 inner_type: Any
176
177
178 def _get_field_type_info(field_info) -> FieldTypeInfo:
179 """Extract field type info from Pydantic field."""
180 annotation = field_info.annotation
181 if annotation is None:
182 return FieldTypeInfo("str", None)
183
184 origin = get_origin(annotation)
185 args = get_args(annotation)
186
187 if origin is types.UnionType:
188 non_none_args = [a for a in args if a is not type(None)]
189 if len(non_none_args) == 1:
190 annotation = non_none_args[0]
191 origin = get_origin(annotation)
192 args = get_args(annotation)
193
194 _SIMPLE_TYPES: dict[type, str] = {bool: "bool", int: "int", float: "float"}
195
196 if origin is list or (hasattr(origin, "__name__") and origin.__name__ == "List"):
197 return FieldTypeInfo("list", args[0] if args else str)
198 if origin is dict or (hasattr(origin, "__name__") and origin.__name__ == "Dict"):
199 return FieldTypeInfo("dict", None)
200 for py_type, name in _SIMPLE_TYPES.items():
201 if annotation is py_type:
202 return FieldTypeInfo(name, None)
203 if isinstance(annotation, type) and issubclass(annotation, BaseModel):
204 return FieldTypeInfo("model", annotation)
205 if origin is Literal:
206 return FieldTypeInfo("literal", list(args))
207 return FieldTypeInfo("str", None)
208
209
210 def _get_field_display_name(field_key: str, field_info) -> str:
211 """Get display name for a field."""
212 if field_info and field_info.description:
213 return field_info.description
214 name = field_key
215 suffix_map = {
216 "_s": " (seconds)",
217 "_ms": " (ms)",
218 "_url": " URL",
219 "_path": " Path",
220 "_id": " ID",
221 "_key": " Key",
222 "_token": " Token",
223 }
224 for suffix, replacement in suffix_map.items():
225 if name.endswith(suffix):
226 name = name[: -len(suffix)] + replacement
227 break
228 return name.replace("_", " ").title()
229
230
231 # --- Sensitive Field Masking ---
232
233 _SENSITIVE_KEYWORDS = frozenset({"api_key", "token", "secret", "password", "credentials"})
234
235
236 def _is_sensitive_field(field_name: str) -> bool:
237 """Check if a field name indicates sensitive content."""
238 return any(kw in field_name.lower() for kw in _SENSITIVE_KEYWORDS)
239
240
241 def _mask_value(value: str) -> str:
242 """Mask a sensitive value, showing only the last 4 characters."""
243 if len(value) <= 4:
244 return "****"
245 return "*" * (len(value) - 4) + value[-4:]
246
247
248 # --- Value Formatting ---
249
250
251 def _format_value(value: Any, rich: bool = True, field_name: str = "") -> str:
252 """Single recursive entry point for safe value display. Handles any depth."""
253 if value is None or value == "" or value == {} or value == []:
254 return "[dim]not set[/dim]" if rich else "[not set]"
255 if _is_sensitive_field(field_name) and isinstance(value, str):
256 masked = _mask_value(value)
257 return f"[dim]{masked}[/dim]" if rich else masked
258 if isinstance(value, BaseModel):
259 parts = []
260 for fname, _finfo in type(value).model_fields.items():
261 fval = getattr(value, fname, None)
262 formatted = _format_value(fval, rich=False, field_name=fname)
263 if formatted != "[not set]":
264 parts.append(f"{fname}={formatted}")
265 return ", ".join(parts) if parts else ("[dim]not set[/dim]" if rich else "[not set]")
266 if isinstance(value, list):
267 return ", ".join(str(v) for v in value)
268 if isinstance(value, dict):
269 # Handle dicts containing BaseModel instances
270 parts = []
271 for k, v in value.items():
272 formatted = _format_value(v, rich=False, field_name=str(k))
273 parts.append(f"{k}: {formatted}")
274 return ", ".join(parts) if parts else ("[dim]not set[/dim]" if rich else "[not set]")
275 return str(value)
276
277
278 def _format_value_for_input(value: Any, field_type: str) -> str:
279 """Format a value for use as input default."""
280 if value is None or value == "":
281 return ""
282 if field_type == "list" and isinstance(value, list):
283 return ",".join(str(v) for v in value)
284 if field_type == "dict" and isinstance(value, dict):
285 return json.dumps(value)
286 return str(value)
287
288
289 def _validate_field_constraint(value: Any, field_info) -> str | None:
290 """Validate a value against Pydantic Field constraints.
291
292 Returns an error message string if validation fails, None if valid.
293 Uses attribute-based detection to handle Pydantic v2 internal types.
294 """
295 if field_info is None or not hasattr(field_info, "metadata"):
296 return None
297
298 for m in field_info.metadata:
299 if hasattr(m, "ge") and isinstance(value, (int, float)):
300 if value < m.ge:
301 return f"Value must be >= {m.ge}"
302 if hasattr(m, "gt") and isinstance(value, (int, float)):
303 if value <= m.gt:
304 return f"Value must be > {m.gt}"
305 if hasattr(m, "le") and isinstance(value, (int, float)):
306 if value > m.le:
307 return f"Value must be <= {m.le}"
308 if hasattr(m, "lt") and isinstance(value, (int, float)):
309 if value >= m.lt:
310 return f"Value must be < {m.lt}"
311 if hasattr(m, "min_length") and hasattr(value, "__len__"):
312 if len(value) < m.min_length:
313 return f"Length must be >= {m.min_length}"
314 if hasattr(m, "max_length") and hasattr(value, "__len__"):
315 if len(value) > m.max_length:
316 return f"Length must be <= {m.max_length}"
317
318 return None
319
320
321 def _get_constraint_hint(field_info) -> str:
322 """Derive a human-readable constraint hint from field metadata.
323
324 Returns a string like "(0-10)" or "(>= 0)" to append to field display names.
325 """
326 if field_info is None or not hasattr(field_info, "metadata"):
327 return ""
328
329 ge_val = None
330 le_val = None
331 for m in field_info.metadata:
332 if hasattr(m, "ge"):
333 ge_val = m.ge
334 if hasattr(m, "le"):
335 le_val = m.le
336
337 if ge_val is not None and le_val is not None:
338 return f" ({ge_val}-{le_val})"
339 if ge_val is not None:
340 return f" (>= {ge_val})"
341 if le_val is not None:
342 return f" (<= {le_val})"
343 return ""
344
345
346 # --- Rich UI Components ---
347
348
349 def _show_config_panel(display_name: str, model: BaseModel, fields: list) -> None:
350 """Display current configuration as a rich table."""
351 table = Table(show_header=False, box=None, padding=(0, 2))
352 table.add_column("Field", style="cyan")
353 table.add_column("Value")
354
355 for fname, field_info in fields:
356 value = getattr(model, fname, None)
357 display = _get_field_display_name(fname, field_info)
358 formatted = _format_value(value, rich=True, field_name=fname)
359 table.add_row(display, formatted)
360
361 console.print(Panel(table, title=f"[bold]{display_name}[/bold]", border_style="blue"))
362
363
364 def _show_main_menu_header() -> None:
365 """Display the main menu header."""
366 from nanobot import __logo__, __version__
367
368 console.print()
369 # Use Align.CENTER for the single line of text
370 from rich.align import Align
371
372 console.print(
373 Align.center(f"{__logo__} [bold cyan]nanobot[{__version__}][/bold cyan]")
374 )
375 console.print()
376
377
378 def _show_section_header(title: str, subtitle: str = "") -> None:
379 """Display a section header."""
380 console.print()
381 if subtitle:
382 console.print(
383 Panel(f"[dim]{subtitle}[/dim]", title=f"[bold]{title}[/bold]", border_style="blue")
384 )
385 else:
386 console.print(Panel("", title=f"[bold]{title}[/bold]", border_style="blue"))
387
388
389 # --- Input Handlers ---
390
391
392 def _input_bool(display_name: str, current: bool | None) -> bool | None:
393 """Get boolean input via confirm dialog."""
394 return _get_questionary().confirm(
395 display_name,
396 default=bool(current) if current is not None else False,
397 ).ask()
398
399
400 def _input_text(display_name: str, current: Any, field_type: str, field_info=None) -> Any:
401 """Get text input and parse based on field type."""
402 default = _format_value_for_input(current, field_type)
403
404 value = _get_questionary().text(f"{display_name}:", default=default).ask()
405
406 if value is None or value == "":
407 return None
408
409 if field_type == "int":
410 try:
411 parsed = int(value)
412 except ValueError:
413 console.print("[yellow]! Invalid number format, value not saved[/yellow]")
414 return None
415 if field_info:
416 error = _validate_field_constraint(parsed, field_info)
417 if error:
418 console.print(f"[yellow]! {error}, value not saved[/yellow]")
419 return None
420 return parsed
421 elif field_type == "float":
422 try:
423 parsed = float(value)
424 except ValueError:
425 console.print("[yellow]! Invalid number format, value not saved[/yellow]")
426 return None
427 if field_info:
428 error = _validate_field_constraint(parsed, field_info)
429 if error:
430 console.print(f"[yellow]! {error}, value not saved[/yellow]")
431 return None
432 return parsed
433 elif field_type == "list":
434 return [v.strip() for v in value.split(",") if v.strip()]
435 elif field_type == "dict":
436 try:
437 return json.loads(value)
438 except json.JSONDecodeError:
439 console.print("[yellow]! Invalid JSON format, value not saved[/yellow]")
440 return None
441
442 return value
443
444
445 def _input_with_existing(
446 display_name: str, current: Any, field_type: str, field_info=None
447 ) -> Any:
448 """Handle input with 'keep existing' option for non-empty values."""
449 has_existing = current is not None and current != "" and current != {} and current != []
450
451 if has_existing and not isinstance(current, list):
452 choice = _get_questionary().select(
453 display_name,
454 choices=["Enter new value", "Keep existing value"],
455 default="Keep existing value",
456 ).ask()
457 if choice == "Keep existing value" or choice is None:
458 return None
459
460 return _input_text(display_name, current, field_type, field_info=field_info)
461
462
463 # --- Pydantic Model Configuration ---
464
465
466 def _get_current_provider(model: BaseModel) -> str:
467 """Get the current provider setting from a model (if available)."""
468 if hasattr(model, "provider"):
469 return getattr(model, "provider", "auto") or "auto"
470 return "auto"
471
472
473 def _input_model_with_autocomplete(
474 display_name: str, current: Any, provider: str
475 ) -> str | None:
476 """Get model input with autocomplete suggestions.
477
478 """
479 from prompt_toolkit.completion import Completer, Completion
480
481 default = str(current) if current else ""
482
483 class DynamicModelCompleter(Completer):
484 """Completer that dynamically fetches model suggestions."""
485
486 def __init__(self, provider_name: str):
487 self.provider = provider_name
488
489 def get_completions(self, document, complete_event):
490 text = document.text_before_cursor
491 suggestions = get_model_suggestions(text, provider=self.provider, limit=50)
492 for model in suggestions:
493 # Skip if model doesn't contain the typed text
494 if text.lower() not in model.lower():
495 continue
496 yield Completion(
497 model,
498 start_position=-len(text),
499 display=model,
500 )
501
502 value = _get_questionary().autocomplete(
503 f"{display_name}:",
504 choices=[""], # Placeholder, actual completions from completer
505 completer=DynamicModelCompleter(provider),
506 default=default,
507 qmark=">",
508 ).ask()
509
510 return value if value else None
511
512
513 def _input_context_window_with_recommendation(
514 display_name: str, current: Any, model_obj: BaseModel
515 ) -> int | None:
516 """Get context window input with option to fetch recommended value."""
517 current_val = current if current else ""
518
519 choices = ["Enter new value"]
520 if current_val:
521 choices.append("Keep existing value")
522 choices.append("[?] Get recommended value")
523
524 choice = _get_questionary().select(
525 display_name,
526 choices=choices,
527 default="Enter new value",
528 ).ask()
529
530 if choice is None:
531 return None
532
533 if choice == "Keep existing value":
534 return None
535
536 if choice == "[?] Get recommended value":
537 # Get the model name from the model object
538 model_name = getattr(model_obj, "model", None)
539 if not model_name:
540 console.print("[yellow]! Please configure the model field first[/yellow]")
541 return None
542
543 provider = _get_current_provider(model_obj)
544 context_limit = get_model_context_limit(model_name, provider)
545
546 if context_limit:
547 console.print(f"[green]+ Recommended context window: {format_token_count(context_limit)} tokens[/green]")
548 return context_limit
549 else:
550 console.print("[yellow]! Could not fetch model info, please enter manually[/yellow]")
551 # Fall through to manual input
552
553 # Manual input
554 value = _get_questionary().text(
555 f"{display_name}:",
556 default=str(current_val) if current_val else "",
557 ).ask()
558
559 if value is None or value == "":
560 return None
561
562 try:
563 return int(value)
564 except ValueError:
565 console.print("[yellow]! Invalid number format, value not saved[/yellow]")
566 return None
567
568
569 def _handle_model_field(
570 working_model: BaseModel, field_name: str, field_display: str, current_value: Any
571 ) -> None:
572 """Handle the 'model' field with autocomplete and context-window auto-fill."""
573 provider = _get_current_provider(working_model)
574 new_value = _input_model_with_autocomplete(field_display, current_value, provider)
575 if new_value is not None and new_value != current_value:
576 setattr(working_model, field_name, new_value)
577 _try_auto_fill_context_window(working_model, new_value)
578
579
580 def _handle_context_window_field(
581 working_model: BaseModel, field_name: str, field_display: str, current_value: Any
582 ) -> None:
583 """Handle context_window_tokens with recommendation lookup."""
584 new_value = _input_context_window_with_recommendation(
585 field_display, current_value, working_model
586 )
587 if new_value is not None:
588 setattr(working_model, field_name, new_value)
589
590
591 _FIELD_HANDLERS: dict[str, Any] = {
592 "model": _handle_model_field,
593 "context_window_tokens": _handle_context_window_field,
594 }
595
596
597 def _configure_pydantic_model(
598 model: BaseModel,
599 display_name: str,
600 *,
601 skip_fields: set[str] | None = None,
602 ) -> BaseModel | None:
603 """Configure a Pydantic model interactively.
604
605 Returns the updated model only when the user explicitly selects "Done".
606 Back and cancel actions discard the section draft.
607 """
608 skip_fields = skip_fields or set()
609 working_model = model.model_copy(deep=True)
610
611 fields = [
612 (name, info)
613 for name, info in type(working_model).model_fields.items()
614 if name not in skip_fields
615 ]
616 if not fields:
617 console.print(f"[dim]{display_name}: No configurable fields[/dim]")
618 return working_model
619
620 def get_choices() -> list[str]:
621 items = []
622 for fname, finfo in fields:
623 value = getattr(working_model, fname, None)
624 display = _get_field_display_name(fname, finfo)
625 formatted = _format_value(value, rich=False, field_name=fname)
626 items.append(f"{display}: {formatted}")
627 return items + ["[Done]"]
628
629 while True:
630 console.clear()
631 _show_config_panel(display_name, working_model, fields)
632 choices = get_choices()
633 answer = _select_with_back("Select field to configure:", choices)
634
635 if answer is _BACK_PRESSED or answer is None:
636 return None
637 if answer == "[Done]":
638 return working_model
639
640 field_idx = next((i for i, c in enumerate(choices) if c == answer), -1)
641 if field_idx < 0 or field_idx >= len(fields):
642 return None
643
644 field_name, field_info = fields[field_idx]
645 current_value = getattr(working_model, field_name, None)
646 ftype = _get_field_type_info(field_info)
647 field_display = _get_field_display_name(field_name, field_info) + _get_constraint_hint(field_info)
648
649 # Nested Pydantic model - recurse
650 if ftype.type_name == "model":
651 nested = current_value
652 created = nested is None
653 if nested is None and ftype.inner_type:
654 nested = ftype.inner_type()
655 if nested and isinstance(nested, BaseModel):
656 updated = _configure_pydantic_model(nested, field_display)
657 if updated is not None:
658 setattr(working_model, field_name, updated)
659 elif created:
660 setattr(working_model, field_name, None)
661 continue
662
663 # Registered special-field handlers
664 handler = _FIELD_HANDLERS.get(field_name)
665 if handler:
666 handler(working_model, field_name, field_display, current_value)
667 continue
668
669 # Select fields with hints (e.g. reasoning_effort)
670 if field_name in _SELECT_FIELD_HINTS:
671 choices_list, hint = _SELECT_FIELD_HINTS[field_name]
672 select_choices = choices_list + ["(clear/unset)"]
673 console.print(f"[dim] Hint: {hint}[/dim]")
674 new_value = _select_with_back(
675 field_display, select_choices, default=current_value or select_choices[0]
676 )
677 if new_value is _BACK_PRESSED:
678 continue
679 if new_value == "(clear/unset)":
680 setattr(working_model, field_name, None)
681 elif new_value is not None:
682 setattr(working_model, field_name, new_value)
683 continue
684
685 # Generic field input
686 if ftype.type_name == "literal" and ftype.inner_type:
687 select_choices = [str(v) for v in ftype.inner_type]
688 default_choice = str(current_value) if current_value in ftype.inner_type else select_choices[0]
689 new_value = _select_with_back(field_display, select_choices, default=default_choice)
690 if new_value is _BACK_PRESSED:
691 continue
692 if new_value is not None:
693 setattr(working_model, field_name, new_value)
694 continue
695 if ftype.type_name == "bool":
696 new_value = _input_bool(field_display, current_value)
697 else:
698 new_value = _input_with_existing(field_display, current_value, ftype.type_name, field_info=field_info)
699 if new_value is not None:
700 setattr(working_model, field_name, new_value)
701
702
703 def _try_auto_fill_context_window(model: BaseModel, new_model_name: str) -> None:
704 """Try to auto-fill context_window_tokens if it's at default value.
705
706 Note:
707 This function imports AgentDefaults from nanobot.config.schema to get
708 the default context_window_tokens value. If the schema changes, this
709 coupling needs to be updated accordingly.
710 """
711 # Check if context_window_tokens field exists
712 if not hasattr(model, "context_window_tokens"):
713 return
714
715 current_context = getattr(model, "context_window_tokens", None)
716
717 # Check if current value is the default (65536)
718 # We only auto-fill if the user hasn't changed it from default
719 from nanobot.config.schema import AgentDefaults
720
721 default_context = AgentDefaults.model_fields["context_window_tokens"].default
722
723 if current_context != default_context:
724 return # User has customized it, don't override
725
726 provider = _get_current_provider(model)
727 context_limit = get_model_context_limit(new_model_name, provider)
728
729 if context_limit:
730 setattr(model, "context_window_tokens", context_limit)
731 console.print(f"[green]+ Auto-filled context window: {format_token_count(context_limit)} tokens[/green]")
732 else:
733 console.print("[dim](i) Could not auto-fill context window (model not in database)[/dim]")
734
735
736 # --- Provider Configuration ---
737
738
739 @lru_cache(maxsize=1)
740 def _get_provider_info() -> dict[str, tuple[str, bool, bool, str]]:
741 """Get provider info from registry (cached)."""
742 from nanobot.providers.registry import PROVIDERS
743
744 return {
745 spec.name: (
746 spec.display_name or spec.name,
747 spec.is_gateway,
748 spec.is_local,
749 spec.default_api_base,
750 )
751 for spec in PROVIDERS
752 if not spec.is_oauth
753 }
754
755
756 def _get_provider_names() -> dict[str, str]:
757 """Get provider display names."""
758 info = _get_provider_info()
759 return {name: data[0] for name, data in info.items() if name}
760
761
762 def _configure_provider(config: Config, provider_name: str) -> None:
763 """Configure a single LLM provider."""
764 provider_config = getattr(config.providers, provider_name, None)
765 if provider_config is None:
766 console.print(f"[red]Unknown provider: {provider_name}[/red]")
767 return
768
769 display_name = _get_provider_names().get(provider_name, provider_name)
770 info = _get_provider_info()
771 default_api_base = info.get(provider_name, (None, None, None, None))[3]
772
773 if default_api_base and not provider_config.api_base:
774 provider_config.api_base = default_api_base
775
776 updated_provider = _configure_pydantic_model(
777 provider_config,
778 display_name,
779 )
780 if updated_provider is not None:
781 setattr(config.providers, provider_name, updated_provider)
782
783
784 def _configure_providers(config: Config) -> None:
785 """Configure LLM providers."""
786
787 def get_provider_choices() -> list[str]:
788 """Build provider choices with config status indicators."""
789 choices = []
790 for name, display in _get_provider_names().items():
791 provider = getattr(config.providers, name, None)
792 if provider and provider.api_key:
793 choices.append(f"{display} *")
794 else:
795 choices.append(display)
796 return choices + ["<- Back"]
797
798 while True:
799 try:
800 console.clear()
801 _show_section_header("LLM Providers", "Select a provider to configure API key and endpoint")
802 choices = get_provider_choices()
803 answer = _select_with_back("Select provider:", choices)
804
805 if answer is _BACK_PRESSED or answer is None or answer == "<- Back":
806 break
807
808 # Type guard: answer is now guaranteed to be a string
809 assert isinstance(answer, str)
810 # Extract provider name from choice (remove " *" suffix if present)
811 provider_name = answer.replace(" *", "")
812 # Find the actual provider key from display names
813 for name, display in _get_provider_names().items():
814 if display == provider_name:
815 _configure_provider(config, name)
816 break
817
818 except KeyboardInterrupt:
819 console.print("\n[dim]Returning to main menu...[/dim]")
820 break
821
822
823 # --- Channel Configuration ---
824
825
826 @lru_cache(maxsize=1)
827 def _get_channel_info() -> dict[str, tuple[str, type[BaseModel]]]:
828 """Get channel info (display name + config class) from channel modules."""
829 import importlib
830
831 from nanobot.channels.registry import discover_all
832
833 result: dict[str, tuple[str, type[BaseModel]]] = {}
834 for name, channel_cls in discover_all().items():
835 try:
836 mod = importlib.import_module(f"nanobot.channels.{name}")
837 config_name = channel_cls.__name__.replace("Channel", "Config")
838 config_cls = getattr(mod, config_name, None)
839 if config_cls and isinstance(config_cls, type) and issubclass(config_cls, BaseModel):
840 display_name = getattr(channel_cls, "display_name", name.capitalize())
841 result[name] = (display_name, config_cls)
842 except Exception:
843 logger.warning(f"Failed to load channel module: {name}")
844 return result
845
846
847 def _get_channel_names() -> dict[str, str]:
848 """Get channel display names."""
849 return {name: info[0] for name, info in _get_channel_info().items()}
850
851
852 def _get_channel_config_class(channel: str) -> type[BaseModel] | None:
853 """Get channel config class."""
854 entry = _get_channel_info().get(channel)
855 return entry[1] if entry else None
856
857
858 def _configure_channel(config: Config, channel_name: str) -> None:
859 """Configure a single channel."""
860 channel_dict = getattr(config.channels, channel_name, None)
861 if channel_dict is None:
862 channel_dict = {}
863 setattr(config.channels, channel_name, channel_dict)
864
865 display_name = _get_channel_names().get(channel_name, channel_name)
866 config_cls = _get_channel_config_class(channel_name)
867
868 if config_cls is None:
869 console.print(f"[red]No configuration class found for {display_name}[/red]")
870 return
871
872 model = config_cls.model_validate(channel_dict) if channel_dict else config_cls()
873
874 updated_channel = _configure_pydantic_model(
875 model,
876 display_name,
877 )
878 if updated_channel is not None:
879 new_dict = updated_channel.model_dump(by_alias=True, exclude_none=True)
880 setattr(config.channels, channel_name, new_dict)
881
882
883 def _configure_channels(config: Config) -> None:
884 """Configure chat channels."""
885 channel_names = list(_get_channel_names().keys())
886 choices = channel_names + ["<- Back"]
887
888 while True:
889 try:
890 console.clear()
891 _show_section_header("Chat Channels", "Select a channel to configure connection settings")
892 answer = _select_with_back("Select channel:", choices)
893
894 if answer is _BACK_PRESSED or answer is None or answer == "<- Back":
895 break
896
897 # Type guard: answer is now guaranteed to be a string
898 assert isinstance(answer, str)
899 _configure_channel(config, answer)
900 except KeyboardInterrupt:
901 console.print("\n[dim]Returning to main menu...[/dim]")
902 break
903
904
905 # --- General Settings ---
906
907 _SETTINGS_SECTIONS: dict[str, tuple[str, str, set[str] | None]] = {
908 "Agent Settings": ("Agent Defaults", "Configure default model, temperature, and behavior", None),
909 "Channel Common": ("Channel Common", "Configure cross-channel behavior: progress, tool hints, retries", None),
910 "API Server": ("API Server", "Configure OpenAI-compatible API endpoint", None),
911 "Gateway": ("Gateway Settings", "Configure server host, port, and heartbeat", None),
912 "Tools": ("Tools Settings", "Configure web search, shell exec, and other tools", {"mcp_servers"}),
913 }
914
915 _SETTINGS_GETTER = {
916 "Agent Settings": lambda c: c.agents.defaults,
917 "Channel Common": lambda c: c.channels,
918 "API Server": lambda c: c.api,
919 "Gateway": lambda c: c.gateway,
920 "Tools": lambda c: c.tools,
921 }
922
923 _SETTINGS_SETTER = {
924 "Agent Settings": lambda c, v: setattr(c.agents, "defaults", v),
925 "Channel Common": lambda c, v: setattr(c, "channels", v),
926 "API Server": lambda c, v: setattr(c, "api", v),
927 "Gateway": lambda c, v: setattr(c, "gateway", v),
928 "Tools": lambda c, v: setattr(c, "tools", v),
929 }
930
931
932 def _configure_general_settings(config: Config, section: str) -> None:
933 """Configure a general settings section (header + model edit + writeback)."""
934 meta = _SETTINGS_SECTIONS.get(section)
935 if not meta:
936 return
937 display_name, subtitle, skip = meta
938 model = _SETTINGS_GETTER[section](config)
939 updated = _configure_pydantic_model(model, display_name, skip_fields=skip)
940 if updated is not None:
941 _SETTINGS_SETTER[section](config, updated)
942
943
944 # --- Summary ---
945
946
947 def _summarize_model(obj: BaseModel) -> list[tuple[str, str]]:
948 """Recursively summarize a Pydantic model. Returns list of (field, value) tuples."""
949 items: list[tuple[str, str]] = []
950 for field_name, field_info in type(obj).model_fields.items():
951 value = getattr(obj, field_name, None)
952 if value is None or value == "" or value == {} or value == []:
953 continue
954 display = _get_field_display_name(field_name, field_info)
955 ftype = _get_field_type_info(field_info)
956 if ftype.type_name == "model" and isinstance(value, BaseModel):
957 for nested_field, nested_value in _summarize_model(value):
958 items.append((f"{display}.{nested_field}", nested_value))
959 continue
960 formatted = _format_value(value, rich=False, field_name=field_name)
961 if formatted != "[not set]":
962 items.append((display, formatted))
963 return items
964
965
966 def _print_summary_panel(rows: list[tuple[str, str]], title: str) -> None:
967 """Build a two-column summary panel and print it."""
968 if not rows:
969 return
970 table = Table(show_header=False, box=None, padding=(0, 2))
971 table.add_column("Setting", style="cyan")
972 table.add_column("Value")
973 for field, value in rows:
974 table.add_row(field, value)
975 console.print(Panel(table, title=f"[bold]{title}[/bold]", border_style="blue"))
976
977
978 def _show_summary(config: Config) -> None:
979 """Display configuration summary using rich."""
980 console.print()
981
982 # Providers
983 provider_rows = []
984 for name, display in _get_provider_names().items():
985 provider = getattr(config.providers, name, None)
986 status = "[green]configured[/green]" if (provider and provider.api_key) else "[dim]not configured[/dim]"
987 provider_rows.append((display, status))
988 _print_summary_panel(provider_rows, "LLM Providers")
989
990 # Channels
991 channel_rows = []
992 for name, display in _get_channel_names().items():
993 channel = getattr(config.channels, name, None)
994 if channel:
995 enabled = (
996 channel.get("enabled", False)
997 if isinstance(channel, dict)
998 else getattr(channel, "enabled", False)
999 )
1000 status = "[green]enabled[/green]" if enabled else "[dim]disabled[/dim]"
1001 else:
1002 status = "[dim]not configured[/dim]"
1003 channel_rows.append((display, status))
1004 _print_summary_panel(channel_rows, "Chat Channels")
1005
1006 # Settings sections
1007 for title, model in [
1008 ("Agent Settings", config.agents.defaults),
1009 ("Channel Common", config.channels),
1010 ("API Server", config.api),
1011 ("Gateway", config.gateway),
1012 ("Tools", config.tools),
1013 ]:
1014 _print_summary_panel(_summarize_model(model), title)
1015
1016 _pause()
1017
1018
1019 def _pause() -> None:
1020 """Pause for user acknowledgement before clearing the screen."""
1021 _get_questionary().text("Press Enter to continue...", default="").ask()
1022
1023
1024 # --- Main Entry Point ---
1025
1026
1027 def _has_unsaved_changes(original: Config, current: Config) -> bool:
1028 """Return True when the onboarding session has committed changes."""
1029 return original.model_dump(by_alias=True) != current.model_dump(by_alias=True)
1030
1031
1032 def _prompt_main_menu_exit(has_unsaved_changes: bool) -> str:
1033 """Resolve how to leave the main menu."""
1034 if not has_unsaved_changes:
1035 return "discard"
1036
1037 answer = _get_questionary().select(
1038 "You have unsaved changes. What would you like to do?",
1039 choices=[
1040 "[S] Save and Exit",
1041 "[X] Exit Without Saving",
1042 "[R] Resume Editing",
1043 ],
1044 default="[R] Resume Editing",
1045 qmark=">",
1046 ).ask()
1047
1048 if answer == "[S] Save and Exit":
1049 return "save"
1050 if answer == "[X] Exit Without Saving":
1051 return "discard"
1052 return "resume"
1053
1054
1055 def run_onboard(initial_config: Config | None = None) -> OnboardResult:
1056 """Run the interactive onboarding questionnaire.
1057
1058 Args:
1059 initial_config: Optional pre-loaded config to use as starting point.
1060 If None, loads from config file or creates new default.
1061 """
1062 _get_questionary()
1063
1064 if initial_config is not None:
1065 base_config = initial_config.model_copy(deep=True)
1066 else:
1067 config_path = get_config_path()
1068 if config_path.exists():
1069 base_config = load_config()
1070 else:
1071 base_config = Config()
1072
1073 original_config = base_config.model_copy(deep=True)
1074 config = base_config.model_copy(deep=True)
1075
1076 while True:
1077 console.clear()
1078 _show_main_menu_header()
1079
1080 try:
1081 answer = _get_questionary().select(
1082 "What would you like to configure?",
1083 choices=[
1084 "[P] LLM Provider",
1085 "[C] Chat Channel",
1086 "[H] Channel Common",
1087 "[A] Agent Settings",
1088 "[I] API Server",
1089 "[G] Gateway",
1090 "[T] Tools",
1091 "[V] View Configuration Summary",
1092 "[S] Save and Exit",
1093 "[X] Exit Without Saving",
1094 ],
1095 qmark=">",
1096 ).ask()
1097 except KeyboardInterrupt:
1098 answer = None
1099
1100 if answer is None:
1101 action = _prompt_main_menu_exit(_has_unsaved_changes(original_config, config))
1102 if action == "save":
1103 return OnboardResult(config=config, should_save=True)
1104 if action == "discard":
1105 return OnboardResult(config=original_config, should_save=False)
1106 continue
1107
1108 _MENU_DISPATCH = {
1109 "[P] LLM Provider": lambda: _configure_providers(config),
1110 "[C] Chat Channel": lambda: _configure_channels(config),
1111 "[H] Channel Common": lambda: _configure_general_settings(config, "Channel Common"),
1112 "[A] Agent Settings": lambda: _configure_general_settings(config, "Agent Settings"),
1113 "[I] API Server": lambda: _configure_general_settings(config, "API Server"),
1114 "[G] Gateway": lambda: _configure_general_settings(config, "Gateway"),
1115 "[T] Tools": lambda: _configure_general_settings(config, "Tools"),
1116 "[V] View Configuration Summary": lambda: _show_summary(config),
1117 }
1118
1119 if answer == "[S] Save and Exit":
1120 return OnboardResult(config=config, should_save=True)
1121 if answer == "[X] Exit Without Saving":
1122 return OnboardResult(config=original_config, should_save=False)
1123
1124 action_fn = _MENU_DISPATCH.get(answer)
1125 if action_fn:
1126 action_fn()
1127
1127 lines PYTHON