fischer-agentkit/src/agentkit/cli/onboarding.py

389 lines
14 KiB
Python

"""Onboarding flow — interactive first-time configuration wizard.
When no agentkit.yaml exists, this wizard guides the user through:
1. Choosing an LLM provider
2. Entering API key
3. Selecting a default model
4. Generating agentkit.yaml + .env
"""
from __future__ import annotations
from pathlib import Path
import yaml
from rich.panel import Panel
from rich.prompt import Prompt, Confirm
from rich import print as rprint
from agentkit.server.config import find_config_path
# ── Provider presets ──────────────────────────────────────────────────
PROVIDER_PRESETS: dict[str, dict[str, object]] = {
"deepseek": {
"name": "DeepSeek",
"env_key": "DEEPSEEK_API_KEY",
"base_url": "https://api.deepseek.com/v1",
"type": "openai",
"models": {
"deepseek-chat": {"alias": "default"},
"deepseek-reasoner": {"alias": "reasoning"},
},
"default_model": "deepseek-chat",
},
"openai": {
"name": "OpenAI",
"env_key": "OPENAI_API_KEY",
"base_url": "https://api.openai.com/v1",
"type": "openai",
"models": {
"gpt-4o": {"alias": "default"},
"gpt-4o-mini": {"alias": "fast"},
},
"default_model": "gpt-4o",
},
"bailian-coding": {
"name": "百炼 Coding Plan",
"env_key": "DASHSCOPE_API_KEY",
"base_url": "https://coding.dashscope.aliyuncs.com/v1",
"type": "openai",
"models": {
"qwen3.7-plus": {"alias": "default"},
"qwen3.6-plus": {},
"qwen3.5-plus": {},
"qwen3-max-2026-01-23": {},
"qwen3-coder-plus": {"alias": "coder"},
"qwen3-coder-next": {},
"kimi-k2.5": {},
"glm-5": {},
"glm-4.7": {},
"MiniMax-M2.5": {},
},
"default_model": "qwen3.7-plus",
},
"qwen": {
"name": "通义千问 (Qwen/DashScope)",
"env_key": "DASHSCOPE_API_KEY",
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"type": "openai",
"models": {
"qwen-plus": {"alias": "default"},
"qwen-turbo": {"alias": "fast"},
},
"default_model": "qwen-plus",
},
"doubao": {
"name": "豆包 (Doubao)",
"env_key": "DOUBAO_API_KEY",
"base_url": "https://ark.cn-beijing.volces.com/api/v3",
"type": "openai",
"models": {
"doubao-pro-32k": {"alias": "default"},
},
"default_model": "doubao-pro-32k",
},
"gemini": {
"name": "Google Gemini",
"env_key": "GEMINI_API_KEY",
"base_url": "https://generativelanguage.googleapis.com",
"type": "gemini",
"models": {
"gemini-2.0-flash": {"alias": "default"},
},
"default_model": "gemini-2.0-flash",
},
"anthropic": {
"name": "Anthropic Claude",
"env_key": "ANTHROPIC_API_KEY",
"base_url": "https://api.anthropic.com",
"type": "anthropic",
"models": {
"claude-sonnet-4-20250514": {"alias": "default"},
},
"default_model": "claude-sonnet-4-20250514",
},
}
def needs_onboarding(config_arg: str | None = None) -> bool:
"""Check if onboarding is needed (no config file found or no valid LLM provider)."""
config_path = find_config_path(config_arg)
if config_path is None:
return True
# Config exists but has no valid LLM provider — needs onboarding to add one
from agentkit.server.config import load_config_with_dotenv
try:
config = load_config_with_dotenv(config_path)
return not config.has_llm_provider()
except Exception:
return True
def run_onboarding(
output_dir: str = ".",
config_arg: str | None = None,
) -> str | None:
"""Run the interactive onboarding wizard.
If agentkit.yaml already exists, only the LLM section is updated
(preserving all other settings). If it doesn't exist, a full config
is generated.
Returns:
Path to the generated/updated config file, or None if cancelled.
"""
# Determine output directory from config_arg if provided
if config_arg:
output_path = Path(config_arg).resolve().parent
else:
output_path = Path(output_dir).resolve()
output_path.mkdir(parents=True, exist_ok=True)
existing_config_path = find_config_path(config_arg)
existing_config: dict[str, object] | None = None
if existing_config_path:
with open(existing_config_path, encoding="utf-8") as f:
existing_config = yaml.safe_load(f) or {}
if existing_config:
rprint(
Panel(
"[bold]AgentKit Configuration Update[/bold]\n\n"
"An [cyan]agentkit.yaml[/cyan] already exists but has no valid LLM provider.\n"
"This wizard will [green]update[/green] the LLM section while preserving\n"
"your existing settings.",
title="AgentKit Setup",
border_style="bright_blue",
)
)
else:
rprint(
Panel(
"[bold]Welcome to AgentKit![/bold]\n\n"
"No configuration file found. Let's set up your first Agent.\n"
"This will create [cyan]agentkit.yaml[/cyan] and [cyan].env[/cyan] for you.",
title="AgentKit Setup",
border_style="bright_blue",
)
)
# ── Step 1: Choose LLM provider ──────────────────────────────
rprint("\n[bold]Step 1: Choose your LLM provider[/bold]")
provider_keys = list(PROVIDER_PRESETS.keys())
for i, key in enumerate(provider_keys, 1):
preset = PROVIDER_PRESETS[key]
rprint(f" [cyan]{i}[/cyan]. {preset['name']}")
choice = Prompt.ask(
"\nSelect a provider",
choices=[str(i) for i in range(1, len(provider_keys) + 1)],
default="1",
)
selected_key = provider_keys[int(choice) - 1]
preset = PROVIDER_PRESETS[selected_key]
rprint(f"\n[green]Selected: {preset['name']}[/green]")
# ── Step 2: Enter API key ─────────────────────────────────────
rprint("\n[bold]Step 2: Enter your API key[/bold]")
rprint(f"You can get one from the {preset['name']} dashboard.")
api_key = Prompt.ask(
f" {preset['env_key']}",
password=True,
)
if not api_key.strip():
rprint("[red]API key is required. Onboarding cancelled.[/red]")
return None
# ── Step 2b: Select default model ────────────────────────────
available_models = list(preset["models"].keys())
if len(available_models) > 1:
rprint("\n[bold]Step 2b: Select your default model[/bold]")
for i, model in enumerate(available_models, 1):
alias = preset["models"][model].get("alias", "")
alias_str = f" [dim]({alias})[/dim]" if alias else ""
recommended = (
" [green]← recommended[/green]" if model == preset.get("default_model") else ""
)
rprint(f" [cyan]{i}[/cyan]. {model}{alias_str}{recommended}")
model_choice = Prompt.ask(
"Select default model",
choices=[str(i) for i in range(1, len(available_models) + 1)],
default=str(
available_models.index(preset.get("default_model", available_models[0])) + 1
),
)
selected_model = available_models[int(model_choice) - 1]
# Rebuild models dict: selected model gets "default" alias
updated_models: dict[str, object] = {}
for model, conf in preset["models"].items():
if model == selected_model:
updated_models[model] = {**conf, "alias": "default"}
else:
# Remove "default" alias from other models
updated_models[model] = {
k: v for k, v in conf.items() if k != "alias" or v != "default"
}
preset = {**preset, "models": updated_models}
rprint(f"[green]Selected: {selected_model}[/green]")
else:
selected_model = available_models[0]
# ── Step 3: Optional — add a second provider ─────────────────
env_vars: dict[str, str] = {preset["env_key"]: api_key.strip()}
providers_config: dict[str, object] = {
selected_key: {
"api_key": f"${{{preset['env_key']}}}",
"base_url": preset["base_url"],
"type": preset["type"],
"models": preset["models"],
}
}
model_aliases: dict[str, str] = {
alias: f"{selected_key}/{model}"
for model, conf in preset["models"].items()
if (alias := conf.get("alias"))
}
if Confirm.ask("\nWould you like to add a second LLM provider (for fallback)?", default=False):
remaining = [k for k in provider_keys if k != selected_key]
for i, key in enumerate(remaining, 1):
rprint(f" [cyan]{i}[/cyan]. {PROVIDER_PRESETS[key]['name']}")
choice2 = Prompt.ask(
"Select second provider (or press Enter to skip)",
choices=[str(i) for i in range(1, len(remaining) + 1)] + [""],
default="",
)
if choice2:
key2 = remaining[int(choice2) - 1]
preset2 = PROVIDER_PRESETS[key2]
api_key2 = Prompt.ask(f" {preset2['env_key']}", password=True)
if api_key2.strip():
env_vars[preset2["env_key"]] = api_key2.strip()
providers_config[key2] = {
"api_key": f"${{{preset2['env_key']}}}",
"base_url": preset2["base_url"],
"type": preset2["type"],
"models": preset2["models"],
}
for model, conf in preset2["models"].items():
alias = conf.get("alias")
if alias and alias not in model_aliases:
model_aliases[alias] = f"{key2}/{model}"
# ── Step 4: Generate/update config files ─────────────────────────
rprint("\n[bold]Step 3: Generating configuration...[/bold]")
if existing_config:
# Merge: only update LLM section, preserve everything else
if "llm" not in existing_config:
existing_config["llm"] = {}
existing_config["llm"]["providers"] = providers_config
existing_config["llm"]["model_aliases"] = model_aliases
config = existing_config
else:
# New config — generate full template
config = {
"server": {
"host": "0.0.0.0",
"port": 8001,
"workers": 1,
"rate_limit": 60,
},
"llm": {
"providers": providers_config,
"model_aliases": model_aliases,
},
"session": {
"backend": "memory",
},
"bus": {
"backend": "memory",
},
"task_store": {
"backend": "memory",
},
"skills": {
"auto_discover": True,
"paths": ["./configs/skills"],
},
"logging": {
"level": "INFO",
"format": "text",
},
}
# Write agentkit.yaml
config_path = output_path / "agentkit.yaml"
with open(config_path, "w", encoding="utf-8") as f:
yaml.dump(config, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
rprint(f" [green]Created:[/green] {config_path}")
# Write .env (merge with existing)
env_path = output_path / ".env"
existing_env: dict[str, str] = {}
existing_env_lines: list[str] = []
if env_path.exists():
with open(env_path, encoding="utf-8") as f:
existing_env_lines = f.readlines()
for line in existing_env_lines:
stripped = line.strip()
if stripped and not stripped.startswith("#") and "=" in stripped:
k, _, v = stripped.partition("=")
existing_env[k.strip()] = v.strip().strip("\"'")
# Merge new env vars (new values take precedence)
existing_env.update(env_vars)
with open(env_path, "w", encoding="utf-8") as f:
f.write("# AgentKit Environment Variables\n")
f.write("# Generated by onboarding wizard\n\n")
for k, v in existing_env.items():
f.write(f"{k}={v}\n")
rprint(f" [green]Updated:[/green] {env_path}")
# ── Step 4: Agent personality (optional) ──────────────────────
rprint("\n[bold]Step 4: Customize your Agent (optional)[/bold]")
rprint(" Press Enter to use defaults, or type your preferences.")
agent_name = Prompt.ask(" Agent name", default="AgentKit")
personality = Prompt.ask(" Personality", default="专业、友好、注重细节")
speaking_style = Prompt.ask(" Speaking style", default="简洁清晰")
# Create SOUL.md
from agentkit.memory.profile import MemoryStore
memory_store = MemoryStore(base_dir=Path.home() / ".agentkit")
soul_content = f"""## 身份
我是{agent_name},一个专业的 AI 助手。
## 性格
{personality}
## 说话方式
{speaking_style}
## 做事准则
- 准确回答用户问题
- 主动记住用户提到的偏好和信息
- 不确定时坦诚说明
"""
memory_store.get_file("soul").write(soul_content.strip())
rprint(" [green]Created:[/green] ~/.agentkit/SOUL.md")
rprint(
Panel(
"[bold green]Setup complete![/bold green]\n\n"
"You can now run:\n"
" [cyan]agentkit chat[/cyan] — Start chatting with your Agent\n"
" [cyan]agentkit serve[/cyan] — Start the API server",
border_style="green",
)
)
return str(config_path)