diff --git a/.gitignore b/.gitignore
index c96ea54..3b88f71 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,3 +32,5 @@ configs/simulation_runs/
configs/agent_experiment_runs/
configs/agent_config_*.yaml
configs/cached/
+
+.pnpm-store/
\ No newline at end of file
diff --git a/README.md b/README.md
index b80fdcd..05002f2 100644
--- a/README.md
+++ b/README.md
@@ -92,6 +92,11 @@
git clone https://github.com/spire-studio/figaro.git
cd figaro
uv sync
+
+cp .env.example .env
+# Edit .env and set:
+# OPENAI_API_KEY=your-api-key
+# POSTGRES_PASSWORD=postgres
```
## 🚀 Quick Start
@@ -110,15 +115,7 @@ docker run -d --name figaro-pg -p 5433:5432 \
-e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=figaro postgres:16
```
-**Step 2 — Configure `.env`**:
-```bash
-cp .env.example .env
-# Edit .env and set:
-# OPENAI_API_KEY=your-api-key
-# POSTGRES_PASSWORD=postgres
-```
-
-**Step 3 — Backend**:
+**Step 2 — Backend**:
```bash
POSTGRES_HOST=localhost POSTGRES_PORT=5433 POSTGRES_PASSWORD=postgres POSTGRES_DB=figaro \
PYTHONPATH=libs:apps/backend/runners \
@@ -130,7 +127,7 @@ To use a specific GPU (e.g. GPU 1):
CUDA_VISIBLE_DEVICES=1 POSTGRES_HOST=localhost ... uv run uvicorn ...
```
-**Step 4 — Frontend** (in another terminal):
+**Step 3 — Frontend** (in another terminal):
```bash
cd apps/frontend && pnpm install && pnpm dev
```
@@ -207,14 +204,27 @@ figaro/
PRs welcome! Figaro is meant to be a readable, research-friendly FL platform.
-**Roadmap** — tentative, contributions welcome:
-
-- [ ] **Richer agent planning** — multi-step reflection and failure recovery in the LangGraph pipeline
-- [ ] **More aggregation strategies** — FedProx, FedAvgM, Scaffold on top of the existing FedAvg baseline
-- [ ] **Hardened distributed mode** — fault tolerance, client reconnection, and heterogeneous workers
-- [ ] **Expanded datasets & models** — beyond CIFAR-10 / MNIST and CNN / ResNet
-- [ ] **End-to-end reproducibility** — deterministic seeds, artifact lineage, and one-click replay
-- [ ] **Observability** — per-run metrics dashboard and structured logs
+**Roadmap**:
+
+**Phase 1: Solidifying the Agentic Platform**
+- [x] **Interactive Agent Planning** — Multi-turn dialogue support for refining experiments, plus visual topology previews (Plan Preview) before execution.
+- [x] **Execution Transparency** — Real-time tracking of node-level status during execution and automated natural-language interpretation of results.
+- [ ] **Strict Configuration Engine** — Implement strict Pydantic/JSON Schema validation to resolve historical inconsistencies between `config_schema` and underlying algorithms.
+- [ ] **Advanced Experiment Tracking** — Multi-dimensional search filtering (by metrics, hyperparameters, status) and configuration version control (diffing).
+
+**Phase 2: LLM & LoRA Federated Fine-Tuning**
+- [ ] **Native LLM Ecosystem Integration** — Seamless Hugging Face model loading (e.g., Llama 3, Qwen) and efficient parsing of JSONL instruction-tuning datasets.
+- [ ] **Parameter-Efficient Runtime** — Deep integration with LoRA/PEFT, including support for QLoRA (4-bit/8-bit quantization) to lower client-side memory barriers.
+- [ ] **Specialized Adapter Aggregation** — Custom aggregation mechanisms for LoRA adapters, exploring support for heterogeneous LoRA ranks across clients.
+- [ ] **LLM Evaluation Metrics** — Built-in evaluation for generative tasks (Rouge, BLEU, Perplexity) and automated LLM-as-a-Judge capabilities.
+- [ ] **Hardware Guardrails** — Pre-run dynamic GPU memory estimation (OOM prevention) and automated tuning of gradient accumulation and checkpointing.
+
+**Phase 3: Enterprise & Team Collaboration**
+- [ ] **Multi-Tenant Workspaces** — Isolated project environments with Role-Based Access Control (RBAC) and comprehensive audit logging.
+- [ ] **Robust Distributed Scheduling** — Global GPU resource queuing, quota management, and enhanced fault tolerance for client reconnections/dropouts.
+- [ ] **Cloud-Native Infrastructure** — Native Kubernetes (K8s) Runner integration with auto-scaling workers based on queue volume.
+- [ ] **Model Asset Registry** — Centralized Artifact Registry to track complete data lineage from dataset versions to final aggregated weights.
+- [ ] **Compliance & Governance** — Automated privacy compliance reporting (e.g., auditing Differential Privacy parameters) to ensure enterprise-grade security.
Figaro is for research and educational use.
diff --git a/README.zh-CN.md b/README.zh-CN.md
index d97145d..64e4396 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -93,6 +93,11 @@
git clone https://github.com/spire-studio/figaro.git
cd figaro
uv sync
+
+cp .env.example .env
+# 编辑 .env,设置:
+# OPENAI_API_KEY=your-api-key
+# POSTGRES_PASSWORD=postgres
```
## 🚀 快速开始
@@ -111,15 +116,7 @@ docker run -d --name figaro-pg -p 5433:5432 \
-e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=figaro postgres:16
```
-**第 2 步 — 配置 `.env`**:
-```bash
-cp .env.example .env
-# 编辑 .env,设置:
-# OPENAI_API_KEY=your-api-key
-# POSTGRES_PASSWORD=postgres
-```
-
-**第 3 步 — 后端**:
+**第 2 步 — 后端**:
```bash
POSTGRES_HOST=localhost POSTGRES_PORT=5433 POSTGRES_PASSWORD=postgres POSTGRES_DB=figaro \
PYTHONPATH=libs:apps/backend/runners \
@@ -131,7 +128,7 @@ POSTGRES_HOST=localhost POSTGRES_PORT=5433 POSTGRES_PASSWORD=postgres POSTGRES_D
CUDA_VISIBLE_DEVICES=1 POSTGRES_HOST=localhost ... uv run uvicorn ...
```
-**第 4 步 — 前端**(在另一个终端):
+**第 3 步 — 前端**(在另一个终端):
```bash
cd apps/frontend && pnpm install && pnpm dev
```
@@ -208,14 +205,27 @@ figaro/
欢迎提 PR!Figaro 的目标是做一个可读性强、研究友好的联邦学习平台。
-**Roadmap**(暂定,欢迎贡献):
-
-- [ ] **更丰富的 Agent 规划** —— LangGraph 流水线加入多步反思和失败恢复
-- [ ] **更多聚合策略** —— 在现有 FedAvg 之上补充 FedProx、FedAvgM、Scaffold
-- [ ] **分布式模式加固** —— 容错、客户端重连、异构 worker 支持
-- [ ] **扩展数据集和模型** —— 突破 CIFAR-10 / MNIST 和 CNN / ResNet 的范围
-- [ ] **端到端可复现** —— 确定性种子、产物血缘、一键回放
-- [ ] **可观测性** —— 单实验指标面板与结构化日志
+**Roadmap**:
+
+**Phase 1:夯实 Agentic 实验平台**
+- [x] **Agent 交互体验升级** —— 支持多轮对话微调实验计划,提供实验执行前的Plan Preview。
+- [x] **执行与分析透明化** —— 支持实验节点的实时状态追踪,以及 Agent 驱动的运行结果自动化图表解释。
+- [ ] **配置引擎重构** —— 引入基于 Pydantic/JSON Schema 的严格强校验,彻底修复 `config_schema` 与底层算法实现不一致的问题。
+- [ ] **高阶实验管理** —— 支持按指标、超参等多维度搜索过滤实验历史,支持配置文件版本控制与 Diff 差异对比。
+
+**Phase 2:LLM / LoRA 联邦微调支持**
+- [ ] **大模型生态原生接入** —— 内置 Hugging Face 适配层,一键加载主流开源模型,支持 JSONL 格式的指令微调数据集高效解析。
+- [ ] **高效分布式微调** —— 深度集成 LoRA/PEFT 训练环境,支持 QLoRA (4-bit/8-bit 量化) 以显著降低边缘节点的显存门槛。
+- [ ] **专属权重聚合策略** —— 针对 LLM 微调定制的 Adapter 权重聚合机制,探索支持客户端异构 LoRA Rank 的聚合方案。
+- [ ] **大模型专项评估体系** —— 集成生成式 NLP 指标,并引入基于大模型的自动化指令跟随能力评估 (LLM-as-a-Judge)。
+- [ ] **资源护栏与预判** —— 训练任务启动前进行动态 GPU 显存预估(防 OOM 机制),并支持梯度累积与 Checkpointing 的自动调优。
+
+**Phase 3:平台化与企业级协作**
+- [ ] **多租户与细粒度权限** —— 构建多用户隔离的项目空间 (Workspaces),引入基于角色的访问控制 (RBAC) 和完整的操作审计日志。
+- [ ] **生产级调度与容错** —— 实现全局 GPU 资源排队与配额限制,增强分布式环境下的客户端掉线重连与死机容错机制。
+- [ ] **云原生基础设施** —— 提供原生的 Kubernetes (K8s) Runner 支持,支持基于排队任务量的 Worker 节点动态扩缩容。
+- [ ] **模型资产与血缘管理** —— 建立集中式的产物注册表 (Artifact Registry),追踪从数据集版本到最终模型权重的完整数据血缘 (Lineage)。
+- [ ] **治理与安全合规** —— 提供自动化的隐私合规检查与报告生成(例如审计差分隐私的 $\epsilon$ 参数),确保企业级联邦学习的数据安全。
Figaro 仅用于科研和教学目的。
diff --git a/apps/backend/app/api/v1/endpoints/agent.py b/apps/backend/app/api/v1/endpoints/agent.py
index 2265a3f..63c5888 100644
--- a/apps/backend/app/api/v1/endpoints/agent.py
+++ b/apps/backend/app/api/v1/endpoints/agent.py
@@ -4,7 +4,8 @@
from __future__ import annotations
-from fastapi import APIRouter, status
+from fastapi import APIRouter, status, HTTPException
+import json
from app.api.deps import AsyncSessionDep
from app.schemas.agent import (
@@ -27,6 +28,8 @@
from app.services.agent.graph import FederatedAgentGraphBuilder
from app.services.agent.objectives import AgentOptimizationObjective, resolve_objective
from app.services.llm import LLMRegistry, LLMService
+from app.schemas.agent import AgentPlanPreviewRequest, AgentPlanPreviewResponse, ExperimentPlanPreview, AgentPlanReviseRequest
+import uuid
agent_router = APIRouter()
agent_runtime_service = AgentRuntimeService()
@@ -143,6 +146,7 @@ def _build_progress_response(snapshot: dict) -> AgentOptimizeProgressResponse:
best_config=snapshot.get("best_config"),
best_metrics=best_metrics,
experiments=_build_experiments(snapshot.get("experiments", [])),
+ draft_experiments=snapshot.get("draft_experiments", []),
summary_text=snapshot.get("summary_text"),
error_message=snapshot.get("error_message"),
created_at=snapshot.get("created_at"),
@@ -243,6 +247,7 @@ async def start_optimization(
system_mode=payload.system_mode,
model_name=payload.model_name,
objective=payload.objective,
+ planned_experiments=payload.planned_experiments,
)
return _build_progress_response(snapshot)
@@ -463,3 +468,145 @@ async def get_agent_run_logs(
)
for log in logs
]
+
+
+@agent_router.post(
+ "/optimize/plan",
+ response_model=AgentPlanPreviewResponse,
+ status_code=status.HTTP_200_OK,
+ summary="Generate a plan preview and save as draft",
+)
+async def generate_plan_preview(
+ payload: AgentPlanPreviewRequest,
+ session: AsyncSessionDep,
+) -> AgentPlanPreviewResponse:
+ """
+ 1. Call LLM to generate the plan.
+ 2. Create a Job record with PENDING_REVIEW status.
+ """
+ # Borrow the graph builder to do the LLM parsing
+ llm_service = LLMService()
+ builder = FederatedAgentGraphBuilder(llm_service=llm_service, session=session)
+
+ # Construct a dummy initial state to run through the parse node
+ temp_state = AgentState(
+ goal=payload.goal,
+ job_name=payload.job_name,
+ model_name=payload.model_name,
+ system_mode=payload.system_mode,
+ max_iterations=10,
+ objective=AgentOptimizationObjective.AUTO,
+ resolved_objective=AgentOptimizationObjective.ACCURACY,
+ )
+
+ # Call the graph's parse node directly to get the LLM result
+ parsed_state = await builder._node_parse(temp_state)
+
+ previews = []
+ for exp in parsed_state.experiments:
+ previews.append(ExperimentPlanPreview(
+ name=exp.name,
+ plan_summary=exp.plan_summary or "",
+ config_patch=exp.config_patch,
+ # Fallback values if LLM doesn't generate them
+ estimated_minutes=0,
+ estimated_gpu_vram_gb=0.0,
+ ))
+
+ # Persist the draft (write snapshot to DB, set status to pending_review)
+ history_service = AgentOptimizationHistoryService(session)
+
+ job = await history_service.create_job(
+ task_id=f"draft-{uuid.uuid4().hex[:8]}",
+ goal=payload.goal,
+ job_name=payload.job_name,
+ model_name=payload.model_name,
+ system_mode=payload.system_mode,
+ max_iterations=len(previews) or 1,
+ status="pending_review",
+ snapshot={
+ "goal": payload.goal,
+ "job_name": payload.job_name,
+ "status": "pending_review",
+ "experiments": [],
+ "draft_experiments": [p.model_dump() for p in previews]
+ }
+ )
+
+ return AgentPlanPreviewResponse(
+ optimization_job_id=job.id,
+ goal=payload.goal,
+ experiments=previews,
+ system_mode=payload.system_mode
+ )
+
+@agent_router.post(
+ "/optimization-jobs/{job_id}/revise",
+ response_model=AgentPlanPreviewResponse,
+ status_code=status.HTTP_200_OK,
+ summary="Revise a pending plan draft using natural language",
+)
+async def revise_plan_preview(
+ job_id: int,
+ payload: AgentPlanReviseRequest,
+ session: AsyncSessionDep,
+) -> AgentPlanPreviewResponse:
+ history_service = AgentOptimizationHistoryService(session)
+ job = await history_service.get_job_or_raise(job_id)
+
+ current_status = job.status.value if hasattr(job.status, "value") else str(job.status)
+ if current_status != "pending_review":
+ raise HTTPException(status_code=400, detail="Only pending_review jobs can be revised.")
+
+ snapshot = job.snapshot_json
+ current_experiments = snapshot.get("draft_experiments", [])
+ if not current_experiments:
+ raise HTTPException(status_code=400, detail="No draft experiments found to revise.")
+
+ instructions = (
+ "You are an AI assistant managing a Federated Learning experiment plan. "
+ "The user wants to modify the current experiment configuration based on their feedback. "
+ "Update the JSON configuration to reflect their request. "
+ "Respond ONLY with a valid JSON array of experiment objects, matching the original schema. "
+ "Do not include any other text."
+ )
+
+ input_text = (
+ f"Current Plan (JSON):\n{json.dumps(current_experiments, indent=2)}\n\n"
+ f"User Modification Request:\n{payload.instruction}\n\n"
+ "Please output the updated JSON array of experiments:"
+ )
+
+ llm_service = LLMService()
+ model_name = snapshot.get("model_name")
+ text, _ = await llm_service.generate_text(
+ model=model_name if model_name else None,
+ instructions=instructions,
+ input_text=input_text,
+ )
+
+ clean_text = text.strip()
+ if clean_text.startswith("```json"):
+ clean_text = clean_text[7:]
+ elif clean_text.startswith("```"):
+ clean_text = clean_text[3:]
+ if clean_text.endswith("```"):
+ clean_text = clean_text[:-3]
+ clean_text = clean_text.strip()
+
+ try:
+ updated_experiments = json.loads(clean_text)
+ if not isinstance(updated_experiments, list):
+ raise ValueError("LLM did not return a list.")
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"Failed to parse LLM response: {str(e)}")
+
+ snapshot["draft_experiments"] = updated_experiments
+ await history_service.update_job_snapshot(task_id=job.task_id, snapshot=snapshot)
+
+ return AgentPlanPreviewResponse(
+ optimization_job_id=job.id,
+ goal=snapshot.get("goal", ""),
+ experiments=[ExperimentPlanPreview.model_validate(exp) for exp in updated_experiments],
+ system_mode=snapshot.get("system_mode", "simulation")
+ )
\ No newline at end of file
diff --git a/apps/backend/app/core/db.py b/apps/backend/app/core/db.py
index 0298e43..85a856c 100644
--- a/apps/backend/app/core/db.py
+++ b/apps/backend/app/core/db.py
@@ -72,9 +72,9 @@ async def init_db() -> None:
async with engine.begin() as conn:
# In dev mode we hard-reset postgres schema so renamed tables/types are
# fully cleaned up (including legacy enum dependencies).
- if conn.dialect.name == "postgresql":
- await conn.execute(text("DROP SCHEMA IF EXISTS public CASCADE"))
- await conn.execute(text("CREATE SCHEMA public"))
- else:
- await conn.run_sync(SQLModel.metadata.drop_all)
+ # if conn.dialect.name == "postgresql":
+ # await conn.execute(text("DROP SCHEMA IF EXISTS public CASCADE"))
+ # await conn.execute(text("CREATE SCHEMA public"))
+ # else:
+ # await conn.run_sync(SQLModel.metadata.drop_all)
await conn.run_sync(SQLModel.metadata.create_all)
diff --git a/apps/backend/app/models/agent/optimization_jobs.py b/apps/backend/app/models/agent/optimization_jobs.py
index bde431c..8848516 100644
--- a/apps/backend/app/models/agent/optimization_jobs.py
+++ b/apps/backend/app/models/agent/optimization_jobs.py
@@ -21,6 +21,7 @@ class AgentOptimizationJobStatus(str, Enum):
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
+ PENDING_REVIEW = "pending_review"
class AgentOptimizationJob(SQLModel, table=True):
diff --git a/apps/backend/app/models/distributed/sessions.py b/apps/backend/app/models/distributed/sessions.py
index 65fcd41..fd9bd1c 100644
--- a/apps/backend/app/models/distributed/sessions.py
+++ b/apps/backend/app/models/distributed/sessions.py
@@ -37,7 +37,7 @@ class DistributedSession(SQLModel, table=True):
sa_column=Column(Integer, ForeignKey("distributed_jobs.id"), nullable=False, index=True),
)
server_ip: str = Field(
- default="127.0.0.1",
+ default="localhost",
sa_column=Column(String(DistributedLimits.SERVER_IP_MAX_LENGTH), nullable=False),
)
server_port: int = Field(
diff --git a/apps/backend/app/schemas/agent.py b/apps/backend/app/schemas/agent.py
index 89ea8e4..f11614f 100644
--- a/apps/backend/app/schemas/agent.py
+++ b/apps/backend/app/schemas/agent.py
@@ -41,6 +41,10 @@ class AgentOptimizeRequest(BaseModel):
default=AgentOptimizationObjective.AUTO,
description="Objective for the experiment batch. 'accuracy' maximizes accuracy; 'auto' defaults to accuracy.",
)
+ planned_experiments: Optional[list[dict[str, Any]]] = Field(
+ default=None,
+ description="Explicit list of experiment patches to run, bypassing LLM parse."
+ )
class AgentModelsResponse(BaseModel):
@@ -143,6 +147,7 @@ class AgentOptimizeProgressResponse(BaseModel):
best_config: dict[str, Any] | None = None
best_metrics: SimulationRunMetricsResponse | None = None
experiments: list[AgentExperimentSummary] = Field(default_factory=list)
+ draft_experiments: list[dict[str, Any]] = Field(default_factory=list)
summary_text: str | None = None
error_message: str | None = None
created_at: datetime | None = None
@@ -215,3 +220,31 @@ class AgentRunMetricsResponse(BaseModel):
run_id: str
metrics: dict[str, Any] = Field(default_factory=dict)
+
+
+class ExperimentPlanPreview(BaseModel):
+ """Single experiment draft for frontend UI display and editing."""
+ name: str
+ plan_summary: str
+ config_patch: dict[str, Any]
+ estimated_minutes: int = Field(default=0, description="Estimated time in minutes")
+ estimated_gpu_vram_gb: float = Field(default=0.0, description="Estimated VRAM in GB")
+ risk_warnings: list[str] = Field(default_factory=list)
+
+class AgentPlanPreviewRequest(BaseModel):
+ """Payload for requesting a draft generation without execution."""
+ goal: str = Field(..., description="Natural-language experiment request.")
+ job_name: str = Field(..., description="Name for the job group.")
+ model_name: Optional[str] = None
+ system_mode: str = "simulation"
+
+class AgentPlanPreviewResponse(BaseModel):
+ """Draft results returned to the frontend."""
+ optimization_job_id: int
+ goal: str
+ experiments: list[ExperimentPlanPreview]
+ system_mode: str
+
+class AgentPlanReviseRequest(BaseModel):
+ """Payload for requesting a revision to an existing plan draft."""
+ instruction: str = Field(..., description="Natural language feedback to revise the plan.")
\ No newline at end of file
diff --git a/apps/backend/app/schemas/distributed/sessions.py b/apps/backend/app/schemas/distributed/sessions.py
index d658712..1d3f738 100644
--- a/apps/backend/app/schemas/distributed/sessions.py
+++ b/apps/backend/app/schemas/distributed/sessions.py
@@ -17,7 +17,7 @@ class DistributedSessionCreateRequest(BaseModel):
Request payload for creating or getting distributed session.
"""
- server_ip: str = "127.0.0.1"
+ server_ip: str = "localhost"
server_port: int = 50052
diff --git a/apps/backend/app/services/agent/experiment_service.py b/apps/backend/app/services/agent/experiment_service.py
index f37afca..601e932 100644
--- a/apps/backend/app/services/agent/experiment_service.py
+++ b/apps/backend/app/services/agent/experiment_service.py
@@ -33,6 +33,10 @@
)
from app.repositories.agent import AgentExperimentRepository
+from app.core.logger import get_logger
+
+logger = get_logger(__name__)
+
# ---------------------------------------------------------------------------
# Shared constants (mirrored from simulation run_service / run_metrics_service)
@@ -472,11 +476,9 @@ async def get_run_metrics(self, run_id: str) -> dict[str, Any]:
run = await self.repo.get_run(run_id)
if not run:
raise exceptions.RunNotFound("Run not found")
-
# If terminal and already has metrics, return them
if run.status in TERMINAL_RUN_STATUSES and isinstance(run.metrics_json, dict) and run.metrics_json:
return self._normalize_metrics_payload(run.metrics_json)
-
# Try live result file
live_result_path = self._results_dir() / self._build_live_results_filename(run_id)
loaded = self._load_metrics_file(live_result_path)
diff --git a/apps/backend/app/services/agent/graph.py b/apps/backend/app/services/agent/graph.py
index 3e6fcd6..cdaabd8 100644
--- a/apps/backend/app/services/agent/graph.py
+++ b/apps/backend/app/services/agent/graph.py
@@ -13,6 +13,9 @@
import uuid
from collections.abc import Awaitable, Callable
+from app.core.logger import get_logger
+logger = get_logger(__name__)
+
from langgraph.graph import END, StateGraph # type: ignore[import-untyped]
from sqlalchemy.ext.asyncio import AsyncSession
@@ -71,6 +74,25 @@ async def _node_parse(self, state: AgentState) -> AgentState:
state.phase = "parsing"
await self._publish_progress(state)
+ if getattr(state, "planned_experiments", None):
+ experiments: list[ExperimentPlan] = []
+ for idx, exp in enumerate(state.planned_experiments):
+ name = exp.get("name", f"exp-{idx + 1}")
+ experiments.append(
+ ExperimentPlan(
+ iteration=idx + 1,
+ iteration_goal=f"Run experiment: {name}",
+ name=name,
+ plan_summary=exp.get("plan_summary", ""),
+ config_patch=exp.get("config_patch", {}),
+ )
+ )
+ state.experiments = experiments
+ state.max_iterations = len(experiments)
+ state.phase = "parsed"
+ await self._publish_progress(state)
+ return state
+
experiment_service = AgentExperimentService(self._session)
schema = experiment_service.get_config_schema()
capabilities = get_platform_capabilities()
@@ -83,16 +105,30 @@ async def _node_parse(self, state: AgentState) -> AgentState:
base_config=base_config,
)
+ logger.info("llm input instructions=%s", instructions)
+ logger.info("llm input prompt=%s", prompt)
+
text, _ = await self._llm.generate_text(
model=select_llm_model(state.model_name),
instructions=instructions,
input_text=prompt,
)
+ logger.info("llm raw output=%s", text)
+
+ clean_text = text.strip()
+ if clean_text.startswith("```json"):
+ clean_text = clean_text[7:]
+ elif clean_text.startswith("```"):
+ clean_text = clean_text[3:]
+ if clean_text.endswith("```"):
+ clean_text = clean_text[:-3]
+ clean_text = clean_text.strip()
+
# Parse LLM response into experiment plans
experiments: list[ExperimentPlan] = []
try:
- parsed = json.loads(text)
+ parsed = json.loads(clean_text)
if isinstance(parsed, dict):
plan_summary = parsed.get("plan_summary", "")
raw_experiments = parsed.get("experiments", [])
@@ -101,14 +137,16 @@ async def _node_parse(self, state: AgentState) -> AgentState:
if not isinstance(exp, dict):
continue
name = exp.get("name", f"exp-{idx + 1}")
- config = exp.get("config", {})
- if not isinstance(config, dict):
- config = {}
+ patch_config = exp.get("config", {})
+ if not isinstance(patch_config, dict):
+ patch_config = {}
# Merge with base config and normalize
- merged = self._deep_merge_dicts(base_config, config)
+ current_full_config = copy.deepcopy(base_config)
+ merged = self._deep_merge_dicts(current_full_config, patch_config)
try:
normalized = experiment_service.normalize_simulation_config(merged)
- except exceptions.BadRequestError:
+ except Exception:
+ # 如果合并出错,至少保证能跑,回退到基础配置
normalized = experiment_service.normalize_simulation_config(
copy.deepcopy(base_config)
)
@@ -118,7 +156,7 @@ async def _node_parse(self, state: AgentState) -> AgentState:
iteration_goal=f"Run experiment: {name}",
name=name,
plan_summary=plan_summary,
- config_patch=config,
+ config_patch=normalized,
)
)
except (json.JSONDecodeError, Exception):
diff --git a/apps/backend/app/services/agent/history_service.py b/apps/backend/app/services/agent/history_service.py
index 637132a..7dce611 100644
--- a/apps/backend/app/services/agent/history_service.py
+++ b/apps/backend/app/services/agent/history_service.py
@@ -37,9 +37,15 @@ async def create_job(
model_name: str | None,
max_iterations: int,
snapshot: dict[str, Any],
+ status: AgentOptimizationJobStatus | str = AgentOptimizationJobStatus.QUEUED,
) -> AgentOptimizationJob:
if job_name:
await self._ensure_unique_job_name(job_name)
+
+ resolved_status = status
+ if isinstance(status, str):
+ resolved_status = AgentOptimizationJobStatus(status.lower())
+
try:
job = await self.repository.create_job(
task_id=task_id,
@@ -47,7 +53,7 @@ async def create_job(
goal=goal,
system_mode=system_mode,
model_name=model_name,
- status=AgentOptimizationJobStatus.QUEUED,
+ status=resolved_status,
max_iterations=max_iterations,
snapshot_json=self._to_json_safe(snapshot),
)
@@ -120,8 +126,12 @@ async def mark_job_failed(
async def _ensure_unique_job_name(self, job_name: str, *, exclude_job_id: int | None = None) -> None:
existing = await self.repository.get_job_by_name(job_name)
if existing and (exclude_job_id is None or existing.id != exclude_job_id):
- raise exceptions.JobAlreadyExists(self.JOB_NAME_CONFLICT_MESSAGE)
-
+ current_status = existing.status.value if hasattr(existing.status, "value") else str(existing.status)
+ if current_status.lower() == "pending_review":
+ await self.session.delete(existing)
+ await self.session.flush()
+ else:
+ raise exceptions.JobAlreadyExists(self.JOB_NAME_CONFLICT_MESSAGE)
@staticmethod
def _extract_simulation_job_id(snapshot: dict[str, Any]) -> int | None:
current_experiment = snapshot.get("current_experiment")
diff --git a/apps/backend/app/services/agent/runtime_service.py b/apps/backend/app/services/agent/runtime_service.py
index 79661f3..5a3335b 100644
--- a/apps/backend/app/services/agent/runtime_service.py
+++ b/apps/backend/app/services/agent/runtime_service.py
@@ -39,6 +39,7 @@ async def start_optimization(
model_name: str | None,
job_name: str | None,
objective: AgentOptimizationObjective,
+ planned_experiments: list[dict[str, Any]],
) -> dict[str, Any]:
"""
Create a background experiment task and return its initial snapshot.
@@ -98,6 +99,7 @@ async def start_optimization(
job_name=job_name,
objective=objective,
resolved_objective=resolved_objective,
+ planned_experiments=planned_experiments,
)
)
task.add_done_callback(lambda finished_task, current_task_id=task_id: self._on_task_done(current_task_id, finished_task))
@@ -124,6 +126,7 @@ async def _run_task(
job_name: str | None,
objective: AgentOptimizationObjective,
resolved_objective: AgentOptimizationObjective,
+ planned_experiments: list[dict[str, Any]] | None = None,
) -> None:
"""Execute one experiment task in the background."""
logger.info("agent_task_started task_id=%s", task_id)
@@ -138,6 +141,7 @@ async def _run_task(
objective=objective,
resolved_objective=resolved_objective,
phase="parsing",
+ planned_experiments=planned_experiments,
)
await self._update_from_state(task_id, initial_state, status="running")
async with AsyncSessionLocal() as session:
diff --git a/apps/backend/app/services/agent/state.py b/apps/backend/app/services/agent/state.py
index e34ba4d..911274b 100644
--- a/apps/backend/app/services/agent/state.py
+++ b/apps/backend/app/services/agent/state.py
@@ -102,3 +102,5 @@ class AgentState:
history: list[ExperimentRecord] = field(default_factory=list)
terminated: bool = False
summary: str | None = None
+
+ planned_experiments: list[dict[str, Any]] | None = None
diff --git a/apps/backend/app/services/agent/summary.py b/apps/backend/app/services/agent/summary.py
index 2fac63e..04e0b5c 100644
--- a/apps/backend/app/services/agent/summary.py
+++ b/apps/backend/app/services/agent/summary.py
@@ -137,3 +137,23 @@ def build_lessons_learned(
) -> list[str]:
"""Bench mode does not generate iterative lessons."""
return []
+
+def calculate_communication_cost(config: dict[str, Any]) -> int:
+ """Estimate communication cost: total rounds * clients per round."""
+ federated = config.get("federated") or {}
+ rounds = int(federated.get("num_rounds", 0))
+ clients = int(federated.get("clients_per_round", 0))
+ return rounds * clients
+
+def calculate_computation_cost(config: dict[str, Any]) -> int:
+ """Estimate computation cost: total rounds * clients per round * local epochs."""
+ federated = config.get("federated") or {}
+ epochs = int(federated.get("local_epochs", 0))
+ return calculate_communication_cost(config) * epochs
+
+def get_best_experiment(state: AgentState) -> ExperimentRecord | None:
+ """Find the best performing experiment (the Winner)."""
+ scored = [r for r in state.experiment_results if r.score is not None]
+ if not scored:
+ return None
+ return max(scored, key=lambda r: r.score)
\ No newline at end of file
diff --git a/apps/backend/app/services/llm.py b/apps/backend/app/services/llm.py
index 88e28db..00caa25 100644
--- a/apps/backend/app/services/llm.py
+++ b/apps/backend/app/services/llm.py
@@ -56,6 +56,14 @@ class LLMRegistry:
"name": "gpt-4o",
"llm": _build_openai_model("gpt-4o"),
},
+ {
+ "name": "bytedance-seed/seed-2.0-mini",
+ "llm": _build_openai_model("bytedance-seed/seed-2.0-mini"),
+ },
+ {
+ "name": "moonshotai/kimi-k2.5",
+ "llm": _build_openai_model("moonshotai/kimi-k2.5"),
+ }
]
@classmethod
diff --git a/apps/backend/tests/api/v1/endpoints/test_agent.py b/apps/backend/tests/api/v1/endpoints/test_agent.py
index 30db778..cefb45a 100644
--- a/apps/backend/tests/api/v1/endpoints/test_agent.py
+++ b/apps/backend/tests/api/v1/endpoints/test_agent.py
@@ -155,12 +155,14 @@ def build():
def test_agent_optimize_start_endpoint_returns_live_progress(client, monkeypatch):
import app.api.v1.endpoints.agent as agent_module
- async def _fake_start_optimization(*, goal, max_iterations, system_mode, model_name, job_name, objective):
+ # ADD `planned_experiments` here 👇
+ async def _fake_start_optimization(*, goal, max_iterations, system_mode, model_name, job_name, objective, planned_experiments):
assert goal == "live optimize"
assert max_iterations == 3
assert system_mode == "simulation"
assert model_name == "gpt-live"
assert job_name == "opt-job-live"
+ assert planned_experiments is None # Optional: verify the default value is passed
return {
"task_id": "task-1",
"status": "running",
diff --git a/apps/backend/tests/api/v1/endpoints/test_distributed.py b/apps/backend/tests/api/v1/endpoints/test_distributed.py
index 00cbf77..0a9ed4b 100644
--- a/apps/backend/tests/api/v1/endpoints/test_distributed.py
+++ b/apps/backend/tests/api/v1/endpoints/test_distributed.py
@@ -23,7 +23,7 @@ def test_distributed_session_lifecycle(client):
created_session = client.post(
f"/api/v1/distributed/jobs/{job_id}/session",
- json={"server_ip": "127.0.0.1", "server_port": 50052},
+ json={"server_ip": "localhost", "server_port": 50052},
)
assert created_session.status_code == 201
session_id = created_session.json()["id"]
@@ -102,7 +102,7 @@ def test_distributed_start_requires_all_clients_ready(client):
created_session = client.post(
f"/api/v1/distributed/jobs/{job_id}/session",
- json={"server_ip": "127.0.0.1", "server_port": 50052},
+ json={"server_ip": "localhost", "server_port": 50052},
)
assert created_session.status_code == 201
session_id = created_session.json()["id"]
diff --git a/apps/backend/tests/core/test_config.py b/apps/backend/tests/core/test_config.py
index ca7dab9..b05f308 100644
--- a/apps/backend/tests/core/test_config.py
+++ b/apps/backend/tests/core/test_config.py
@@ -10,7 +10,7 @@ def _base_settings_kwargs() -> dict:
"PROJECT_NAME": "Figaro",
"API_PREFIX": "/api/v1",
"FRONTEND_URL": "http://localhost:5173",
- "ALLOWED_ORIGINS": "http://localhost:5173, http://127.0.0.1:5173",
+ "ALLOWED_ORIGINS": "http://localhost:5173, http://localhost:5173",
"POSTGRES_HOST": "localhost",
"POSTGRES_PORT": 5432,
"POSTGRES_DB": "figaro",
diff --git a/apps/backend/tests/core/test_exception_handlers.py b/apps/backend/tests/core/test_exception_handlers.py
index 385975f..e339d87 100644
--- a/apps/backend/tests/core/test_exception_handlers.py
+++ b/apps/backend/tests/core/test_exception_handlers.py
@@ -19,7 +19,7 @@ def _build_request(path: str = "/test") -> Request:
"raw_path": path.encode("utf-8"),
"query_string": b"",
"headers": [],
- "client": ("127.0.0.1", 12345),
+ "client": ("localhost", 12345),
"server": ("testserver", 80),
}
return Request(scope)
diff --git a/apps/backend/tests/models/test_models.py b/apps/backend/tests/models/test_models.py
index 9d08999..2a52ff8 100644
--- a/apps/backend/tests/models/test_models.py
+++ b/apps/backend/tests/models/test_models.py
@@ -46,7 +46,7 @@ def test_distributed_models_have_expected_defaults():
assert job.status == DistributedJobStatus.DRAFT
assert job.expected_clients == 1
assert session.status == DistributedSessionStatus.WAITING_CLIENTS
- assert session.server_ip == "127.0.0.1"
+ assert session.server_ip == "localhost"
assert session.server_port == 50052
assert len(session.id) == 8
assert participant.status == DistributedParticipantStatus.PENDING_APPROVAL
diff --git a/apps/backend/tests/schemas/test_contracts.py b/apps/backend/tests/schemas/test_contracts.py
index ba6b545..488a136 100644
--- a/apps/backend/tests/schemas/test_contracts.py
+++ b/apps/backend/tests/schemas/test_contracts.py
@@ -33,7 +33,7 @@ def test_basic_request_schema_defaults():
sim_cfg = SimulationJobConfigUpdateRequest(config={"x": 1})
sim_copy = SimulationJobCopyRequest()
- assert dist_session.server_ip == "127.0.0.1"
+ assert dist_session.server_ip == "localhost"
assert dist_session.server_port == 50052
assert dist_cfg.config == {}
assert message.detail is None
@@ -64,7 +64,7 @@ def test_from_attributes_response_models_round_trip():
dist_session = DistributedSession(
id="sess0001",
job_id=2,
- server_ip="127.0.0.1",
+ server_ip="localhost",
server_port=50052,
)
diff --git a/apps/frontend/.env.example b/apps/frontend/.env.example
index 52caf89..1d9b979 100644
--- a/apps/frontend/.env.example
+++ b/apps/frontend/.env.example
@@ -1,2 +1,2 @@
-VITE_API_BASE_URL=http://127.0.0.1:8000
-OPENAPI_URL=http://127.0.0.1:8000/openapi.json
+VITE_API_BASE_URL=http://localhost:8000
+OPENAPI_URL=http://localhost:8000/openapi.json
diff --git a/apps/frontend/README.md b/apps/frontend/README.md
index 2c21b42..25051f0 100644
--- a/apps/frontend/README.md
+++ b/apps/frontend/README.md
@@ -23,7 +23,7 @@ This updates `src/api/openapi.d.ts` from FastAPI OpenAPI schema.
pnpm run dev
```
-Frontend uses `VITE_API_BASE_URL` (default: `http://127.0.0.1:8000`).
+Frontend uses `VITE_API_BASE_URL` (default: `http://localhost:8000`).
## Pages/Features
diff --git a/apps/frontend/package.json b/apps/frontend/package.json
index d59a501..c5d3584 100644
--- a/apps/frontend/package.json
+++ b/apps/frontend/package.json
@@ -8,8 +8,8 @@
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
- "gen:api": "openapi-typescript ${OPENAPI_URL:-http://127.0.0.1:8000/openapi.json} -o src/api/openapi.d.ts"
- },
+ "gen:api": "openapi-typescript ${OPENAPI_URL:-http://localhost:8000/openapi.json} -o src/api/openapi.d.ts"
+ },
"dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.16",
diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx
index caeaace..3faca2f 100644
--- a/apps/frontend/src/App.tsx
+++ b/apps/frontend/src/App.tsx
@@ -1,58 +1,74 @@
-import { useState } from "react";
-import { BriefcaseBusiness, Loader2 } from "lucide-react";
-
+import { BriefcaseBusiness, Sparkles, LayoutDashboard, LineChart } from "lucide-react";
import { Card, CardContent } from "./components/ui/card";
import { Tabs, TabsList, TabsTrigger } from "./components/ui/tabs";
+
import { useAgentController } from "./features/agent/useAgentController";
-import { useDistributedController } from "./features/distributed/useDistributedController";
-import { useSimulationController } from "./features/simulation/useSimulationController";
-import { AgentPage } from "./pages/AgentPage";
-import { DistributedPage } from "./pages/DistributedPage";
-import { SimulationPage } from "./pages/SimulationPage";
-import type { PageMode } from "./pages/types";
+import { AgentExperimentStudio } from "./features/agent/components/AgentExperimentStudio";
+import { AgentPlanPreview } from "./features/agent/components/AgentPlanPreview";
+import { AgentRunDashboard } from "./features/agent/components/AgentRunDashboard";
+import { AgentResultsCompare } from "./features/agent/components/AgentResultsCompare";
export default function App() {
- const [pageMode, setPageMode] = useState("agent");
- const simulationPageProps = useSimulationController();
- const agentPageProps = useAgentController();
- const distributedPageProps = useDistributedController({
- pageMode,
- configSchema: simulationPageProps.configSchema,
- });
- const busy = simulationPageProps.busy || distributedPageProps.busy || agentPageProps.busy;
+ const agentProps = useAgentController();
+ const { workflowStep, setWorkflowStep, draftPlan } = agentProps;
+
+ const activeTab =
+ (workflowStep === "home" || workflowStep === "preview") ? "studio" :
+ (workflowStep === "running") ? "dashboard" :
+ "results";
+
+ const handleTabChange = (val: string) => {
+ if (val === "studio") {
+ setWorkflowStep(draftPlan ? "preview" : "home");
+ } else if (val === "dashboard") {
+ setWorkflowStep("running");
+ } else if (val === "results") {
+ setWorkflowStep("results");
+ }
+ };
return (
-
-
-
+
+
+
+ {/* 顶层全局导航栏 */}
+
-
-
Figaro Control Center
+
+
+
+
+ Figaro Agent Studio
+
+
- setPageMode(value as PageMode)}>
-
- Agent
- Simulation
- Distributed
+
+
+
+ Agent Studio
+
+
+ Run Dashboard
+
+
+ Results & History
+
- {pageMode === "simulation" &&
}
- {pageMode === "distributed" &&
}
- {pageMode === "agent" &&
}
-
- {busy && (
-
-
- Processing...
-
- )}
+ {/* 动态内容渲染区 */}
+
+ {activeTab === "studio" && workflowStep === "home" &&
}
+ {activeTab === "studio" && workflowStep === "preview" &&
}
+ {activeTab === "dashboard" &&
}
+ {activeTab === "results" &&
}
+
);
-}
+}
\ No newline at end of file
diff --git a/apps/frontend/src/api/agent.ts b/apps/frontend/src/api/agent.ts
index d5e61ac..3a67e27 100644
--- a/apps/frontend/src/api/agent.ts
+++ b/apps/frontend/src/api/agent.ts
@@ -90,6 +90,7 @@ export type AgentOptimizeProgressResponse = {
best_config: Record | null;
best_metrics: RunMetrics | null;
experiments: AgentExperimentSummary[];
+ draft_experiments?: any[];
summary_text: string | null;
error_message: string | null;
created_at: string | null;
@@ -153,6 +154,33 @@ export type AgentRunLogResponse = {
created_at: string;
};
+export interface ExperimentPlanPreview {
+ name: string;
+ plan_summary: string;
+ config_patch: Record;
+ estimated_minutes?: number;
+ estimated_gpu_vram_gb?: number;
+ risk_warnings?: string[];
+};
+
+export interface AgentPlanPreviewRequest {
+ goal: string;
+ job_name: string;
+ model_name: string | null;
+ system_mode: string;
+};
+
+export interface AgentPlanPreviewResponse {
+ optimization_job_id: number;
+ goal: string;
+ experiments: ExperimentPlanPreview[];
+ system_mode: string;
+};
+
+export interface AgentPlanReviseRequest {
+ instruction: string;
+};
+
async function readJson(response: Response): Promise {
if (!response.ok) {
throw new Error(await getHttpErrorMessage(response));
@@ -235,4 +263,35 @@ export const agentApi = {
const response = await fetch(`${baseUrl}/api/v1/agent/runs/${encodeURIComponent(runId)}/logs`);
return readJson(response);
},
-};
+
+ async generatePlan(request: AgentPlanPreviewRequest): Promise {
+ const response = await fetch(`${baseUrl}/api/v1/agent/optimize/plan`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(request),
+ });
+ return readJson(response);
+ },
+
+ async startOptimizeFromDraft(request: any): Promise {
+ const response = await fetch(`${baseUrl}/api/v1/agent/optimize/start`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(request),
+ });
+ return readJson(response);
+ },
+
+ async revisePlan(jobId: number, request: AgentPlanReviseRequest): Promise {
+ const response = await fetch(`${baseUrl}/api/v1/agent/optimization-jobs/${encodeURIComponent(jobId)}/revise`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(request),
+ });
+ if (!response.ok) {
+ const err = await response.json().catch(() => ({}));
+ throw new Error(err.detail || "Failed to revise plan");
+ }
+ return response.json();
+ },
+};
\ No newline at end of file
diff --git a/apps/frontend/src/api/client.ts b/apps/frontend/src/api/client.ts
index 2924daf..1c53624 100644
--- a/apps/frontend/src/api/client.ts
+++ b/apps/frontend/src/api/client.ts
@@ -1,7 +1,7 @@
import createClient from "openapi-fetch";
import type { paths } from "./openapi";
-export const baseUrl = import.meta.env.VITE_API_BASE_URL ?? "http://127.0.0.1:8000";
+export const baseUrl = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
export const api = createClient({
baseUrl
diff --git a/apps/frontend/src/api/runs.ts b/apps/frontend/src/api/runs.ts
index 50c30fc..799cddd 100644
--- a/apps/frontend/src/api/runs.ts
+++ b/apps/frontend/src/api/runs.ts
@@ -24,7 +24,7 @@ export type RunMetrics = {
client_results: Record;
};
-const baseUrl = import.meta.env.VITE_API_BASE_URL ?? "http://127.0.0.1:8000";
+const baseUrl = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
async function readJson(response: Response): Promise {
if (!response.ok) {
diff --git a/apps/frontend/src/components/ui/separator.tsx b/apps/frontend/src/components/ui/separator.tsx
index b49016d..fa85fbe 100644
--- a/apps/frontend/src/components/ui/separator.tsx
+++ b/apps/frontend/src/components/ui/separator.tsx
@@ -1,3 +1,10 @@
-export function Separator() {
- return
;
-}
+import * as React from "react";
+
+export function Separator({ className, ...props }: React.HTMLAttributes) {
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/apps/frontend/src/features/agent/components/AgentExperimentStudio.tsx b/apps/frontend/src/features/agent/components/AgentExperimentStudio.tsx
new file mode 100644
index 0000000..887a3b8
--- /dev/null
+++ b/apps/frontend/src/features/agent/components/AgentExperimentStudio.tsx
@@ -0,0 +1,73 @@
+import { Sparkles, ArrowRight, Loader2 } from "lucide-react";
+import { Button } from "../../../components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "../../../components/ui/card";
+import { Textarea } from "../../../components/ui/textarea";
+import { Input } from "../../../components/ui/input";
+import type { AgentPageProps } from "../../../pages/types";
+
+export function AgentExperimentStudio(props: AgentPageProps) {
+ const { goal, setGoal, jobName, setJobName, presets, busy, handleGeneratePlan } = props;
+
+ return (
+
+
+
Figaro Studio
+
Describe your federated learning goals. The agent will design the configuration.
+
+
+
+
+
+ Job Name
+ setJobName(e.target.value)}
+ placeholder="e.g., resnet-cifar-tuning"
+ />
+
+
+
+ Experiment Goal
+
+
+
+ {presets.map((preset, idx) => (
+ setGoal(preset)}
+ className="text-xs px-3 py-1.5 rounded-full bg-muted hover:bg-muted/80 transition-colors text-muted-foreground"
+ >
+ {preset}
+
+ ))}
+
+
+
+
+
{ void handleGeneratePlan(); }}
+ >
+ {busy ? (
+ <>
+
+ Generating Plan...
+ >
+ ) : (
+ <>
+
+ Generate Experiment Plan
+ >
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/frontend/src/features/agent/components/AgentPlanPreview.tsx b/apps/frontend/src/features/agent/components/AgentPlanPreview.tsx
new file mode 100644
index 0000000..9ff9f2d
--- /dev/null
+++ b/apps/frontend/src/features/agent/components/AgentPlanPreview.tsx
@@ -0,0 +1,196 @@
+import { useState } from "react";
+import { Play, Clock, Cpu, AlertTriangle, Sparkles, MessageSquare, ClipboardList, Target } from "lucide-react";
+import { Button } from "../../../components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "../../../components/ui/card";
+import { Badge } from "../../../components/ui/badge";
+import { Input } from "../../../components/ui/input";
+import { Textarea } from "../../../components/ui/textarea";
+import { toast } from "sonner";
+import { agentApi } from "../../../api/agent";
+import type { AgentPageProps } from "../../../pages/types";
+
+export function AgentPlanPreview(props: AgentPageProps) {
+ const { draftPlan, busy: globalBusy, handleExecutePlan, setWorkflowStep, setDraftPlan } = props;
+
+ const [localExperiments, setLocalExperiments] = useState(() =>
+ draftPlan?.experiments ? JSON.parse(JSON.stringify(draftPlan.experiments)) : []
+ );
+ const [instruction, setInstruction] = useState("");
+ const [isRevising, setIsRevising] = useState(false);
+
+ if (!draftPlan) return null;
+ const isBusy = globalBusy || isRevising;
+
+ const handleRevise = async () => {
+ if (!instruction.trim() || !draftPlan.job_id) return;
+ setIsRevising(true);
+ try {
+ const response = await agentApi.revisePlan(draftPlan.job_id, { instruction });
+ setLocalExperiments(response.experiments);
+ setDraftPlan({
+ ...draftPlan,
+ experiments: response.experiments
+ });
+ setInstruction("");
+ toast.success("Plan updated successfully by Agent!");
+ } catch (error: any) {
+ toast.error(error.message || "Failed to update plan via Agent.");
+ } finally {
+ setIsRevising(false);
+ }
+ };
+
+ const handleConfigChange = (index: number, newJsonString: string) => {
+ const updated = [...localExperiments];
+ try {
+ updated[index].config_patch = JSON.parse(newJsonString);
+ updated[index]._jsonError = false;
+ } catch (e) {
+ updated[index]._jsonError = true;
+ }
+ updated[index]._rawJsonString = newJsonString;
+ setLocalExperiments(updated);
+ };
+
+ const onRunClick = () => {
+ const hasErrors = localExperiments.some((exp: any) => exp._jsonError);
+ if (hasErrors) {
+ toast.error("Please fix invalid JSON formatting before running.");
+ return;
+ }
+ const cleanExperiments = localExperiments.map((exp: any) => {
+ const { _rawJsonString, _jsonError, ...rest } = exp;
+ return rest;
+ });
+ void handleExecutePlan(cleanExperiments);
+ };
+
+ const experiments = draftPlan.experiments || [];
+ const totalMinutes = experiments.reduce((acc, curr) => acc + (curr.estimated_minutes || 0), 0);
+
+ return (
+
+
+
+
Review & Edit Plan
+
The agent proposed {localExperiments.length} configurations to achieve your goal.
+
+
+
setWorkflowStep("home")} disabled={isBusy}>
+ Back
+
+
+ Execute Plan
+
+
+
+
+
+
+
+
Experiment Goal
+
{draftPlan.goal}
+
+
+
+
+
+
+
+
+ setInstruction(e.target.value)}
+ onKeyDown={(e) => { if (e.key === "Enter") void handleRevise(); }}
+ disabled={isBusy}
+ className="flex-1 bg-background"
+ />
+ { void handleRevise(); }} disabled={isBusy || !instruction.trim()} className="shrink-0">
+ {isRevising ? : }
+ Ask Agent
+
+
+
+
+ {/* Resource Estimation Summary */}
+ {/*
+
+
+
+
+
Est. Total Time
+
{totalMinutes > 0 ? `~${totalMinutes} mins` : "Unknown"}
+
+
+
+
+
+
+
+
Peak VRAM Required
+
+ {experiments.length > 0 && experiments[0].estimated_gpu_vram_gb
+ ? `~${experiments[0].estimated_gpu_vram_gb} GB` : "Unknown"}
+
+
+
+
+
+
+
+
+
System Mode
+
Simulation
+
+
+
+
*/}
+
+
+
+
+
+ Proposed Execution Plan
+
+
+ The agent has defined the following {localExperiments.length} steps to execute. Edit the config patch manually or use the AI input above.
+
+
+
+
+ {localExperiments.map((exp: any, idx: number) => {
+ const jsonString = exp._rawJsonString ?? JSON.stringify(exp.config_patch, null, 2);
+ return (
+
+
+
+
+
+ Step {idx + 1}
+ {exp.name}
+
+
+
+
{exp.plan_summary}
+
+
+
Configuration Parameters
+
+
+
+ )
+ })}
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/frontend/src/features/agent/components/AgentResultsCompare.tsx b/apps/frontend/src/features/agent/components/AgentResultsCompare.tsx
new file mode 100644
index 0000000..10f12cd
--- /dev/null
+++ b/apps/frontend/src/features/agent/components/AgentResultsCompare.tsx
@@ -0,0 +1,230 @@
+import { useEffect, useState } from "react";
+import { History, Bot, Activity, CheckCircle2, Trophy, ArrowRight, Target, Calendar } from "lucide-react";
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "../../../components/ui/card";
+import { Badge } from "../../../components/ui/badge";
+import { Button } from "../../../components/ui/button";
+import { Separator } from "../../../components/ui/separator";
+import { MiniLineChart } from "../../simulation/components/MiniLineChart";
+import { fmt } from "../../../lib/time";
+import type { AgentPageProps } from "../../../pages/types";
+import { baseUrl } from "../../../../src/api/client";
+import ReactMarkdown from 'react-markdown';
+import remarkGfm from 'remark-gfm';
+
+const CHART_COLORS = [
+ "hsl(var(--primary))", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6", "#ec4899", "#06b6d4"
+];
+
+export function AgentResultsCompare(props: AgentPageProps) {
+ const { historyJobs, selectedHistory, selectHistoryJob, setWorkflowStep } = props;
+
+ const jobs = (historyJobs || []).filter((job: any) => job.status === "completed");
+ const [selectedRunId, setSelectedRunId] = useState(null);
+ const [metrics, setMetrics] = useState(null);
+
+ const experiments = selectedHistory?.experiments || [];
+ const bestExp = experiments.reduce((prev: any, curr: any) =>
+ (curr.score || 0) > (prev?.score || 0) ? curr : prev, experiments[0] || null);
+
+ useEffect(() => {
+ if (bestExp) {
+ setSelectedRunId(bestExp.run_id);
+ } else {
+ setSelectedRunId(null);
+ setMetrics(null);
+ }
+ }, [selectedHistory]);
+
+ useEffect(() => {
+ if (!selectedRunId) {
+ setMetrics(null);
+ return;
+ }
+ fetch(`${baseUrl}/api/v1/agent/runs/${selectedRunId}/metrics`)
+ .then(res => res.json())
+ .then(data => {
+ if (data.metrics) setMetrics(data.metrics);
+ })
+ .catch(() => {});
+ }, [selectedRunId]);
+
+ const globalResults = metrics?.global_results || {};
+ const clientResults = metrics?.client_results || {};
+ const actualDataLength = globalResults.global_accuracy?.length || 0;
+ const rounds = globalResults.rounds ? globalResults.rounds.slice(0, actualDataLength) : Array.from({ length: actualDataLength }, (_, i) => i + 1);
+ const safeSlice = (arr: any[]) => (arr || []).slice(0, actualDataLength);
+
+ const globalAccSeries = [{ key: "g_acc", label: "Global Accuracy", color: CHART_COLORS[0], values: safeSlice(globalResults.global_accuracy) }];
+ const globalLossSeries = [{ key: "g_loss", label: "Global Loss", color: CHART_COLORS[3], values: safeSlice(globalResults.global_loss) }];
+ const clientIds = Object.keys(clientResults);
+ const clientTrainAccSeries = clientIds.map((cId, idx) => ({ key: `${cId}_train_acc`, label: cId, color: CHART_COLORS[(idx + 1) % CHART_COLORS.length], values: safeSlice(clientResults[cId].train_acc) }));
+ const clientTrainLossSeries = clientIds.map((cId, idx) => ({ key: `${cId}_train_loss`, label: cId, color: CHART_COLORS[(idx + 1) % CHART_COLORS.length], values: safeSlice(clientResults[cId].train_loss) }));
+ const clientTestAccSeries = clientIds.map((cId, idx) => ({ key: `${cId}_test_acc`, label: cId, color: CHART_COLORS[(idx + 1) % CHART_COLORS.length], values: safeSlice(clientResults[cId].test_acc) }));
+ const clientTestLossSeries = clientIds.map((cId, idx) => ({ key: `${cId}_test_loss`, label: cId, color: CHART_COLORS[(idx + 1) % CHART_COLORS.length], values: safeSlice(clientResults[cId].test_loss) }));
+
+ return (
+
+
+
+
+
+ Job History
+
+ Past agent optimizations
+
+
+ {jobs.length === 0 && No history yet.
}
+
+ {jobs.map((job: any) => {
+ const isSelected = selectedHistory?.optimization_job_id === job.optimization_job_id;
+ const isCompleted = job.status === "completed";
+
+ return (
+ selectHistoryJob(job.optimization_job_id)}
+ className={`flex flex-col p-3 rounded-lg border text-xs cursor-pointer transition-all hover:shadow-sm
+ ${isSelected ? 'bg-primary/5 border-primary/40 ring-1 ring-primary/20' : 'bg-card border-border hover:border-primary/30'}`}
+ >
+
+
+ {job.job_name || `Job #${job.optimization_job_id}`}
+
+
+ {job.status}
+
+
+
+
+ {job.goal}
+
+
+
+ {fmt(job.created_at)}
+
+
+ );
+ })}
+
+
+
+
+ {!selectedHistory ? (
+
+
+
Select a historical job from the sidebar to view its insights and charts.
+
+ ) : (
+ <>
+
+
+
{selectedHistory.job_name || `Optimization Job #${selectedHistory.optimization_job_id}`}
+
+ {selectedHistory.goal}
+
+
+
setWorkflowStep("home")} size="sm">
+ Start New Agent Job
+
+
+
+
+
+
+
+
+
+
+
Optimal Configuration
+
+
+ {bestExp?.score != null ? `${(bestExp.score * 100).toFixed(2)}%` : "N/A"}
+
+
+ Experiment "{bestExp?.name}" yielded the highest accuracy.
+
+
+
+
+
+
+
+ Agent Summary Report
+
+
+
+ {selectedHistory.summary_text ? (
+ ,
+ p: ({node, ...props}) =>
,
+ ul: ({node, ...props}) => ,
+ strong: ({node, ...props}) => ,
+ table: ({node, ...props}) => (
+
+ ),
+ th: ({node, ...props}) => ,
+ td: ({node, ...props}) => ,
+ }}
+ >
+ {selectedHistory.summary_text}
+
+ ) : (
+ No final report was generated by the agent for this job.
+ )}
+
+
+
+
+
+
+
+ Explore Experiment Metrics
+
+
+ {experiments.map((exp: any, i: number) => {
+ const isBest = exp.run_id === bestExp?.run_id;
+ const isActive = selectedRunId === exp.run_id;
+
+ return (
+ setSelectedRunId(exp.run_id)}
+ >
+ {exp.name || `Exp ${i + 1}`}
+ {isBest && }
+
+ )
+ })}
+
+
+
+ {selectedRunId ? (
+
+ `${(v * 100).toFixed(2)}%`} />
+ v.toFixed(4)} />
+ `${(v * 100).toFixed(2)}%`} />
+ `${(v * 100).toFixed(2)}%`} />
+ v.toFixed(4)} />
+ v.toFixed(4)} />
+
+ ) : (
+
+ Select an experiment above to load its charts.
+
+ )}
+ >
+ )}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/frontend/src/features/agent/components/AgentRunDashboard.tsx b/apps/frontend/src/features/agent/components/AgentRunDashboard.tsx
new file mode 100644
index 0000000..d5d82fa
--- /dev/null
+++ b/apps/frontend/src/features/agent/components/AgentRunDashboard.tsx
@@ -0,0 +1,285 @@
+import { useEffect, useState } from "react";
+import { Activity, Bot, TerminalSquare, CheckCircle2, CircleDashed, LayoutGrid } from "lucide-react";
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "../../../components/ui/card";
+import { Separator } from "../../../components/ui/separator";
+import { MiniLineChart } from "../../simulation/components/MiniLineChart";
+import { fmt } from "../../../lib/time";
+import type { AgentPageProps } from "../../../pages/types";
+import { baseUrl } from "../../../../src/api/client";
+import ReactMarkdown from 'react-markdown';
+import remarkGfm from 'remark-gfm';
+
+const CHART_COLORS = [
+ "hsl(var(--primary))",
+ "#10b981", // Emerald
+ "#f59e0b", // Amber
+ "#ef4444", // Red
+ "#8b5cf6", // Violet
+ "#ec4899", // Pink
+ "#06b6d4" // Cyan
+];
+
+export function AgentRunDashboard({ progress }: AgentPageProps) {
+ const [selectedRunId, setSelectedRunId] = useState(null);
+ const [liveMetrics, setLiveMetrics] = useState(null);
+
+ if (!progress) return null;
+
+ const currentExp = progress.current_experiment;
+ const currentPlan = progress.current_plan;
+ const experiments = progress.experiments || [];
+ const activeRunId = selectedRunId
+ || currentExp?.run_id
+ || (experiments.length > 0 ? experiments[experiments.length - 1].run_id : null);
+
+ useEffect(() => {
+ if (!activeRunId) {
+ setLiveMetrics(null);
+ return;
+ }
+
+ let isSubscribed = true;
+ const fetchLiveMetrics = async () => {
+ try {
+ const res = await fetch(`${baseUrl}/api/v1/agent/runs/${activeRunId}/metrics`);
+ if (res.ok && isSubscribed) {
+ const data = await res.json();
+ if (data.metrics) {
+ setLiveMetrics(data.metrics);
+ }
+ }
+ } catch (e) {
+ }
+ };
+
+ fetchLiveMetrics();
+ const intervalId = setInterval(fetchLiveMetrics, 2000);
+
+ return () => {
+ isSubscribed = false;
+ clearInterval(intervalId);
+ };
+ }, [activeRunId]);
+
+ const activeExpMetadata = experiments.find((e: any) => e.run_id === activeRunId)
+ || (currentExp?.run_id === activeRunId ? currentExp : null);
+
+ const metrics = liveMetrics || activeExpMetadata?.metrics || {};
+ const globalResults = metrics.global_results || {};
+ const clientResults = metrics.client_results || {};
+ const actualDataLength = globalResults.global_accuracy?.length || 0;
+
+ const rounds = globalResults.rounds
+ ? globalResults.rounds.slice(0, actualDataLength)
+ : Array.from({ length: actualDataLength }, (_, i) => i + 1);
+
+ const safeSlice = (arr: any[]) => (arr || []).slice(0, actualDataLength);
+
+ // 1. Global Accuracy
+ const globalAccSeries = [{
+ key: "g_acc", label: "Global Accuracy", color: CHART_COLORS[0],
+ values: safeSlice(globalResults.global_accuracy)
+ }];
+
+ // 2. Global Loss
+ const globalLossSeries = [{
+ key: "g_loss", label: "Global Loss", color: CHART_COLORS[3],
+ values: safeSlice(globalResults.global_loss)
+ }];
+
+ const clientIds = Object.keys(clientResults);
+
+ // 3. Client Train Acc
+ const clientTrainAccSeries = clientIds.map((cId, idx) => ({
+ key: `${cId}_train_acc`, label: cId, color: CHART_COLORS[(idx + 1) % CHART_COLORS.length],
+ values: safeSlice(clientResults[cId].train_acc)
+ }));
+
+ // 4. Client Train Loss
+ const clientTrainLossSeries = clientIds.map((cId, idx) => ({
+ key: `${cId}_train_loss`, label: cId, color: CHART_COLORS[(idx + 1) % CHART_COLORS.length],
+ values: safeSlice(clientResults[cId].train_loss)
+ }));
+
+ // 5. Client Test Acc
+ const clientTestAccSeries = clientIds.map((cId, idx) => ({
+ key: `${cId}_test_acc`, label: cId, color: CHART_COLORS[(idx + 1) % CHART_COLORS.length],
+ values: safeSlice(clientResults[cId].test_acc)
+ }));
+
+ // 6. Client Test Loss
+ const clientTestLossSeries = clientIds.map((cId, idx) => ({
+ key: `${cId}_test_loss`, label: cId, color: CHART_COLORS[(idx + 1) % CHART_COLORS.length],
+ values: safeSlice(clientResults[cId].test_loss)
+ }));
+
+ return (
+
+
+
+
+ Execution Queue
+
+ {progress.completed_iterations} of {progress.max_iterations} done
+
+
+ {experiments.map((exp: any, idx: number) => {
+ const isSelected = activeRunId === exp.run_id;
+ return (
+ setSelectedRunId(exp.run_id)}
+ className={`flex items-center justify-between p-2.5 rounded-md border text-xs cursor-pointer transition-colors
+ ${isSelected ? 'bg-primary/10 border-primary/40 shadow-sm' : 'bg-card hover:bg-muted border-transparent'}`}
+ >
+
+
+ {`Exp ${idx + 1}`}
+
+ {exp.score != null && Acc: {(exp.score * 100).toFixed(2)}% }
+
+
+
+ );
+ })}
+
+ {progress.status === "running" &&
+ progress.completed_iterations < progress.max_iterations &&
+ currentPlan &&
+ currentExp?.run_id &&
+ !experiments.some((e: any) => e.run_id === currentExp.run_id) && (
+ setSelectedRunId(currentExp.run_id)}
+ className={`p-2.5 rounded-md border cursor-pointer transition-colors
+ ${activeRunId === currentExp.run_id ? 'bg-primary/10 border-primary shadow-sm' : 'bg-card border-primary/40 hover:bg-muted'}`}
+ >
+
+ Running:
+
+
+
Round {currentExp.iteration} ...
+
+ )}
+
+ {progress.status === "running" &&
+ progress.completed_iterations >= progress.max_iterations && (
+
+
+ Generating Agent Summary
+
+
+
+ Analyzing metrics & drafting report...
+
+
+ )}
+
+
+
+
+
+
+
+ Live Metrics ({"Initializing..."})
+
+
+
+
+ {/* Chart 1: Global Acc */}
+
+ `${(v * 100).toFixed(2)}%`} />
+
+
+ {/* Chart 2: Global Loss */}
+
+ v.toFixed(4)} />
+
+
+ {/* Chart 3: Client Train Acc */}
+
+ `${(v * 100).toFixed(2)}%`} />
+
+
+ {/* Chart 4: Client Test Acc */}
+
+ `${(v * 100).toFixed(2)}%`} />
+
+
+ {/* Chart 5: Client Train Loss */}
+
+ v.toFixed(4)} />
+
+
+ {/* Chart 6: Client Test Loss */}
+
+ v.toFixed(4)} />
+
+
+
+
+
+
+
+
+ Agent Insights
+
+
+
+ {progress.status === "completed" ? (
+
+
Final Summary
+
+ {progress.summary_text ? (
+
,
+ ul: ({node, ...props}) => ,
+ strong: ({node, ...props}) => ,
+ table: ({node, ...props}) => (
+
+ ),
+ th: ({node, ...props}) => ,
+ td: ({node, ...props}) => ,
+ }}
+ >
+ {progress.summary_text}
+
+ ) : (
+ "The agent is finalizing the comparison report..."
+ )}
+
+
+ ) : (
+ <>
+ {currentPlan?.hypothesis && (
+
+
Hypothesis
+
"{currentPlan.hypothesis}"
+
+ )}
+
+ {currentPlan?.rationale && currentPlan.rationale.length > 0 && (
+
+
Reasoning
+
+ {currentPlan.rationale.map((r: string, i: number) => (
+
+ • {r}
+
+ ))}
+
+
+ )}
+ >
+ )}
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/frontend/src/features/agent/useAgentController.ts b/apps/frontend/src/features/agent/useAgentController.ts
index cd169db..38b463f 100644
--- a/apps/frontend/src/features/agent/useAgentController.ts
+++ b/apps/frontend/src/features/agent/useAgentController.ts
@@ -10,7 +10,7 @@ import {
type AgentOptimizeResponse,
type AgentRunResponse,
} from "../../api/agent";
-import type { AgentPageProps } from "../../pages/types";
+import type { AgentPageProps, AgentWorkflowStep, AgentPlanDraft } from "../../pages/types";
import { toErrorMessage } from "../simulation/utils";
const DEFAULT_GOAL = "Compare CIFAR-10 non-IID with alpha=0.1, 0.3, 0.5";
@@ -68,6 +68,8 @@ function toHistorySummary(progress: AgentOptimizeProgressResponse): AgentOptimiz
}
export function useAgentController(): AgentPageProps {
+ const [workflowStep, setWorkflowStep] = useState("home");
+ const [draftPlan, setDraftPlan] = useState(null);
const [activeTaskId, setActiveTaskId] = useState(null);
const [busy, setBusy] = useState(false);
const [goal, setGoal] = useState(DEFAULT_GOAL);
@@ -136,8 +138,86 @@ export function useAgentController(): AgentPageProps {
async function selectHistoryJob(optimizationJobId: number): Promise {
setSelectedHistoryJobId(optimizationJobId);
- const detail = await agentApi.getOptimizationJob(optimizationJobId);
- setSelectedHistory(detail);
+ try {
+ const detail = await agentApi.getOptimizationJob(optimizationJobId);
+ setSelectedHistory(detail);
+
+ if (detail.status === "pending_review" && detail.draft_experiments?.length) {
+ setDraftPlan({
+ job_id: detail.optimization_job_id!,
+ goal: detail.goal,
+ experiments: detail.draft_experiments
+ });
+ setWorkflowStep("preview");
+ } else if (detail.status === "completed") {
+ setResult(toFinalResult(detail));
+ }
+ } catch (error) {
+ notifyError(error, "agent-history-detail");
+ }
+ }
+
+ async function handleGeneratePlan(): Promise {
+ const trimmedGoal = goal.trim();
+ if (!trimmedGoal) {
+ toast.warning("Please describe your experiment.");
+ return;
+ }
+ const trimmedJobName = jobName.trim();
+ if (!trimmedJobName) {
+ toast.warning("Please provide a job name.");
+ return;
+ }
+
+ setBusy(true);
+ try {
+ const data = await agentApi.generatePlan({
+ goal: trimmedGoal,
+ job_name: trimmedJobName,
+ model_name: modelName.trim().length > 0 ? modelName.trim() : null,
+ system_mode: "simulation",
+ });
+
+ setDraftPlan({
+ job_id: data.optimization_job_id!,
+ goal: data.goal,
+ experiments: data.experiments,
+ });
+
+ setWorkflowStep("preview");
+ void refreshHistoryJobs(); // Refresh sidebar to show PENDING_REVIEW job
+ } catch (error) {
+ notifyError(error, "agent-plan");
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ async function handleExecutePlan(editedExperiments: any[]): Promise {
+ if (!draftPlan) return;
+
+ setBusy(true);
+ setResult(null);
+ setProgress(null);
+
+ try {
+ const data = await agentApi.startOptimizeFromDraft({
+ goal: draftPlan.goal,
+ job_name: jobName,
+ max_iterations: editedExperiments.length,
+ system_mode: "simulation",
+ model_name: modelName.trim().length > 0 ? modelName.trim() : null,
+ objective: objective,
+ planned_experiments: editedExperiments, // Bypass LLM parse in backend
+ });
+
+ setProgress(data);
+ setActiveTaskId(data.task_id);
+ setWorkflowStep("running");
+ } catch (error) {
+ notifyError(error, "agent-execute");
+ setBusy(false);
+ }
}
async function handleOptimize(): Promise {
@@ -253,6 +333,7 @@ export function useAgentController(): AgentPageProps {
setResult(toFinalResult(nextProgress));
setBusy(false);
setActiveTaskId(null);
+ setWorkflowStep("results");
void refreshHistoryJobs().catch((error: unknown) => notifyError(error, "agent-history-refresh"));
void refreshExperiments().catch((error: unknown) => notifyError(error, "agent-experiments-refresh"));
toast.success("Experiment run finished.");
@@ -318,5 +399,11 @@ export function useAgentController(): AgentPageProps {
setMaxIterations,
setModelName,
setObjective,
+ workflowStep,
+ setWorkflowStep,
+ draftPlan,
+ setDraftPlan,
+ handleGeneratePlan,
+ handleExecutePlan,
};
}
diff --git a/apps/frontend/src/features/distributed/useDistributedController.ts b/apps/frontend/src/features/distributed/useDistributedController.ts
index 257d58a..455b9de 100644
--- a/apps/frontend/src/features/distributed/useDistributedController.ts
+++ b/apps/frontend/src/features/distributed/useDistributedController.ts
@@ -417,7 +417,7 @@ export function useDistributedController({
: {};
const configuredIp = String(getValueByPath(jobConfig, "distributed.server_ip") ?? "").trim();
const nextIp =
- configuredIp.length > 0 && configuredIp !== "127.0.0.1" && configuredIp !== "localhost"
+ configuredIp.length > 0 && configuredIp !== "localhost" && configuredIp !== "localhost"
? configuredIp
: DEFAULT_DISTRIBUTED_GRPC_HOST;
const configuredPort = String(getValueByPath(jobConfig, "distributed.port") ?? "").trim();
diff --git a/apps/frontend/src/features/simulation/components/MiniScatterChart.tsx b/apps/frontend/src/features/simulation/components/MiniScatterChart.tsx
new file mode 100644
index 0000000..2a66d01
--- /dev/null
+++ b/apps/frontend/src/features/simulation/components/MiniScatterChart.tsx
@@ -0,0 +1,81 @@
+import { Card, CardContent, CardHeader, CardTitle } from "../../../components/ui/card";
+
+type ScatterPoint = {
+ x: number;
+ y: number;
+ label: string;
+ isBest?: boolean;
+};
+
+type MiniScatterChartProps = {
+ title: string;
+ points: ScatterPoint[];
+ xAxisLabel: string;
+ yAxisLabel: string;
+};
+
+export function MiniScatterChart({ title, points, xAxisLabel, yAxisLabel }: MiniScatterChartProps) {
+ const chartWidth = 520;
+ const chartHeight = 260;
+ const margin = { top: 20, right: 30, bottom: 40, left: 60 };
+ const plotWidth = chartWidth - margin.left - margin.right;
+ const plotHeight = chartHeight - margin.top - margin.bottom;
+
+ const hasData = points.length > 0;
+ const xValues = points.map(p => p.x);
+ const yValues = points.map(p => p.y);
+
+ const xMin = Math.min(...xValues, 0);
+ const xMax = Math.max(...xValues) * 1.1 || 1;
+ const yMin = Math.min(...yValues, 0);
+ const yMax = Math.max(...yValues) * 1.1 || 1;
+
+ const getX = (val: number) => margin.left + ((val - xMin) / (xMax - xMin)) * plotWidth;
+ const getY = (val: number) => margin.top + (plotHeight - ((val - yMin) / (yMax - yMin)) * plotHeight);
+
+ return (
+
+
+ {title}
+
+
+ {!hasData ? (
+
+ Waiting for comparison data...
+
+ ) : (
+
+
+ {/* Axis Labels */}
+ {xAxisLabel}
+ {yAxisLabel}
+
+ {/* Grid Lines */}
+ {[0, 0.25, 0.5, 0.75, 1].map(ratio => (
+
+
+
+
+ ))}
+
+ {/* Data Points */}
+ {points.map((point, i) => (
+
+
+ {point.isBest && (
+ Winner
+ )}
+
+ ))}
+
+
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/frontend/src/features/simulation/utils.ts b/apps/frontend/src/features/simulation/utils.ts
index 66992e5..b6b9bd4 100644
--- a/apps/frontend/src/features/simulation/utils.ts
+++ b/apps/frontend/src/features/simulation/utils.ts
@@ -98,7 +98,7 @@ const CLIENT_SERIES_COLORS = [
];
export const DEFAULT_DISTRIBUTED_SERVER_API_BASE = import.meta.env.VITE_DISTRIBUTED_SERVER_API_BASE ?? baseUrl;
-export const DEFAULT_DISTRIBUTED_GRPC_HOST = import.meta.env.VITE_DISTRIBUTED_GRPC_HOST ?? "127.0.0.1";
+export const DEFAULT_DISTRIBUTED_GRPC_HOST = import.meta.env.VITE_DISTRIBUTED_GRPC_HOST ?? "localhost";
export const DEFAULT_DISTRIBUTED_GRPC_PORT = import.meta.env.VITE_DISTRIBUTED_GRPC_PORT ?? "50052";
export function nodeCenter(node: TopologyNode): Point {
diff --git a/apps/frontend/src/pages/AgentPage.tsx b/apps/frontend/src/pages/AgentPage.tsx
index fd31d30..784f8a2 100644
--- a/apps/frontend/src/pages/AgentPage.tsx
+++ b/apps/frontend/src/pages/AgentPage.tsx
@@ -10,6 +10,10 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../co
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../components/ui/select";
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
import { Textarea } from "../components/ui/textarea";
+import { AgentExperimentStudio } from "../features/agent/components/AgentExperimentStudio";
+import { AgentPlanPreview } from "../features/agent/components/AgentPlanPreview";
+import { AgentRunDashboard } from "../features/agent/components/AgentRunDashboard";
+import { AgentResultsCompare } from "../features/agent/components/AgentResultsCompare";
import type { AgentPageProps } from "./types";
/* ------------------------------------------------------------------ */
@@ -62,273 +66,15 @@ function getConfigLabel(config: Record | null): string {
/* ------------------------------------------------------------------ */
export function AgentPage(props: AgentPageProps) {
- const {
- busy,
- clearResult,
- defaultModelName,
- experiments,
- experimentRuns,
- goal,
- handleOptimize,
- historyJobs,
- lastSubmittedGoal,
- modelName,
- modelOptions,
- presets,
- progress,
- result,
- selectedExperimentId,
- selectedHistory,
- selectedHistoryJobId,
- selectExperiment,
- selectHistoryJob,
- setGoal,
- setModelName,
- } = props;
-
- const [topTab, setTopTab] = useState<"experiment" | "runs">("experiment");
- const [selectedRunJobId, setSelectedRunJobId] = useState(null);
-
- // Current experiment data
- const summaryData = busy ? (progress ?? selectedHistory) : (selectedHistory ?? progress);
- const currentExperiments = [...(summaryData?.experiments ?? result?.experiments ?? [])];
- const summaryText = summaryData?.summary_text ?? result?.summary_text ?? null;
- const completedCount = summaryData?.completed_iterations ?? result?.iterations_executed ?? 0;
- const rawTotal = summaryData?.max_iterations ?? result?.max_iterations ?? 0;
- const totalCount = Math.max(rawTotal, completedCount, currentExperiments.length);
- const showProgress = busy;
- const defaultLabel = defaultModelName ? `Use backend default (${defaultModelName})` : "Use backend default";
- const hasResults = currentExperiments.length > 0 || result;
-
- // Selected run from history (for Runs tab detail view)
- const selectedRunJob = historyJobs.find((j) => j.optimization_job_id === selectedRunJobId) ?? null;
- const selectedRunDetail = selectedRunJobId !== null && selectedHistoryJobId === selectedRunJobId ? selectedHistory : null;
- const selectedRunExperiments = [...(selectedRunDetail?.experiments ?? [])];
- const selectedRunSummary = selectedRunDetail?.summary_text ?? null;
-
+ // Select which view to render based on current workflow step
return (
-
- {/* ── Top tab bar ── */}
-
- setTopTab(v as "experiment" | "runs")}>
-
- Experiment
- Runs
-
-
-
-
-
- {/* ════════════════════════════════════════════ */}
- {/* EXPERIMENT TAB: Input + Current Results */}
- {/* ════════════════════════════════════════════ */}
- {topTab === "experiment" && (
-
- {/* Left: Controls */}
-
-
-
-
-
- Intelligent Experiment Runner
-
-
- Describe your FL experiments in natural language. AI parses, runs, and compares results automatically.
-
-
-
-
-
-
- {/* Progress */}
- {showProgress && (
-
-
-
-
-
-
- {summaryData?.current_phase ?? "Running..."}
- {completedCount}/{totalCount}
-
-
-
0 ? (completedCount / totalCount) * 100 : 0}%` }} />
-
-
-
-
-
- )}
-
-
- {/* Right: Current Results */}
-
- {!busy && !hasResults && (
-
-
-
- Describe your experiment and click Run Experiments to start
-
-
- )}
-
- {currentExperiments.length > 0 && (
-
-
-
-
- Results
-
- {lastSubmittedGoal && {lastSubmittedGoal} }
-
-
-
-
-
- )}
-
- {summaryText && (
-
- Key Findings
-
-
- {summaryText}
-
-
-
- )}
-
- {currentExperiments.length > 0 && (
-
-
-
- View detailed JSON
-
- {JSON.stringify(currentExperiments.map((e) => ({ name: e.iteration_goal, config: e.config, metrics: e.metrics, score: e.score })), null, 2)}
-
-
-
-
- )}
-
-
- )}
-
- {/* ════════════════════════════════════════════ */}
- {/* RUNS TAB: History of all runs */}
- {/* ════════════════════════════════════════════ */}
- {topTab === "runs" && (
-
- {/* Left: Runs list */}
-
-
All Runs (newest first)
- {historyJobs.length === 0 && (
-
No runs yet. Go to Experiment tab to start.
- )}
- {historyJobs.map((job) => (
-
{
- setSelectedRunJobId(job.optimization_job_id);
- selectHistoryJob(job.optimization_job_id).catch(() => undefined);
- }}
- >
-
-
{job.job_name ?? `Run #${job.optimization_job_id}`}
-
{job.goal}
-
- {job.completed_iterations} experiments{job.best_score !== null ? ` · best=${job.best_score.toFixed(4)}` : ""}
-
-
- {job.status}
-
- ))}
-
-
- {/* Right: Selected run detail */}
-
- {selectedRunJobId === null && (
-
-
- Select a run from the list to view details
-
-
- )}
-
- {selectedRunJob && (
-
-
- {selectedRunJob.job_name ?? `Run #${selectedRunJob.optimization_job_id}`}
- {selectedRunJob.goal}
-
-
-
- {selectedRunJob.status}
- {selectedRunJob.completed_iterations} experiments
- {selectedRunJob.best_score !== null && best={selectedRunJob.best_score.toFixed(4)} }
-
-
- {/* Sub-experiments table */}
- {selectedRunExperiments.length > 0 && (
-
- )}
-
- {/* Summary */}
- {selectedRunSummary && (
-
- {selectedRunSummary}
-
- )}
-
- {selectedRunExperiments.length === 0 && !selectedRunSummary && (
-
- {selectedRunJob.status === "running" ? "Experiments are still running..." : "No experiment data available"}
-
- )}
-
-
- )}
-
-
- )}
-
+
+
+ {props.workflowStep === "home" && }
+ {props.workflowStep === "preview" && }
+ {props.workflowStep === "running" && }
+ {/* {props.workflowStep === "results" && } */}
+
);
}
diff --git a/apps/frontend/src/pages/distributed/DistributedClientPanel.tsx b/apps/frontend/src/pages/distributed/DistributedClientPanel.tsx
index 0071ab5..fc32f8e 100644
--- a/apps/frontend/src/pages/distributed/DistributedClientPanel.tsx
+++ b/apps/frontend/src/pages/distributed/DistributedClientPanel.tsx
@@ -39,7 +39,7 @@ export function DistributedClientPanel(props: DistributedPageProps) {
setClientServerApiBase(event.target.value)}
- placeholder="http://127.0.0.1:8000"
+ placeholder="http://localhost:8000"
/>
diff --git a/apps/frontend/src/pages/types.ts b/apps/frontend/src/pages/types.ts
index fb99d65..2c66968 100644
--- a/apps/frontend/src/pages/types.ts
+++ b/apps/frontend/src/pages/types.ts
@@ -30,6 +30,14 @@ export type NodeRole = "server" | "client";
export type SystemMode = "simulation" | "distributed";
export type BadgeVariant = "default" | "secondary" | "outline" | "success" | "warning" | "danger";
+export type AgentWorkflowStep = "home" | "preview" | "running" | "results";
+
+export type AgentPlanDraft = {
+ job_id: number;
+ goal: string;
+ experiments: any[];
+};
+
export type Point = {
x: number;
y: number;
@@ -251,6 +259,13 @@ export type AgentPageProps = {
setMaxIterations: Dispatch>;
setModelName: Dispatch>;
setObjective: Dispatch>;
+ workflowStep: AgentWorkflowStep;
+ setWorkflowStep: Dispatch>;
+ draftPlan: AgentPlanDraft | null;
+ setDraftPlan: Dispatch>;
+ handleGeneratePlan: () => Promise;
+ handleExecutePlan: (editedExperiments: any[]) => Promise;
+ optimizationJobs?: any[];
};
export type { RunMetrics };
diff --git a/config_schema.yaml b/config_schema.yaml
index 9f3a8da..b7fb373 100644
--- a/config_schema.yaml
+++ b/config_schema.yaml
@@ -110,7 +110,7 @@ distributed:
role: "server"
server_ip:
type: "text"
- default: "127.0.0.1"
+ default: "localhost"
depends_on: "system.mode == distributed"
port:
type: "number"
diff --git a/configs/simulation_config.yaml b/configs/simulation_config.yaml
index b1c647d..073f5b0 100644
--- a/configs/simulation_config.yaml
+++ b/configs/simulation_config.yaml
@@ -31,7 +31,7 @@ system:
mode: simulation
node_role: server
distributed:
- server_ip: 127.0.0.1
+ server_ip: localhost
port: 50052
client_id: 0
logging:
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
index 01c5f26..e5bb18e 100644
--- a/docker-compose.dev.yml
+++ b/docker-compose.dev.yml
@@ -48,14 +48,14 @@ services:
http_proxy: ""
HTTPS_PROXY: ""
https_proxy: ""
- NO_PROXY: localhost,127.0.0.1,postgres
- no_proxy: localhost,127.0.0.1,postgres
+ NO_PROXY: localhost,localhost,postgres
+ no_proxy: localhost,localhost,postgres
NVIDIA_VISIBLE_DEVICES: all
NVIDIA_DRIVER_CAPABILITIES: compute,utility
PROJECT_NAME: Figaro
API_PREFIX: /api/v1
FRONTEND_URL: http://localhost:5173
- ALLOWED_ORIGINS: http://localhost:5173,http://127.0.0.1:5173,http://localhost
+ ALLOWED_ORIGINS: http://localhost:5173,http://localhost:5173,http://localhost
POSTGRES_HOST: postgres
POSTGRES_PORT: 5432
POSTGRES_DB: ${POSTGRES_DB:-figaro}
@@ -85,7 +85,7 @@ services:
&& pnpm install --frozen-lockfile
&& pnpm dev --host 0.0.0.0 --port 5173"
environment:
- VITE_API_BASE_URL: http://127.0.0.1:8000
+ VITE_API_BASE_URL: http://localhost:8000
CHOKIDAR_USEPOLLING: "true"
ports:
- "5173:5173"
diff --git a/docker-compose.dist.yml b/docker-compose.dist.yml
index 83a8b42..9b27aba 100644
--- a/docker-compose.dist.yml
+++ b/docker-compose.dist.yml
@@ -28,8 +28,8 @@ x-backend-base: &backend-base
http_proxy: ""
HTTPS_PROXY: ""
https_proxy: ""
- NO_PROXY: localhost,127.0.0.1
- no_proxy: localhost,127.0.0.1
+ NO_PROXY: localhost,localhost
+ no_proxy: localhost,localhost
PROJECT_NAME: Figaro
API_PREFIX: /api/v1
POSTGRES_PORT: 5432
@@ -56,7 +56,7 @@ x-frontend-base: &frontend-base
&& pnpm dev --host 0.0.0.0 --port 5173"
environment:
CHOKIDAR_USEPOLLING: "true"
- VITE_DISTRIBUTED_SERVER_API_BASE: http://127.0.0.1:${DIST_SERVER_API_PORT:-8100}
+ VITE_DISTRIBUTED_SERVER_API_BASE: http://localhost:${DIST_SERVER_API_PORT:-8100}
volumes:
- ./:/workspace
- figaro_dist_pnpm_store:/root/.local/share/pnpm/store
@@ -99,8 +99,8 @@ services:
PROJECT_NAME: Figaro
API_PREFIX: /api/v1
FRONTEND_URL: http://localhost:${DIST_SERVER_WEB_PORT:-5273}
- ALLOWED_ORIGINS: http://localhost:${DIST_SERVER_WEB_PORT:-5273},http://127.0.0.1:${DIST_SERVER_WEB_PORT:-5273}
- POSTGRES_HOST: 127.0.0.1
+ ALLOWED_ORIGINS: http://localhost:${DIST_SERVER_WEB_PORT:-5273},http://localhost:${DIST_SERVER_WEB_PORT:-5273}
+ POSTGRES_HOST: localhost
POSTGRES_PORT: ${DIST_SERVER_POSTGRES_HOST_PORT:-55431}
POSTGRES_DB: ${DIST_SERVER_POSTGRES_DB:-figaro_server}
POSTGRES_USER: ${POSTGRES_USER:-postgres}
@@ -120,9 +120,9 @@ services:
container_name: figaro-dist-frontend-server
environment:
CHOKIDAR_USEPOLLING: "true"
- VITE_API_BASE_URL: http://127.0.0.1:${DIST_SERVER_API_PORT:-8100}
- VITE_DISTRIBUTED_SERVER_API_BASE: http://127.0.0.1:${DIST_SERVER_API_PORT:-8100}
- VITE_DISTRIBUTED_GRPC_HOST: 127.0.0.1
+ VITE_API_BASE_URL: http://localhost:${DIST_SERVER_API_PORT:-8100}
+ VITE_DISTRIBUTED_SERVER_API_BASE: http://localhost:${DIST_SERVER_API_PORT:-8100}
+ VITE_DISTRIBUTED_GRPC_HOST: localhost
VITE_DISTRIBUTED_GRPC_PORT: "50052"
ports:
- "${DIST_SERVER_WEB_PORT:-5273}:5173"
@@ -168,8 +168,8 @@ services:
PROJECT_NAME: Figaro
API_PREFIX: /api/v1
FRONTEND_URL: http://localhost:${DIST_CLIENT0_WEB_PORT:-5274}
- ALLOWED_ORIGINS: http://localhost:${DIST_CLIENT0_WEB_PORT:-5274},http://127.0.0.1:${DIST_CLIENT0_WEB_PORT:-5274}
- POSTGRES_HOST: 127.0.0.1
+ ALLOWED_ORIGINS: http://localhost:${DIST_CLIENT0_WEB_PORT:-5274},http://localhost:${DIST_CLIENT0_WEB_PORT:-5274}
+ POSTGRES_HOST: localhost
POSTGRES_PORT: ${DIST_CLIENT0_POSTGRES_HOST_PORT:-55432}
POSTGRES_DB: ${DIST_CLIENT0_POSTGRES_DB:-figaro_client0}
POSTGRES_USER: ${POSTGRES_USER:-postgres}
@@ -189,9 +189,9 @@ services:
container_name: figaro-dist-frontend-client0
environment:
CHOKIDAR_USEPOLLING: "true"
- VITE_API_BASE_URL: http://127.0.0.1:${DIST_CLIENT0_API_PORT:-8101}
- VITE_DISTRIBUTED_SERVER_API_BASE: http://127.0.0.1:${DIST_SERVER_API_PORT:-8100}
- VITE_DISTRIBUTED_GRPC_HOST: 127.0.0.1
+ VITE_API_BASE_URL: http://localhost:${DIST_CLIENT0_API_PORT:-8101}
+ VITE_DISTRIBUTED_SERVER_API_BASE: http://localhost:${DIST_SERVER_API_PORT:-8100}
+ VITE_DISTRIBUTED_GRPC_HOST: localhost
VITE_DISTRIBUTED_GRPC_PORT: "50052"
ports:
- "${DIST_CLIENT0_WEB_PORT:-5274}:5173"
@@ -237,8 +237,8 @@ services:
PROJECT_NAME: Figaro
API_PREFIX: /api/v1
FRONTEND_URL: http://localhost:${DIST_CLIENT1_WEB_PORT:-5275}
- ALLOWED_ORIGINS: http://localhost:${DIST_CLIENT1_WEB_PORT:-5275},http://127.0.0.1:${DIST_CLIENT1_WEB_PORT:-5275}
- POSTGRES_HOST: 127.0.0.1
+ ALLOWED_ORIGINS: http://localhost:${DIST_CLIENT1_WEB_PORT:-5275},http://localhost:${DIST_CLIENT1_WEB_PORT:-5275}
+ POSTGRES_HOST: localhost
POSTGRES_PORT: ${DIST_CLIENT1_POSTGRES_HOST_PORT:-55433}
POSTGRES_DB: ${DIST_CLIENT1_POSTGRES_DB:-figaro_client1}
POSTGRES_USER: ${POSTGRES_USER:-postgres}
@@ -258,9 +258,9 @@ services:
container_name: figaro-dist-frontend-client1
environment:
CHOKIDAR_USEPOLLING: "true"
- VITE_API_BASE_URL: http://127.0.0.1:${DIST_CLIENT1_API_PORT:-8102}
- VITE_DISTRIBUTED_SERVER_API_BASE: http://127.0.0.1:${DIST_SERVER_API_PORT:-8100}
- VITE_DISTRIBUTED_GRPC_HOST: 127.0.0.1
+ VITE_API_BASE_URL: http://localhost:${DIST_CLIENT1_API_PORT:-8102}
+ VITE_DISTRIBUTED_SERVER_API_BASE: http://localhost:${DIST_SERVER_API_PORT:-8100}
+ VITE_DISTRIBUTED_GRPC_HOST: localhost
VITE_DISTRIBUTED_GRPC_PORT: "50052"
ports:
- "${DIST_CLIENT1_WEB_PORT:-5275}:5173"
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..02d9060
--- /dev/null
+++ b/package.json
@@ -0,0 +1,6 @@
+{
+ "dependencies": {
+ "react-markdown": "^10.1.0",
+ "remark-gfm": "^4.0.1"
+ }
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
new file mode 100644
index 0000000..234d54a
--- /dev/null
+++ b/pnpm-lock.yaml
@@ -0,0 +1,929 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .:
+ dependencies:
+ react-markdown:
+ specifier: ^10.1.0
+ version: 10.1.0(@types/react@19.2.14)(react@19.2.5)
+ remark-gfm:
+ specifier: ^4.0.1
+ version: 4.0.1
+
+packages:
+
+ '@types/debug@4.1.13':
+ resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
+
+ '@types/estree-jsx@1.0.5':
+ resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==}
+
+ '@types/estree@1.0.8':
+ resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
+
+ '@types/hast@3.0.4':
+ resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
+
+ '@types/mdast@4.0.4':
+ resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
+
+ '@types/ms@2.1.0':
+ resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
+
+ '@types/react@19.2.14':
+ resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
+
+ '@types/unist@2.0.11':
+ resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
+
+ '@types/unist@3.0.3':
+ resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
+
+ '@ungap/structured-clone@1.3.0':
+ resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
+
+ bail@2.0.2:
+ resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==}
+
+ ccount@2.0.1:
+ resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
+
+ character-entities-html4@2.1.0:
+ resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==}
+
+ character-entities-legacy@3.0.0:
+ resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==}
+
+ character-entities@2.0.2:
+ resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==}
+
+ character-reference-invalid@2.0.1:
+ resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==}
+
+ comma-separated-tokens@2.0.3:
+ resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
+
+ csstype@3.2.3:
+ resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+
+ debug@4.4.3:
+ resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
+ engines: {node: '>=6.0'}
+ peerDependencies:
+ supports-color: '*'
+ peerDependenciesMeta:
+ supports-color:
+ optional: true
+
+ decode-named-character-reference@1.3.0:
+ resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==}
+
+ dequal@2.0.3:
+ resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
+ engines: {node: '>=6'}
+
+ devlop@1.1.0:
+ resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
+
+ escape-string-regexp@5.0.0:
+ resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
+ engines: {node: '>=12'}
+
+ estree-util-is-identifier-name@3.0.0:
+ resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==}
+
+ extend@3.0.2:
+ resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
+
+ hast-util-to-jsx-runtime@2.3.6:
+ resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
+
+ hast-util-whitespace@3.0.0:
+ resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
+
+ html-url-attributes@3.0.1:
+ resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
+
+ inline-style-parser@0.2.7:
+ resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
+
+ is-alphabetical@2.0.1:
+ resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==}
+
+ is-alphanumerical@2.0.1:
+ resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==}
+
+ is-decimal@2.0.1:
+ resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==}
+
+ is-hexadecimal@2.0.1:
+ resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==}
+
+ is-plain-obj@4.1.0:
+ resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
+ engines: {node: '>=12'}
+
+ longest-streak@3.1.0:
+ resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
+
+ markdown-table@3.0.4:
+ resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
+
+ mdast-util-find-and-replace@3.0.2:
+ resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==}
+
+ mdast-util-from-markdown@2.0.3:
+ resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==}
+
+ mdast-util-gfm-autolink-literal@2.0.1:
+ resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==}
+
+ mdast-util-gfm-footnote@2.1.0:
+ resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==}
+
+ mdast-util-gfm-strikethrough@2.0.0:
+ resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==}
+
+ mdast-util-gfm-table@2.0.0:
+ resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==}
+
+ mdast-util-gfm-task-list-item@2.0.0:
+ resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==}
+
+ mdast-util-gfm@3.1.0:
+ resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==}
+
+ mdast-util-mdx-expression@2.0.1:
+ resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==}
+
+ mdast-util-mdx-jsx@3.2.0:
+ resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==}
+
+ mdast-util-mdxjs-esm@2.0.1:
+ resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==}
+
+ mdast-util-phrasing@4.1.0:
+ resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==}
+
+ mdast-util-to-hast@13.2.1:
+ resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==}
+
+ mdast-util-to-markdown@2.1.2:
+ resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==}
+
+ mdast-util-to-string@4.0.0:
+ resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==}
+
+ micromark-core-commonmark@2.0.3:
+ resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==}
+
+ micromark-extension-gfm-autolink-literal@2.1.0:
+ resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==}
+
+ micromark-extension-gfm-footnote@2.1.0:
+ resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==}
+
+ micromark-extension-gfm-strikethrough@2.1.0:
+ resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==}
+
+ micromark-extension-gfm-table@2.1.1:
+ resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==}
+
+ micromark-extension-gfm-tagfilter@2.0.0:
+ resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==}
+
+ micromark-extension-gfm-task-list-item@2.1.0:
+ resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==}
+
+ micromark-extension-gfm@3.0.0:
+ resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==}
+
+ micromark-factory-destination@2.0.1:
+ resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==}
+
+ micromark-factory-label@2.0.1:
+ resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==}
+
+ micromark-factory-space@2.0.1:
+ resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==}
+
+ micromark-factory-title@2.0.1:
+ resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==}
+
+ micromark-factory-whitespace@2.0.1:
+ resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==}
+
+ micromark-util-character@2.1.1:
+ resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==}
+
+ micromark-util-chunked@2.0.1:
+ resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==}
+
+ micromark-util-classify-character@2.0.1:
+ resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==}
+
+ micromark-util-combine-extensions@2.0.1:
+ resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==}
+
+ micromark-util-decode-numeric-character-reference@2.0.2:
+ resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==}
+
+ micromark-util-decode-string@2.0.1:
+ resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==}
+
+ micromark-util-encode@2.0.1:
+ resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==}
+
+ micromark-util-html-tag-name@2.0.1:
+ resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==}
+
+ micromark-util-normalize-identifier@2.0.1:
+ resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==}
+
+ micromark-util-resolve-all@2.0.1:
+ resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==}
+
+ micromark-util-sanitize-uri@2.0.1:
+ resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==}
+
+ micromark-util-subtokenize@2.1.0:
+ resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==}
+
+ micromark-util-symbol@2.0.1:
+ resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==}
+
+ micromark-util-types@2.0.2:
+ resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==}
+
+ micromark@4.0.2:
+ resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==}
+
+ ms@2.1.3:
+ resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+
+ parse-entities@4.0.2:
+ resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
+
+ property-information@7.1.0:
+ resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
+
+ react-markdown@10.1.0:
+ resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==}
+ peerDependencies:
+ '@types/react': '>=18'
+ react: '>=18'
+
+ react@19.2.5:
+ resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==}
+ engines: {node: '>=0.10.0'}
+
+ remark-gfm@4.0.1:
+ resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
+
+ remark-parse@11.0.0:
+ resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==}
+
+ remark-rehype@11.1.2:
+ resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==}
+
+ remark-stringify@11.0.0:
+ resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==}
+
+ space-separated-tokens@2.0.2:
+ resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
+
+ stringify-entities@4.0.4:
+ resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
+
+ style-to-js@1.1.21:
+ resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==}
+
+ style-to-object@1.0.14:
+ resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==}
+
+ trim-lines@3.0.1:
+ resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
+
+ trough@2.2.0:
+ resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==}
+
+ unified@11.0.5:
+ resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
+
+ unist-util-is@6.0.1:
+ resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==}
+
+ unist-util-position@5.0.0:
+ resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==}
+
+ unist-util-stringify-position@4.0.0:
+ resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==}
+
+ unist-util-visit-parents@6.0.2:
+ resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==}
+
+ unist-util-visit@5.1.0:
+ resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==}
+
+ vfile-message@4.0.3:
+ resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
+
+ vfile@6.0.3:
+ resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
+
+ zwitch@2.0.4:
+ resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
+
+snapshots:
+
+ '@types/debug@4.1.13':
+ dependencies:
+ '@types/ms': 2.1.0
+
+ '@types/estree-jsx@1.0.5':
+ dependencies:
+ '@types/estree': 1.0.8
+
+ '@types/estree@1.0.8': {}
+
+ '@types/hast@3.0.4':
+ dependencies:
+ '@types/unist': 3.0.3
+
+ '@types/mdast@4.0.4':
+ dependencies:
+ '@types/unist': 3.0.3
+
+ '@types/ms@2.1.0': {}
+
+ '@types/react@19.2.14':
+ dependencies:
+ csstype: 3.2.3
+
+ '@types/unist@2.0.11': {}
+
+ '@types/unist@3.0.3': {}
+
+ '@ungap/structured-clone@1.3.0': {}
+
+ bail@2.0.2: {}
+
+ ccount@2.0.1: {}
+
+ character-entities-html4@2.1.0: {}
+
+ character-entities-legacy@3.0.0: {}
+
+ character-entities@2.0.2: {}
+
+ character-reference-invalid@2.0.1: {}
+
+ comma-separated-tokens@2.0.3: {}
+
+ csstype@3.2.3: {}
+
+ debug@4.4.3:
+ dependencies:
+ ms: 2.1.3
+
+ decode-named-character-reference@1.3.0:
+ dependencies:
+ character-entities: 2.0.2
+
+ dequal@2.0.3: {}
+
+ devlop@1.1.0:
+ dependencies:
+ dequal: 2.0.3
+
+ escape-string-regexp@5.0.0: {}
+
+ estree-util-is-identifier-name@3.0.0: {}
+
+ extend@3.0.2: {}
+
+ hast-util-to-jsx-runtime@2.3.6:
+ dependencies:
+ '@types/estree': 1.0.8
+ '@types/hast': 3.0.4
+ '@types/unist': 3.0.3
+ comma-separated-tokens: 2.0.3
+ devlop: 1.1.0
+ estree-util-is-identifier-name: 3.0.0
+ hast-util-whitespace: 3.0.0
+ mdast-util-mdx-expression: 2.0.1
+ mdast-util-mdx-jsx: 3.2.0
+ mdast-util-mdxjs-esm: 2.0.1
+ property-information: 7.1.0
+ space-separated-tokens: 2.0.2
+ style-to-js: 1.1.21
+ unist-util-position: 5.0.0
+ vfile-message: 4.0.3
+ transitivePeerDependencies:
+ - supports-color
+
+ hast-util-whitespace@3.0.0:
+ dependencies:
+ '@types/hast': 3.0.4
+
+ html-url-attributes@3.0.1: {}
+
+ inline-style-parser@0.2.7: {}
+
+ is-alphabetical@2.0.1: {}
+
+ is-alphanumerical@2.0.1:
+ dependencies:
+ is-alphabetical: 2.0.1
+ is-decimal: 2.0.1
+
+ is-decimal@2.0.1: {}
+
+ is-hexadecimal@2.0.1: {}
+
+ is-plain-obj@4.1.0: {}
+
+ longest-streak@3.1.0: {}
+
+ markdown-table@3.0.4: {}
+
+ mdast-util-find-and-replace@3.0.2:
+ dependencies:
+ '@types/mdast': 4.0.4
+ escape-string-regexp: 5.0.0
+ unist-util-is: 6.0.1
+ unist-util-visit-parents: 6.0.2
+
+ mdast-util-from-markdown@2.0.3:
+ dependencies:
+ '@types/mdast': 4.0.4
+ '@types/unist': 3.0.3
+ decode-named-character-reference: 1.3.0
+ devlop: 1.1.0
+ mdast-util-to-string: 4.0.0
+ micromark: 4.0.2
+ micromark-util-decode-numeric-character-reference: 2.0.2
+ micromark-util-decode-string: 2.0.1
+ micromark-util-normalize-identifier: 2.0.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+ unist-util-stringify-position: 4.0.0
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-gfm-autolink-literal@2.0.1:
+ dependencies:
+ '@types/mdast': 4.0.4
+ ccount: 2.0.1
+ devlop: 1.1.0
+ mdast-util-find-and-replace: 3.0.2
+ micromark-util-character: 2.1.1
+
+ mdast-util-gfm-footnote@2.1.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ devlop: 1.1.0
+ mdast-util-from-markdown: 2.0.3
+ mdast-util-to-markdown: 2.1.2
+ micromark-util-normalize-identifier: 2.0.1
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-gfm-strikethrough@2.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ mdast-util-from-markdown: 2.0.3
+ mdast-util-to-markdown: 2.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-gfm-table@2.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ devlop: 1.1.0
+ markdown-table: 3.0.4
+ mdast-util-from-markdown: 2.0.3
+ mdast-util-to-markdown: 2.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-gfm-task-list-item@2.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ devlop: 1.1.0
+ mdast-util-from-markdown: 2.0.3
+ mdast-util-to-markdown: 2.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-gfm@3.1.0:
+ dependencies:
+ mdast-util-from-markdown: 2.0.3
+ mdast-util-gfm-autolink-literal: 2.0.1
+ mdast-util-gfm-footnote: 2.1.0
+ mdast-util-gfm-strikethrough: 2.0.0
+ mdast-util-gfm-table: 2.0.0
+ mdast-util-gfm-task-list-item: 2.0.0
+ mdast-util-to-markdown: 2.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-mdx-expression@2.0.1:
+ dependencies:
+ '@types/estree-jsx': 1.0.5
+ '@types/hast': 3.0.4
+ '@types/mdast': 4.0.4
+ devlop: 1.1.0
+ mdast-util-from-markdown: 2.0.3
+ mdast-util-to-markdown: 2.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-mdx-jsx@3.2.0:
+ dependencies:
+ '@types/estree-jsx': 1.0.5
+ '@types/hast': 3.0.4
+ '@types/mdast': 4.0.4
+ '@types/unist': 3.0.3
+ ccount: 2.0.1
+ devlop: 1.1.0
+ mdast-util-from-markdown: 2.0.3
+ mdast-util-to-markdown: 2.1.2
+ parse-entities: 4.0.2
+ stringify-entities: 4.0.4
+ unist-util-stringify-position: 4.0.0
+ vfile-message: 4.0.3
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-mdxjs-esm@2.0.1:
+ dependencies:
+ '@types/estree-jsx': 1.0.5
+ '@types/hast': 3.0.4
+ '@types/mdast': 4.0.4
+ devlop: 1.1.0
+ mdast-util-from-markdown: 2.0.3
+ mdast-util-to-markdown: 2.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-phrasing@4.1.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ unist-util-is: 6.0.1
+
+ mdast-util-to-hast@13.2.1:
+ dependencies:
+ '@types/hast': 3.0.4
+ '@types/mdast': 4.0.4
+ '@ungap/structured-clone': 1.3.0
+ devlop: 1.1.0
+ micromark-util-sanitize-uri: 2.0.1
+ trim-lines: 3.0.1
+ unist-util-position: 5.0.0
+ unist-util-visit: 5.1.0
+ vfile: 6.0.3
+
+ mdast-util-to-markdown@2.1.2:
+ dependencies:
+ '@types/mdast': 4.0.4
+ '@types/unist': 3.0.3
+ longest-streak: 3.1.0
+ mdast-util-phrasing: 4.1.0
+ mdast-util-to-string: 4.0.0
+ micromark-util-classify-character: 2.0.1
+ micromark-util-decode-string: 2.0.1
+ unist-util-visit: 5.1.0
+ zwitch: 2.0.4
+
+ mdast-util-to-string@4.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+
+ micromark-core-commonmark@2.0.3:
+ dependencies:
+ decode-named-character-reference: 1.3.0
+ devlop: 1.1.0
+ micromark-factory-destination: 2.0.1
+ micromark-factory-label: 2.0.1
+ micromark-factory-space: 2.0.1
+ micromark-factory-title: 2.0.1
+ micromark-factory-whitespace: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-chunked: 2.0.1
+ micromark-util-classify-character: 2.0.1
+ micromark-util-html-tag-name: 2.0.1
+ micromark-util-normalize-identifier: 2.0.1
+ micromark-util-resolve-all: 2.0.1
+ micromark-util-subtokenize: 2.1.0
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-extension-gfm-autolink-literal@2.1.0:
+ dependencies:
+ micromark-util-character: 2.1.1
+ micromark-util-sanitize-uri: 2.0.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-extension-gfm-footnote@2.1.0:
+ dependencies:
+ devlop: 1.1.0
+ micromark-core-commonmark: 2.0.3
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-normalize-identifier: 2.0.1
+ micromark-util-sanitize-uri: 2.0.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-extension-gfm-strikethrough@2.1.0:
+ dependencies:
+ devlop: 1.1.0
+ micromark-util-chunked: 2.0.1
+ micromark-util-classify-character: 2.0.1
+ micromark-util-resolve-all: 2.0.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-extension-gfm-table@2.1.1:
+ dependencies:
+ devlop: 1.1.0
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-extension-gfm-tagfilter@2.0.0:
+ dependencies:
+ micromark-util-types: 2.0.2
+
+ micromark-extension-gfm-task-list-item@2.1.0:
+ dependencies:
+ devlop: 1.1.0
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-extension-gfm@3.0.0:
+ dependencies:
+ micromark-extension-gfm-autolink-literal: 2.1.0
+ micromark-extension-gfm-footnote: 2.1.0
+ micromark-extension-gfm-strikethrough: 2.1.0
+ micromark-extension-gfm-table: 2.1.1
+ micromark-extension-gfm-tagfilter: 2.0.0
+ micromark-extension-gfm-task-list-item: 2.1.0
+ micromark-util-combine-extensions: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-factory-destination@2.0.1:
+ dependencies:
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-factory-label@2.0.1:
+ dependencies:
+ devlop: 1.1.0
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-factory-space@2.0.1:
+ dependencies:
+ micromark-util-character: 2.1.1
+ micromark-util-types: 2.0.2
+
+ micromark-factory-title@2.0.1:
+ dependencies:
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-factory-whitespace@2.0.1:
+ dependencies:
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-util-character@2.1.1:
+ dependencies:
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-util-chunked@2.0.1:
+ dependencies:
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-classify-character@2.0.1:
+ dependencies:
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-util-combine-extensions@2.0.1:
+ dependencies:
+ micromark-util-chunked: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-util-decode-numeric-character-reference@2.0.2:
+ dependencies:
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-decode-string@2.0.1:
+ dependencies:
+ decode-named-character-reference: 1.3.0
+ micromark-util-character: 2.1.1
+ micromark-util-decode-numeric-character-reference: 2.0.2
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-encode@2.0.1: {}
+
+ micromark-util-html-tag-name@2.0.1: {}
+
+ micromark-util-normalize-identifier@2.0.1:
+ dependencies:
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-resolve-all@2.0.1:
+ dependencies:
+ micromark-util-types: 2.0.2
+
+ micromark-util-sanitize-uri@2.0.1:
+ dependencies:
+ micromark-util-character: 2.1.1
+ micromark-util-encode: 2.0.1
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-subtokenize@2.1.0:
+ dependencies:
+ devlop: 1.1.0
+ micromark-util-chunked: 2.0.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-util-symbol@2.0.1: {}
+
+ micromark-util-types@2.0.2: {}
+
+ micromark@4.0.2:
+ dependencies:
+ '@types/debug': 4.1.13
+ debug: 4.4.3
+ decode-named-character-reference: 1.3.0
+ devlop: 1.1.0
+ micromark-core-commonmark: 2.0.3
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-chunked: 2.0.1
+ micromark-util-combine-extensions: 2.0.1
+ micromark-util-decode-numeric-character-reference: 2.0.2
+ micromark-util-encode: 2.0.1
+ micromark-util-normalize-identifier: 2.0.1
+ micromark-util-resolve-all: 2.0.1
+ micromark-util-sanitize-uri: 2.0.1
+ micromark-util-subtokenize: 2.1.0
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+ transitivePeerDependencies:
+ - supports-color
+
+ ms@2.1.3: {}
+
+ parse-entities@4.0.2:
+ dependencies:
+ '@types/unist': 2.0.11
+ character-entities-legacy: 3.0.0
+ character-reference-invalid: 2.0.1
+ decode-named-character-reference: 1.3.0
+ is-alphanumerical: 2.0.1
+ is-decimal: 2.0.1
+ is-hexadecimal: 2.0.1
+
+ property-information@7.1.0: {}
+
+ react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.5):
+ dependencies:
+ '@types/hast': 3.0.4
+ '@types/mdast': 4.0.4
+ '@types/react': 19.2.14
+ devlop: 1.1.0
+ hast-util-to-jsx-runtime: 2.3.6
+ html-url-attributes: 3.0.1
+ mdast-util-to-hast: 13.2.1
+ react: 19.2.5
+ remark-parse: 11.0.0
+ remark-rehype: 11.1.2
+ unified: 11.0.5
+ unist-util-visit: 5.1.0
+ vfile: 6.0.3
+ transitivePeerDependencies:
+ - supports-color
+
+ react@19.2.5: {}
+
+ remark-gfm@4.0.1:
+ dependencies:
+ '@types/mdast': 4.0.4
+ mdast-util-gfm: 3.1.0
+ micromark-extension-gfm: 3.0.0
+ remark-parse: 11.0.0
+ remark-stringify: 11.0.0
+ unified: 11.0.5
+ transitivePeerDependencies:
+ - supports-color
+
+ remark-parse@11.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ mdast-util-from-markdown: 2.0.3
+ micromark-util-types: 2.0.2
+ unified: 11.0.5
+ transitivePeerDependencies:
+ - supports-color
+
+ remark-rehype@11.1.2:
+ dependencies:
+ '@types/hast': 3.0.4
+ '@types/mdast': 4.0.4
+ mdast-util-to-hast: 13.2.1
+ unified: 11.0.5
+ vfile: 6.0.3
+
+ remark-stringify@11.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ mdast-util-to-markdown: 2.1.2
+ unified: 11.0.5
+
+ space-separated-tokens@2.0.2: {}
+
+ stringify-entities@4.0.4:
+ dependencies:
+ character-entities-html4: 2.1.0
+ character-entities-legacy: 3.0.0
+
+ style-to-js@1.1.21:
+ dependencies:
+ style-to-object: 1.0.14
+
+ style-to-object@1.0.14:
+ dependencies:
+ inline-style-parser: 0.2.7
+
+ trim-lines@3.0.1: {}
+
+ trough@2.2.0: {}
+
+ unified@11.0.5:
+ dependencies:
+ '@types/unist': 3.0.3
+ bail: 2.0.2
+ devlop: 1.1.0
+ extend: 3.0.2
+ is-plain-obj: 4.1.0
+ trough: 2.2.0
+ vfile: 6.0.3
+
+ unist-util-is@6.0.1:
+ dependencies:
+ '@types/unist': 3.0.3
+
+ unist-util-position@5.0.0:
+ dependencies:
+ '@types/unist': 3.0.3
+
+ unist-util-stringify-position@4.0.0:
+ dependencies:
+ '@types/unist': 3.0.3
+
+ unist-util-visit-parents@6.0.2:
+ dependencies:
+ '@types/unist': 3.0.3
+ unist-util-is: 6.0.1
+
+ unist-util-visit@5.1.0:
+ dependencies:
+ '@types/unist': 3.0.3
+ unist-util-is: 6.0.1
+ unist-util-visit-parents: 6.0.2
+
+ vfile-message@4.0.3:
+ dependencies:
+ '@types/unist': 3.0.3
+ unist-util-stringify-position: 4.0.0
+
+ vfile@6.0.3:
+ dependencies:
+ '@types/unist': 3.0.3
+ vfile-message: 4.0.3
+
+ zwitch@2.0.4: {}
diff --git a/scripts/docker-dev-build-proxy.sh b/scripts/docker-dev-build-proxy.sh
index 46d1133..f19600f 100755
--- a/scripts/docker-dev-build-proxy.sh
+++ b/scripts/docker-dev-build-proxy.sh
@@ -5,11 +5,11 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
COMPOSE_FILE_REL="docker-compose.dev.yml"
SERVICE="${SERVICE:-backend}"
-PROXY_HOSTPORT="${PROXY_HOSTPORT:-127.0.0.1:8888}"
+PROXY_HOSTPORT="${PROXY_HOSTPORT:-localhost:8888}"
HTTP_PROXY_VALUE="${HTTP_PROXY:-http://${PROXY_HOSTPORT}}"
HTTPS_PROXY_VALUE="${HTTPS_PROXY:-${HTTP_PROXY_VALUE}}"
ALL_PROXY_VALUE="${ALL_PROXY-${HTTP_PROXY_VALUE}}"
-NO_PROXY_VALUE="${NO_PROXY:-localhost,127.0.0.1,postgres}"
+NO_PROXY_VALUE="${NO_PROXY:-localhost,localhost,postgres}"
USE_NO_CACHE=0
USE_PULL=0
diff --git a/scripts/docker-dev-up.sh b/scripts/docker-dev-up.sh
index 7d80a33..75bccd3 100755
--- a/scripts/docker-dev-up.sh
+++ b/scripts/docker-dev-up.sh
@@ -18,9 +18,9 @@ echo "[figaro-dev] starting postgres + backend + frontend..."
echo
echo "[figaro-dev] started"
-echo " Frontend: http://127.0.0.1:5173"
-echo " Backend : http://127.0.0.1:8000"
-echo " API Docs: http://127.0.0.1:8000/docs"
+echo " Frontend: http://localhost:5173"
+echo " Backend : http://localhost:8000"
+echo " API Docs: http://localhost:8000/docs"
echo
echo "Logs:"
echo " docker compose -f ${COMPOSE_FILE} logs -f --tail=200"
diff --git a/scripts/docker-dist-up.sh b/scripts/docker-dist-up.sh
index a7a2508..83b8ff3 100755
--- a/scripts/docker-dist-up.sh
+++ b/scripts/docker-dist-up.sh
@@ -44,17 +44,17 @@ echo
echo "[figaro-dist] started"
echo " Network mode: host (backend services)"
echo " Server:"
-echo " Frontend: http://127.0.0.1:${SERVER_WEB_PORT}"
-echo " Backend : http://127.0.0.1:${SERVER_API_PORT}"
-echo " OpenAPI : http://127.0.0.1:${SERVER_API_PORT}/docs"
+echo " Frontend: http://localhost:${SERVER_WEB_PORT}"
+echo " Backend : http://localhost:${SERVER_API_PORT}"
+echo " OpenAPI : http://localhost:${SERVER_API_PORT}/docs"
echo " Client0:"
-echo " Frontend: http://127.0.0.1:${CLIENT0_WEB_PORT}"
-echo " Backend : http://127.0.0.1:${CLIENT0_API_PORT}"
-echo " OpenAPI : http://127.0.0.1:${CLIENT0_API_PORT}/docs"
+echo " Frontend: http://localhost:${CLIENT0_WEB_PORT}"
+echo " Backend : http://localhost:${CLIENT0_API_PORT}"
+echo " OpenAPI : http://localhost:${CLIENT0_API_PORT}/docs"
echo " Client1:"
-echo " Frontend: http://127.0.0.1:${CLIENT1_WEB_PORT}"
-echo " Backend : http://127.0.0.1:${CLIENT1_API_PORT}"
-echo " OpenAPI : http://127.0.0.1:${CLIENT1_API_PORT}/docs"
+echo " Frontend: http://localhost:${CLIENT1_WEB_PORT}"
+echo " Backend : http://localhost:${CLIENT1_API_PORT}"
+echo " OpenAPI : http://localhost:${CLIENT1_API_PORT}/docs"
echo
echo "Logs:"
echo " docker compose -f ${COMPOSE_FILE} logs -f --tail=200"